外观
数据库 Schema 参考
本文是控制面数据库 schema 的单一事实源——表、字段、关系、写入路径、迁移。
生产数据库是 PostgreSQL(主控制面经 Hyperdrive 访问),由两个控制面实现共享同一份 schema:
- 主控制面(TS 面)
apps/control-server-ts:生产 schema 的权威定义在apps/control-server-ts/src/schema.ts(drizzle),迁移由 drizzle-kit 管理(见文末 TS 面 schema(drizzle))。 - 温备控制面(Python 面)
apps/control-server:apps/control-server/app/db/models/下的 SQLAlchemy ORM 与 drizzle schema 保持同构镜像(温备 + 本地开发用)。
两面新增表 / 列必须同步落到 drizzle schema 与 Python ORM。列名 / 类型 / 语义两面一致, 本文各表章节按 Python ORM 源码逐列描述。
Python 面所有表共享同一个 SQLAlchemy 声明性基类 Base(apps/control-server/app/db/base.py:20)。Base.metadata 注入了统一的命名约定 (NAMING_CONVENTION,base.py:11),保证索引 / 外键 / 唯一键 / 主键名跨数据库稳定一致:
| 类型 | 模板 |
|---|---|
索引 ix | ix_%(table_name)s_%(column_0_N_name)s |
唯一键 uq | uq_%(table_name)s_%(column_0_N_name)s |
检查 ck | ck_%(table_name)s_%(constraint_name)s |
外键 fk | fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s |
主键 pk | pk_%(table_name)s |
引擎装配在 apps/control-server/app/db/engine.py:Database 封装一个异步 AsyncEngine + async_sessionmaker(expire_on_commit=False,pool_pre_ping=True)。 对 SQLite 有两处特殊处理:
check_same_thread=False(engine.py:28)——异步多任务下放开 same-thread 限制。- 每个新连接执行
PRAGMA foreign_keys=ON(engine.py:43)——SQLite 默认外键关闭,不开 则所有ondelete子句(如Peering.remote_node_id/EnrollmentToken.node_id的SET NULL、AgentToken.node_id的CASCADE)变成死字。
时间戳列统一用 DateTime(timezone=True),Python 侧默认 datetime.now(timezone.utc), DB 侧 server_default=func.now();updated_at 列额外带 onupdate。
聚合根与模型概览
数据模型有三个核心组织点:
Node是节点的聚合根。 一行nodes持有该节点的身份字段(node_id主键、asn、router_id、loopback、前缀)、base_template(DesiredState 中不来自子表的部分)以及current_generation(已发布的最新世代号)。它的子表(agent_tokens、peerings、wg_interfaces、bgp_sessions、generations)全部cascade="all, delete-orphan", 删节点连带删子行。WG 节点身份(公钥 + 私钥托管密文)也是节点级事实,落在nodes上。Peering是网络对等关系的聚合根。 一条 peering 对应 0..1 条WgInterface+ 0..N 条BgpSession(均cascade="all, delete-orphan")。local_node_id指本端节点 (CASCADE),remote_node_id可空、指向另一受管节点(SET NULL)——materializer 据此 现取现填对端 WG 公钥并区分内外部互联(见「Materialize 写入路径」)。DnsGroup是共享模型,节点通过Node.dns_group_id订阅。 DNS 不再按节点保存;多个 节点指向同一组即拿到同一份配置 = anycast / 任播。组被删时Node.dns_group_id置 NULL (节点停跑 DNS,不连带删节点)。
关系一览(→ 表示外键指向;括号内为 ondelete):
nodes (聚合根)
├─ agent_tokens.node_id → nodes.node_id (CASCADE)
├─ enrollment_tokens.node_id → nodes.node_id (SET NULL)
├─ generations.node_id → nodes.node_id (CASCADE) UNIQUE(node_id, generation)
├─ peerings.local_node_id → nodes.node_id (CASCADE) UNIQUE(local_node_id, name)
├─ peerings.remote_node_id → nodes.node_id (SET NULL)
├─ wg_interfaces.node_id → nodes.node_id (CASCADE) UNIQUE(node_id, name)
│ └─ wg_interfaces.peering_id → peerings.id (SET NULL)
├─ bgp_sessions.node_id → nodes.node_id (CASCADE) UNIQUE(node_id, name)
│ └─ bgp_sessions.peering_id → peerings.id (SET NULL)
├─ node_status.node_id → nodes.node_id (CASCADE) (PK)
├─ node_status_events.node_id → nodes.node_id (CASCADE)
├─ node_routing.node_id → nodes.node_id (CASCADE) (PK)
├─ node_route_entries.node_id → nodes.node_id (CASCADE)
├─ node_route_prefix_hashes.node_id→ nodes.node_id (CASCADE) (PK)
├─ node_routing_events.node_id → nodes.node_id (CASCADE)
├─ node_traffic_rollup.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ node_traffic_last_sample.node_id→ nodes.node_id (CASCADE) (PK)
├─ node_iface_traffic_rollup.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ node_iface_traffic_last.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ node_metrics_rollup.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ node_metrics_last.node_id → nodes.node_id (CASCADE) (PK)
├─ bgp_flap_state.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ bgp_flap_transitions.node_id → nodes.node_id (CASCADE)
├─ node_prefix_flaps.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ node_prefix_flap_meta.node_id → nodes.node_id (CASCADE) (PK)
├─ node_prefix_flap_paths.node_id → nodes.node_id (CASCADE) (PK 复合)
├─ prefix_flap_rate_rollup.node_id → nodes.node_id (CASCADE) (PK 复合)
└─ nodes.dns_group_id → dns_groups.id (SET NULL)
dns_groups (共享聚合根)
└─ dns_group_zones.dns_group_id → dns_groups.id (CASCADE) UNIQUE(dns_group_id, zone)
└─ dns_records.dns_group_zone_id → dns_group_zones.id (CASCADE)
admin_users
└─ admin_sessions.user_id → admin_users.id (CASCADE)
flap_alerts (独立;node_id 有意不设外键,告警不随节点删除蒸发)
flap_activity_rollup (独立,无外键;fleet 级 60s 活跃度时间线)
admin_audit_log (独立,无外键)
registry_asn / registry_meta (独立,无外键;DN42 registry 只读副本)WgInterface / BgpSession 采用「索引列 + spec JSON」双层结构:少量字段(name / node_id / peering_id / enabled / kind / remote_asn)做查询 / 约束,完整的 Pydantic schema dump 放 spec 列,后端不必随 schema 演进改表(peering.py:6)。索引列由 apply_spec() 从校验过的 spec 派生投影,杜绝列与 JSON 漂移。
节点与身份
nodes
节点聚合根。apps/control-server/app/db/models/node.py:31。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK | 节点稳定字符串 ID(如 edge1)。 |
site | String(32) | 可空 | 站点标识。 |
asn | Integer | NOT NULL | 节点 ASN。 |
router_id | String(64) | NOT NULL | BGP router-id。 |
loopback_ipv4 | String(64) | 可空 | 身份 loopback v4。 |
loopback_ipv6 | String(64) | 可空 | 身份 loopback v6。 |
link_local | String(64) | 可空 | 节点级 IPv6 link-local(fe80::/10,不带 %zone)。单一真相源:materializer 把 <link_local>/64 派生到所有外部 eBGP WG 接口的 addresses(见 desired-state.md)。迁移 a4b5c6d7e8f9 引入。 |
ipv4_prefixes | JSON | NOT NULL,默认 [] | 宣告的 v4 前缀列表。 |
ipv6_prefixes | JSON | NOT NULL,默认 [] | 宣告的 v6 前缀列表。 |
inventory | JSON | NOT NULL,默认 {} | agent 注册带来的可读元信息。 |
labels | JSON | NOT NULL,默认 {} | 标签。 |
base_template | JSON | NOT NULL,默认 {} | DesiredState 去掉 generation/interfaces/bgp_sessions/dns 后的部分(runtime / bird / templates / schema_version 等)。 |
current_generation | Integer | NOT NULL,默认 0 | 已发布最新世代号;0 = 尚未发布。 |
lifecycle | String(16) | NOT NULL,默认 active,server_default active | active / decommissioned。退役态 materialize 产空 interfaces/bgp/dns。 |
dns_group_id | Integer | FK→dns_groups.id(SET NULL),可空,index | 订阅的共享 DNS 组;NULL = 不部署 DNS。 |
wireguard_public_key | String(64) | 可空 | 节点 WG 公钥(agent 上报,注册一致性校验的权威事实,传播进对端 peer 配置)。 |
wireguard_private_key_escrow | Text | 可空 | 节点 WG 私钥经恢复公钥 RSA-OAEP 封装的密文;控制面只存不解。 |
created_at / updated_at | DateTime(tz) | server_default now,updated_at 带 onupdate | 时间戳。 |
关系:agent_tokens / peerings(foreign_keys=Peering.local_node_id)/ wg_interfaces / bgp_sessions / generations 均 cascade="all, delete-orphan";dns_group 为 joined 只读。
agent_tokens
长期 Bearer token,绑定具体节点。node.py:154。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
token | String(128) | PK | 非机密查找键 id(形如 agt_xxxx),明文 secret 永不落库。 |
token_hash | String(128) | NOT NULL,unique,index | 完整 Bearer 的 sha256;校验只走它。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 绑定节点。 |
agent_id | String(128) | NOT NULL | agent 标识。 |
issued_at | DateTime(tz) | server_default now | 签发时刻。 |
expires_at | DateTime(tz) | 可空 | 非空即会过期。 |
revoked_at | DateTime(tz) | 可空 | 吊销时刻。 |
enrollment_tokens
一次性注册 token。node.py:129。与 AgentToken 同安全模型。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
token | String(128) | PK | 非机密查找键 id(ent_*)。 |
token_hash | String(128) | NOT NULL,unique,index | sha256;校验只走它。 |
node_id | String(64) | FK→nodes.node_id(SET NULL),可空 | 空 = 尚未指定节点(管理员预生成)。 |
description | String(256) | 可空 | 备注。 |
expires_at | DateTime(tz) | 可空 | 过期时刻。 |
used_at | DateTime(tz) | 可空 | 非空 = 已消费(一次性语义)。 |
created_at | DateTime(tz) | server_default now | 创建时刻。 |
pending_registrations
待审批的 agent 注册请求。node.py:179。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
requested_node_id | String(64) | NOT NULL,index | 请求的节点 ID(重复注册刷新同一行)。 |
hostname | String(255) | 可空 | 主机名。 |
inventory | JSON | NOT NULL,默认 {} | 上报的硬件 / 系统信息。 |
status | String(16) | NOT NULL,默认 pending,index | pending / approved / rejected。 |
note | String(256) | 可空 | 审批备注。 |
created_at / updated_at | DateTime(tz) | server_default now,updated_at 带 onupdate | 时间戳。 |
部分唯一索引(迁移 b5c6d7e8f9a0):UNIQUE(requested_node_id) WHERE status='pending'——同一节点同时只允许一条 pending 记录(重复注册刷新同一行),历史 approved/rejected 行不受限。
期望状态发布
generations
每节点的已发布 DesiredState 世代快照。apps/control-server/app/db/models/generation.py:28。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点。 |
generation | Integer | NOT NULL | 世代号,节点内严格单调递增。 |
snapshot | JSON | NOT NULL | 控制面渲染好的完整 DesiredState JSON,agent 直接读。 |
reason | String(256) | 可空 | 发布原因备注。 |
published_at | DateTime(tz) | server_default now | 发布时刻。 |
唯一约束 UNIQUE(node_id, generation)(uq_generations_node_id_generation)。当前世代由 Node.current_generation 指向。
网络配置
peerings
对等关系聚合根。apps/control-server/app/db/models/peering.py:38。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
local_node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 本端节点。 |
remote_node_id | String(64) | FK→nodes.node_id(SET NULL),可空,index | 对端受管节点(外部对等留空);materializer 据此取对端 WG 公钥。 |
name | String(64) | NOT NULL | peering 名。 |
remote_asn | Integer | NOT NULL | 对端 ASN。 |
remote_label | String(128) | 可空 | 对端标签。 |
is_internal | Boolean | NOT NULL,默认 False | 内部互联(iBGP/OSPF)标志;影响 LLA 注入。 |
enabled | Boolean | NOT NULL,默认 True | 启用标志。 |
notes | String(512) | 可空 | 备注。 |
created_at / updated_at | DateTime(tz) | server_default now,updated_at 带 onupdate | 时间戳。 |
唯一约束 UNIQUE(local_node_id, name)(uq_peerings_local_node_id_name)。关系: wg_interfaces / bgp_sessions 均 cascade="all, delete-orphan"(selectin);local_node 与 remote_node 用各自的 foreign_keys 消歧。
wg_interfaces
节点接口资源(WireGuard / GRE / dummy 等)。peering.py:91。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点。 |
peering_id | Integer | FK→peerings.id(SET NULL),可空,index | 空 = 纯节点级接口(dummy lo、IGP 隧道等)。 |
name | String(64) | NOT NULL | 接口名(apply_spec 从 spec 派生)。 |
kind | String(32) | NOT NULL | 接口种类(apply_spec 从 spec.kind 派生)。 |
enabled | Boolean | NOT NULL,默认 True | 控制面列;disabled 接口不进 snapshot。 |
spec | JSON | NOT NULL | 完整 InterfaceSpec 的 Pydantic dump。 |
sort_order | Integer | NOT NULL,默认 0 | 排序键(materializer 按 sort_order, id 取)。 |
唯一约束 UNIQUE(node_id, name)(uq_wg_interfaces_node_id_name)。apply_spec(InterfaceSpec) (peering.py:119)投影 name / kind / spec;enabled 由调用方单独维护。
bgp_sessions
节点上的一条 BGP 会话。peering.py:132。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点。 |
peering_id | Integer | FK→peerings.id(SET NULL),可空,index | 关联 peering。 |
name | String(64) | NOT NULL | 会话名(从 spec 派生)。 |
remote_asn | Integer | NOT NULL | 对端 ASN(从 spec 派生)。 |
enabled | Boolean | NOT NULL,默认 True | 从 spec 派生;disabled 会话仍进 snapshot 但 enabled=False。 |
spec | JSON | NOT NULL | 完整 BgpSessionSpec 的 Pydantic dump。 |
sort_order | Integer | NOT NULL,默认 0 | 排序键。 |
唯一约束 UNIQUE(node_id, name)(uq_bgp_sessions_node_id_name)。apply_spec(BgpSessionSpec) (peering.py:156)投影 name / remote_asn / enabled / spec;materializer 读取时 _bgp_payload 把列 enabled 投影回 spec。
共享 DNS
记录为中心的共享 DNS 三级模型(apps/control-server/app/db/models/dns.py)。节点经 Node.dns_group_id 订阅;materializer 把 组→zone→记录 组装成 DnsSpec。
dns_groups
共享 DNS 组:一份可被多节点订阅的配置。dns.py:36。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
name | String(255) | NOT NULL,unique | 组名。 |
bind_addresses | JSON | NOT NULL,默认 [] | 提供 DNS 服务的 IP(anycast 监听地址)。 |
cache_ttl_seconds | Integer | NOT NULL,默认 300 | 缓存 TTL。 |
forwards | JSON | NOT NULL,默认 [] | 转发(递归 resolver)配置;纯权威留空。 |
enabled | Boolean | NOT NULL,默认 True | 禁用则订阅节点不部署 DNS。 |
created_at / updated_at | DateTime(tz) | server_default now,updated_at 带 onupdate | 时间戳。 |
关系:zones cascade="all, delete-orphan"。
dns_group_zones
组声明的权威 zone + 可选 SOA 覆盖。dns.py:67。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
dns_group_id | Integer | FK→dns_groups.id(CASCADE),NOT NULL,index | 所属组。 |
zone | String(255) | NOT NULL | 权威 zone(正向 example.dn42 / 反向 20.172.in-addr.arpa)。 |
primary_ns | String(255) | 可空 | SOA 主 NS(留空自动 ns.<zone>)。 |
admin_email | String(255) | 可空 | SOA 管理邮箱(留空自动 hostmaster.<zone>)。 |
soa_refresh | Integer | 可空 | SOA refresh(留空默认)。 |
soa_retry | Integer | 可空 | SOA retry。 |
soa_expire | Integer | 可空 | SOA expire。 |
soa_minimum | Integer | 可空 | SOA minimum。 |
default_ttl | Integer | 可空 | 默认 TTL。 |
enabled | Boolean | NOT NULL,默认 True | 禁用 zone 不输出。 |
created_at / updated_at | DateTime(tz) | server_default now,updated_at 带 onupdate | 时间戳。 |
唯一约束 UNIQUE(dns_group_id, zone)(uq_dns_group_zones_group_id_zone)。关系:recordscascade="all, delete-orphan"。
dns_records
扁平 DNS 资源记录。dns.py:114。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
dns_group_zone_id | Integer | FK→dns_group_zones.id(CASCADE),NOT NULL,index | 所属 zone。 |
name | String(255) | NOT NULL | zone 内主机名(相对 / @ / FQDN)。 |
type | String(16) | NOT NULL | 记录类型(rDNS 即反向 zone 下 PTR)。 |
content | Text | NOT NULL | 记录值。 |
ttl | Integer | 可空 | 记录 TTL。 |
comment | Text | 可空 | 仅供 WebUI,不进 zone 文件。 |
enabled | Boolean | NOT NULL,默认 True | disabled 记录不输出。 |
sort_order | Integer | NOT NULL,默认 0 | 排序键。 |
created_at / updated_at | DateTime(tz) | server_default now,updated_at 带 onupdate | 时间戳。 |
运行状态
node_status
每节点最新运行时健康,随上报 upsert。apps/control-server/app/db/models/node_status.py:30。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK,FK→nodes.node_id(CASCADE) | 主键即外键。 |
desired_generation | Integer | 可空 | 控制面已发布的期望世代。 |
observed_generation | Integer | 可空 | agent 实际观测到的世代。 |
last_report_status | String(32) | 可空 | 最近对账状态(succeeded/degraded/failed/skipped)。 |
last_apply_status | String(32) | 可空 | 最近 apply 状态。 |
drift_count | Integer | NOT NULL,默认 0 | 漂移计数。 |
health | String(16) | NOT NULL,默认 unknown | 派生健康。库里只落 4 态:ok/degraded/stale/unknown;第五态 down 是读侧按时间阈值叠加的覆盖(不改库,见 internals/control-server.md)。 |
last_snapshot / last_report / last_apply | JSON | 可空 | 最近一次完整 payload(面板下钻)。 |
bgp_summary | JSON | 可空 | 快照摄入时预提取的 payload.bgp_protocols 原样副本(P2.7,与 TS 面同构)——fleet 聚合读侧不再解析 last_snapshot 大列。node_status.py:60,TS 面 schema.ts:260。 |
peer_rates | JSON | 可空 | 与上一份快照差分得出的 per-peer 速率(compute_peer_rates,两面同算法同形态;P2.7 预提取列)。node_status.py:61,TS 面 schema.ts:261。 |
last_snapshot_at / last_report_at / last_apply_at | DateTime(tz) | 可空 | 对应时刻。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
node_status_events
append-only 上报历史。node_status.py:75。写入按节点+种类裁剪到最近 _HISTORY_KEEP 条。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点。 |
kind | String(16) | NOT NULL,index | snapshot / report / apply。 |
generation | Integer | 可空 | 关联世代。 |
status | String(32) | 可空 | 状态。 |
payload | JSON | NOT NULL | 完整上报 payload。 |
created_at | DateTime(tz) | server_default now,index | 写入时刻。 |
路由
agent 周期上报的 RoutingTableSnapshot 持久化在这里,与 reconcile 健康分开 (apps/control-server/app/db/models/routing.py)。路由明细的写路径为差分写: per-prefix 哈希基准表(node_route_prefix_hashes)门控,只重写实际变化的前缀 (演进脉络见 ROADMAP)。
node_routing
每节点最新路由全表 + 预聚合,随上报 upsert。routing.py:27。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK,FK→nodes.node_id(CASCADE) | 主键即外键。 |
observation | String(16) | NOT NULL,默认 not-observed | 采集状态(observed/unavailable/not-observed)。 |
captured_at | DateTime(tz) | 可空 | 采集时刻。 |
route_count | Integer | NOT NULL,默认 0 | 唯一前缀数(按最优路径去重)。 |
route_count_v4 / route_count_v6 | Integer | NOT NULL,默认 0 | 分族计数。 |
rpki_valid / rpki_invalid / rpki_not_found | Integer | NOT NULL,默认 0 | RPKI 分布计数(rpki_unknown 已被迁移 a3b4c5d6e7f8 删除)。 |
aggregates | JSON | 可空 | 预聚合(origins / prefix_lengths / as_path_lengths / peers / routes_hash)。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
历史的
routes单 JSON 列已由迁移f2a3b4c5d6e7删除,明细改存node_route_entries。
node_route_entries
逐路由明细,每路由一行。routing.py:61。前缀检索走 SQL WHERE + 索引 + LIMIT;写入 两级门控:全表内容哈希未变整份跳过;变化时对比 node_route_prefix_hashes 基准做差分写 (只 delete+insert 哈希变化 / 消失的前缀,见下)。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL | 所属节点(不单独建 index,由复合索引最左前缀覆盖)。 |
prefix | String(64) | NOT NULL | 路由前缀。 |
is_v6 | Boolean | NOT NULL,默认 False,server_default 0 | 地址族(按 prefix 是否含冒号预判)。 |
local | Boolean | NOT NULL,默认 False,server_default 0 | 本地起源(static/direct/device),供 scope 过滤。 |
primary | Boolean | NOT NULL,默认 False,server_default 0 | 是否最优路径。 |
origin_asn | Integer | 可空 | 起源 AS。 |
protocol | String(128) | 可空 | 协议来源。 |
rpki | String(16) | 可空 | RPKI 状态。 |
next_hop | String(128) | 可空 | 下一跳。 |
as_path / communities / large_communities | JSON | 可空 | 路径与团体属性。 |
复合索引(均以 node_id 打头):ix_node_route_entries_node_v6 (node_id, is_v6)、 ix_node_route_entries_node_local (node_id, local)、 ix_node_route_entries_node_prefix (node_id, prefix)。
node_route_prefix_hashes
每节点 per-prefix 规范化哈希基准(差分写门控,P1)。routing.py:98。record_snapshot 全表哈希变化时不再整表重写明细,而是对比这份基准:只 delete+insert 哈希变化 / 消失的前缀 (实测每轮实际变化通常几十行,对比整表 ~12.8k 行)。哈希算法与 TS 面逐字节一致 (FNV-1a64 over 定序数组 JSON);跨面不一致的最坏后果 = 一次无害的整表重写(基准缺失分支)。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK,FK→nodes.node_id(CASCADE) | 主键即外键。 |
hashes | JSON | NOT NULL,默认 {} | prefix → 规范化哈希 映射(整节点一行一份基准)。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
node_routing_events
append-only 路由表时间序列(每次全表快照一条 + churn)。routing.py:121。按节点裁剪到最近 _HISTORY_KEEP 条。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点。 |
captured_at | DateTime(tz) | 可空 | 采集时刻。 |
route_count / route_count_v4 / route_count_v6 | Integer | NOT NULL,默认 0 | 表规模计数。 |
rpki_valid / rpki_invalid / rpki_not_found | Integer | NOT NULL,默认 0 | RPKI 计数(rpki_unknown 已被迁移 a3b4c5d6e7f8 删除)。 |
announced / withdrawn | Integer | NOT NULL,默认 0 | 相对上次快照的 churn(新增 / 撤销前缀数)。 |
created_at | DateTime(tz) | server_default now,index | 写入时刻。 |
流量存档
agent 30s 轻量循环上报的 WG 流量采样主要落 Redis 热窗口(2h、30s 分辨率,供实时吞吐视图),Redis 是旁路、可丢;这组小表是它的持久化存档 + 差分 prev 回退(apps/control-server/app/db/models/traffic.py),保证 Redis 不可用时 5min 粒度历史与速率差分不断档。
node_traffic_rollup
每节点每 5min 桶的 WG 吞吐速率降采样,随采样 upsert(累加进桶)。迁移 c0d1e2f3a4b5。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
bucket_start | BigInteger | PK(复合) | 桶起点(epoch 秒,对齐 5min)。 |
rx_rate_sum / tx_rate_sum | Float | NOT NULL,默认 0.0 | 落进该桶的各次瞬时速率(字节/秒)之和;读时 sum/count 得均值。 |
sample_count | Integer | NOT NULL,默认 0 | 采样次数。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
node_traffic_last_sample
每节点最近一次累计采样(差分的持久 prev),随采样 upsert。迁移 d1e2f3a4b5c6。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK,FK→nodes.node_id(CASCADE) | 主键即外键。 |
captured_at | String(64) | NOT NULL | 采样时刻(ISO8601 字符串,与 Redis 热窗口元素同形)。 |
rx_bytes / tx_bytes | BigInteger | NOT NULL,默认 0 | 全 peer 累计收 / 发字节。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
node_iface_traffic_rollup / node_iface_traffic_last
按 (节点, WG 接口) 粒度的 5min 速率存档与差分 prev(「按对端 AS 划分流量」的数据层)。数据源是 runtime snapshot 里的 per-peer 累计字节;读侧经 接口 → peering → remote_asn 归并出按 AS 的序列/份额。迁移 f3a4b5c6d7e8。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
interface | String(64) | PK(复合) | WG 接口名。 |
bucket_start | BigInteger | PK(复合,仅 rollup) | 桶起点(epoch 秒)。 |
| 其余列 | — | — | 同上两表(rollup 含 *_rate_sum/sample_count;last 含 captured_at/*_bytes)。 |
node_metrics_rollup / node_metrics_last
agent 自观测(self_metrics)的 5min 存档与失败数差分 prev(apps/control-server/app/db/models/metrics.py),GET /ui/nodes/metrics 机群指标矩阵的数据层——快照事件历史只留 100 条(≈8h),7d 观测窗口靠这张存档(保留 60 天,时间截断)。快照入库时顺手累加,旧 agent 快照不带 self_metrics 则 no-op。迁移 a5b6c7d8e9f0。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
bucket_start | BigInteger | PK(复合,仅 rollup) | 桶起点(epoch 秒,对齐 5min)。 |
cpu_sum / cpu_count | Float / Integer | NOT NULL,默认 0 | CPU 百分比的和与计数(分开计数:AgentSelfMetrics 字段全部可选),均值 = 和/计数。 |
rss_sum / rss_count | Float / Integer | NOT NULL,默认 0 | RSS(MB)的和与计数。 |
reconcile_failures | Integer | NOT NULL,默认 0 | 桶内 reconcile 失败次数(total_failures 累计计数与 prev 差分后累加;agent 重启归零时按当前值计)。 |
sample_count | Integer | NOT NULL,默认 0 | 落桶快照数(>0 即「该桶有上报」)。 |
captured_at / total_failures | String(64) / BigInteger | (仅 last 表)NOT NULL / 可空 | 最近一次采样时刻与 total_failures 累计值(差分的持久 prev,仅向前推进)。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
Flap 检测
会话级(P0)与前缀级(P1)两级 flap 检测、告警事件(P2)与时序存档的数据层 (apps/control-server/app/db/models/flap.py、prefix_flap.py)。前缀级打分热态存 KV(TS 面)/ Redis(Python 面)blob(pflap:<node>,见下文「前缀级热态」),SQL 只留 meta 与速率存档。检测机制全貌见 flap 检测内部, 演进脉络见 ROADMAP。
bgp_flap_state
每 (节点, bird 协议名) 一行的会话级 flap 打分状态,随快照 upsert(乱序快照跳过)。 flap.py:27。数据源是 runtime snapshot 里的 bgp_protocols(~5min 节奏)——快照入库时与 上一份逐会话比对,发现状态转移(或 state 同为 Established 但 since 变化的「隐性重连」) 即记转移并给该会话的指数衰减 flap 分加分;零 agent 变更,纯控制面推导。会话从快照消失 (配置删除)时行同步删除。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
name | String(128) | PK(复合) | bird protocol 名(快照内唯一)。 |
session | String(128) | 可空 | 反查到的 schema 会话名(eBGP 有,iBGP 合成会话为 NULL)。 |
last_state | String(32) | NOT NULL | 上次观测的 state(比对基准)。 |
last_since | String(64) | 可空 | 上次观测的 since(since 变而 state 不变 = 隐性重连)。 |
captured_at | String(64) | NOT NULL | 分数锚点 = 上次纳入比对的快照 captured_at(ISO8601);读取时从锚点按半衰期续衰到 now。 |
score | Float | NOT NULL,默认 0.0 | 指数衰减 flap 分(每次 flap 事件 +1 后随时间衰减,半衰期见 services/flaps.py)。 |
transitions_total | Integer | NOT NULL,默认 0 | 累计状态转移数(不衰减,供展示)。 |
flaps_total | Integer | NOT NULL,默认 0 | 其中计分的 flap 事件数。 |
last_transition_at | String(64) | 可空 | 最近一次转移时刻。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
bgp_flap_transitions
append-only 会话状态转移历史,供 Web 下钻「这条会话何时抖过」。flap.py:62。只落涉及 Established 的转移(断连 / 隐性重连 / 恢复;非 Established 间换挡只计数不落行,防挤占), 按节点裁剪到最近 _TRANSITIONS_KEEP 条。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点。 |
name | String(128) | NOT NULL,index | bird protocol 名。 |
session | String(128) | 可空 | schema 会话名。 |
kind | String(16) | NOT NULL | state(state 实变,如 Established→Active)/ restart(隐性重连:两份快照 state 均 Established 但 since 变化)。 |
from_state / to_state | String(32) | NOT NULL | 转移前后状态。 |
captured_at | String(64) | NOT NULL | 观测到转移的快照时刻(ISO8601,非转移实际发生时刻——分辨率即快照节奏)。 |
created_at | DateTime(tz) | server_default now | 写入时刻。 |
flap_alerts
flap 告警事件(边沿触发的持久层,P2)。flap.py:87。衰减分是「当下」视图,消退后历史即 不可见;本表由控制面背景评估循环(services/flap_alerts.py,60s)边沿触发地维护:分数 上穿阈值开一条,下穿消警线(阈值一半,迟滞防乒乓)关该条(resolved_at)。 kind=session 定位 (node_id, name=会话名);kind=prefix 定位 (name=前缀, peer_asn=归因 对端),nodes 记录波及节点集合。node_id 有意不设外键:告警是历史事实,不随节点删除 级联蒸发。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
kind | String(16) | NOT NULL | session / prefix。 |
node_id | String(64) | 可空,index(无 FK) | 会话告警的所属节点;前缀告警为 NULL。 |
name | String(128) | NOT NULL | 会话名或前缀。 |
peer_asn | BigInteger | 可空 | 前缀告警的归因对端 AS。 |
nodes | JSON | 可空 | 波及节点集合(session=单元素;prefix=看到该抖动的节点,持续期间并集)。 |
peak_score / last_score | Float | NOT NULL | 持续期间峰值分 / 最近评估分。 |
started_at | DateTime(tz) | NOT NULL,server_default now | 开单时刻。 |
resolved_at | DateTime(tz) | 可空,index | 消警时刻;NULL = 在开。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
前缀级热态:主存 Redis / KV,SQL 是回退
前缀级 flap 的「当下打分热态」(打分条目 + 路径证据,每 60s 整份替换、读时续衰)不落 SQL——落 SQL 每月产生 1.5 亿+ 行写。Python 面存 Redis 键 pflap:<node> 单 blob(每报 1 次 SET,TTL 7 天,app/services/prefix_flaps.py:68-74);TS 面存 KV 同名键 (src/services/prefixFlaps.ts,blob 结构逐字段一致)。SQL 只存 meta(喂送健康度)与 60s 速率存档;Redis / KV 未配置或故障时自动回退下述 node_prefix_flaps / node_prefix_flap_paths 整表路径(测试与降级共用)。
node_prefix_flaps
一条 (节点, 前缀, 对端 AS) 的当前前缀级 flap 打分,随上报按节点整表替换(条目 ≤500, delete + bulk insert)。prefix_flap.py:33。数据源是 agent 旁路 flapfeed 采集管线的周期 上报(PrefixFlapReport,~60s):agent 侧已按 (prefix, 归因对端 AS) 算好指数衰减分并截断 Top-N。热态主存 Redis / KV(见上),本表为回退路径。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
prefix | String(64) | PK(复合) | 前缀。 |
peer_asn | BigInteger | PK(复合),默认 0 | 归因对端 AS;0 = 未归因 / 本节点自起源(主键列不可空,读出归一回 None)。⚠️ ASN 列必须 BigInteger(32-bit ASN 溢出 int32)。 |
origin_asn | BigInteger | 可空 | 前缀起源 AS(AS_PATH 最后一跳);本网自起源 / 无从归因 / 旧 agent 未上报为 NULL。 |
score | Float | NOT NULL | agent 侧算好的指数衰减分(锚定 captured_at,读取时可续衰)。 |
changes | BigInteger | NOT NULL,默认 0 | 该 (prefix, peer) 自 flapfeed 启动以来的累计变化数(不衰减)。 |
recent_changes | BigInteger | 可空 | 上一报告间隔内的变化数(收报时对同键 changes 差分;上一报告无该键 = 无基准 → NULL,宁可漏检不虚报)。 |
rate_per_s | Float | 可空 | 折算的每秒速率(榜单 Rate 列口径)。 |
last_change_at | String(64) | 可空 | 最近一次变化时刻。 |
captured_at | String(64) | NOT NULL | 所属上报批次的生成时刻(sink 侧,ISO8601)。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
node_prefix_flap_meta
每节点最近一次前缀级 flap 上报的元信息(喂送健康度),区分「没有抖动」与「喂送断了看 不见」。prefix_flap.py:69。写率低,始终走 SQL。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK,FK→nodes.node_id(CASCADE) | 主键即外键。 |
captured_at | String(64) | NOT NULL | 最近上报生成时刻。 |
feed_state | String(32) | NOT NULL,默认 unknown | flapfeed BGP 会话状态(established / down / unknown)。 |
total_changes | BigInteger | NOT NULL,默认 0 | sink 启动以来的累计路径变化总数。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
node_prefix_flap_paths
一条 (节点, 前缀) 的 V2 路径证据,随上报按节点整表替换。prefix_flap.py:91。只对上榜 抖动前缀存(agent 侧已过滤,量小;JSON 路径列表 ≤32 条)。控制面根因区定位时跨节点读出、 按 AS_PATH 求并喂 app.services.flap_localize.localize(判「源头 AS 传播过来」还是「中段 某链路在翻」)。同属热态,主存 Redis / KV,本表为回退路径。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
prefix | String(64) | PK(复合) | 前缀。 |
paths | JSON | NOT NULL,默认 [] | [{"as_path": [int...], "churn": float, "present": bool}]——外部 AS_PATH 证据(已剥本网前缀)。 |
changes | Float | NOT NULL,默认 0.0 | 前缀级衰减计数:总变化事件(源头信号分母)。 |
withdrawn | Float | NOT NULL,默认 0.0 | 彻底消失次数(分子)。 |
captured_at | String(64) | NOT NULL | 上报批次生成时刻。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
prefix_flap_rate_rollup
每 (节点, 60s 桶) 的路径变化速率存档,收报时差分累加,保留 60 天。prefix_flap.py:120。 changes 来自喂送级累计计数 total_changes 的差分(该节点看到的全部计分事件,含未 上榜前缀);listed_changes 是榜内 (prefix, peer) 条目 changes 的差分之和(只算两份报告 都在榜的键——新上榜键无基准不计,宁可漏检)。fleet 级「路由变化速率」时序与 gauge 都从 这张表读时聚合。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点。 |
bucket_start | BigInteger | PK(复合) | 桶起点(epoch 秒,对齐 60s)。 |
changes | BigInteger | NOT NULL,默认 0 | 喂送级差分(含未上榜前缀)。 |
listed_changes | BigInteger | NOT NULL,默认 0 | 榜内条目差分之和。 |
samples | Integer | NOT NULL,默认 0 | 落桶报告数(>0 即「该桶有上报」,读侧据此区分 0 与无数据)。 |
额外索引:ix_prefix_flap_rate_rollup_bucket (bucket_start)。
flap_activity_rollup
fleet 级 flap 活跃度时间线(每 60s 桶一行,无 node 维度),告警评估循环顺手落点,保留 60 天。prefix_flap.py:144(ORM 类名 FlapActivitySample)。与速率存档互补:速率答 「变化多快」,这里答「多少主体在抖」。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
bucket_start | BigInteger | PK | 桶起点(epoch 秒,对齐 60s)。 |
tracked_prefixes | Integer | NOT NULL,默认 0 | 在跟踪的前缀数(有任何存活分数)。 |
flapping_prefixes | Integer | NOT NULL,默认 0 | 过阈前缀数。 |
flapping_sessions | Integer | NOT NULL,默认 0 | 过阈会话数。 |
open_alerts | Integer | NOT NULL,默认 0 | 在开告警数。 |
DN42 registry 副本
控制面周期从官方 registry(git.dn42.dev)同步 tarball 并解析 RPSL 对象(apps/control-server/app/db/models/registry.py;需配置 DN42_CONTROL_REGISTRY_TOKEN,见 configuration.md)。第一消费者是 UI 起源 AS 榜与 peering 向导的「名称」列。迁移 e2f3a4b5c6d7。
registry_asn
每 ASN 一行(aut-num 摘要),整表随每次成功同步全量替换(registry 是外部权威,本地不做增量合并)。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
asn | BigInteger | PK(非自增) | ASN(32 位 ASN 溢出 int32,必须 BigInteger)。 |
as_name | String(128) | 可空 | aut-num 的 as-name。 |
mnt_by | String(128) | 可空 | 首个 mnt-by(维护者标识)。 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻。 |
registry_meta
同步位置单行表(id 恒为 1):commit(master sha,变更门控用)、asn_count、synced_at。
管理员账号
账号登录(用户名 + 密码)签发的不透明会话令牌与静态 DN42_CONTROL_ADMIN_TOKEN 在 deps.require_admin 同一入口无差别接受(apps/control-server/app/db/models/auth.py)。 安全模型与 agent token 同纪律:明文令牌永不落库,只存 sha256 摘要;密码只存 argon2id 单向散列。迁移 b2c3d4e5f6a7。
admin_users
一个管理员账号。auth.py:20。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
username | String(64) | NOT NULL,unique | 用户名。 |
password_hash | String(255) | NOT NULL | argon2id 编码串(含参数与盐,形如 $argon2id$v=19$m=...)。 |
role | String(32) | NOT NULL,默认 admin,server_default admin | v1 预留字段(当前恒为 admin,无权限分级)。 |
created_at / updated_at | DateTime(tz) | NOT NULL,server_default now,updated_at 带 onupdate | 时间戳。 |
admin_sessions
账号登录签发的一条会话。auth.py:44。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
user_id | Integer | FK→admin_users.id(CASCADE),NOT NULL,index | 所属账号。 |
token_hash | String(64) | NOT NULL,unique | 完整 Bearer 的 sha256 十六进制摘要(唯一查找键)。 |
created_at | DateTime(tz) | NOT NULL,server_default now | 签发时刻。 |
expires_at | DateTime(tz) | NOT NULL | 过期时刻。 |
revoked_at | DateTime(tz) | 可空 | 非空即已吊销(改密时批量吊销该用户全部会话)。 |
审计
admin_audit_log
append-only Admin 写操作记录。apps/control-server/app/db/models/audit.py:14。无外键。每条 对应一次到达 /api/v1/admin 的变更类请求(POST/PUT/PATCH/DELETE),无论鉴权是否通过。
| 列 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键。 |
actor | String(64) | 可空,index | 鉴权通过的主体:静态 admin token 为 admin,账号会话为用户名(见「管理员账号」);失败时空。 |
method | String(8) | NOT NULL | HTTP 方法。 |
path | String(512) | NOT NULL,index | 请求路径。 |
status_code | Integer | NOT NULL | 响应状态码。 |
detail | JSON | NOT NULL,默认 {} | 详情。 |
created_at | DateTime(tz) | server_default now,index | 写入时刻。 |
Materialize 写入路径
控制面采用「normalized 表 → DesiredState 快照」的物化模型。入口有二:
- Provision:
provision_node_from_state(apps/control-server/app/db/provision.py:74) 把整份DesiredState落库。_split_base_template切出Node.base_template(剥掉interfaces/bgp_sessions/generation;dns整段置 None——DNS 改共享组模型;并剥掉 node 块里的身份字段node_id/asn/router_id/loopback/prefixes,因为它们是nodes列的 权威值,materialize 时由_node_payload无条件覆盖,避免存陈旧副本)。子表逐行apply_spec写入。该函数幂等:节点已存在时覆盖 base_template、删并重建子表,再 materialize。 - Admin 写:管理员对 peerings / wg / bgp / dns 等表的增改删,提交前调用
materialize发布新世代。
materialize(session, node_id, ...)(apps/control-server/app/services/materializer.py:33) 流程:
session.get(Node, node_id, with_for_update=True)行级锁住节点,让并发 admin 写在此处 串行化,保证 generation 严格单调、不撞UNIQUE(node_id, generation)(Postgres/MySQL 走SELECT ... FOR UPDATE;SQLite 忽略该子句,靠单写者 + UNIQUE 兜底)。new_generation = (current_generation or 0) + 1。_assemble_snapshot把base_template叠加子表内容组装成完整 DesiredState dict:_node_payload:DB 节点身份字段优先覆盖,base_template.node 兜底(提供 region 等非 DB 字段)。_load_interfaces/_load_sessions:按sort_order, id取子行。- enabled 语义:
InterfaceSpec无enabled字段,disabled 接口直接不进 snapshot;BgpSessionSpec有enabled,disabled 会话仍在 snapshot 但enabled=False。 - 对端 WG 公钥派生(
_load_peer_public_keys+_interface_payload):对端是另一受管 节点的接口(peering.remote_node_id非空),取对端Node.wireguard_public_key现取现填 进wireguard_peer.public_key——真相源是对端节点的公钥,不再依赖 spec 副本。 - LLA 派生:节点级
NodeSpec.link_local注入到外部 eBGP WG 接口的addresses(fe80::X/64),条件为接口是 WIREGUARD、peering存在且is_internal=False。内部互联 用各自接口的 LL。 - DNS 派生(
_load_dns_group):节点未分配组 / 组不存在 / 组禁用 ⇒dns=None;否则按 组→enabled zone→enabled 记录 组装DnsSpec(每 zone 内联 records,SOA 留空自动生成)。空 zone 不输出;无任何 zone 且无 forwards ⇒ None。多节点订阅同组拿到同一份 = anycast。 - 退役处理:
Node.lifecycle == "decommissioned"时,snapshot 的interfaces/bgp_sessions置空、dns置 None——agent 收敛即拆所有隧道、撤所有 BGP、停止宣告路由;核心 runtime 服务(router-netns/wg-gateway/bird-router)按 schema 要求保留为惰性空转。
DesiredState.model_validate(snapshot)再走一遍 schema 校验(拒绝任何漂移,不通过即抛、由 上层回滚事务),保证写入generations.snapshot的内容总是合法。- 写一条新
Generation(含snapshot+reason),并把Node.current_generation指过去。 - 世代保留裁剪:
keep_generations(默认DEFAULT_GENERATION_RETENTION = 100,materializer.py:30)> 0 时,同一事务内删除generation <= new_generation - keep的旧代, 防止generations表无界增长。当前代必然落在保留窗口内。
materialize 不触发 EventBus;由调用方在事务提交后决定是否广播事件,避免「事件先发、事务 后回滚」让 agent 拉到旧数据。
详见 控制服务器内部 与 DesiredState 参考。
迁移(Alembic)
迁移脚本在 migrations/versions/,配置见 alembic.ini(script_location = migrations, file_template = %(rev)s_%(slug)s)。migrations/env.py 从环境变量 DN42_CONTROL_DATABASE_URL 取 DSN(与运行时共享同一变量),并把 async driver 统一替换为同步 驱动跑迁移(+aiosqlite→空、+asyncpg→+psycopg2、+asyncmy→+pymysql);target_metadata = Base.metadata。
后端库是 PostgreSQL(ASN 等列用 BigInteger——DN42 ASN 4242420000+ 超 int32)。schema 初始化由启动期 _init_schema(apps/control-server/app/main.py:79)按配置项 db_auto_migrate(TOML [database].auto_migrate / env DN42_CONTROL_DB_AUTO_MIGRATE, 默认 False)分流:置 True 走 alembic upgrade head(迁移失败即启动中止,fail-fast); 默认走 Base.metadata.create_all(dev/CI;只建缺失的表、绝不应用 ALTER)。
两者当前不等价:ORM 含 node_route_prefix_hashes 表与 node_status.bgp_summary / peer_rates 列,而迁移链(head b2c3d4e5f6a7)没有对应迁移(生产 Python 面历史上经 create_all 建库,这两处随写路径 P1 / P2.7 只补了 TS 面 drizzle 迁移 0002 / 0003), 从空库 alembic upgrade head 产出会缺这三处,待补迁移。既有由 create_all 建起、未被 alembic 纳管的库首次切 db_auto_migrate=True 前还需手动 alembic stamp(见 config.py:211 的字段说明)。
历史坑(早期 SQLite + create_all 起库期):
create_all只建「最新模型」、不跑迁移里的数据剥离 / 列删除,故破坏性变更(删列)在 create_all 库需手动ALTER TABLE——如迁移a3b4c5d6e7f8的DROP COLUMN rpki_unknown。现已迁到 PostgreSQL,新部署 create_all/alembic 任一即可。
迁移历史(按链表顺序):
| revision | down_revision | slug | 内容 |
|---|---|---|---|
a0043f410bda | (无,根) | initial_schema | 初始 schema:nodes 及核心表。 |
b1f2c3d4e5a6 | a0043f410bda | node_runtime_status | 新增 node_status / node_status_events 运行时健康表。 |
c2a3b4c5d6e7 | b1f2c3d4e5a6 | token_hash_pending_registrations | agent_tokens 加 token_hash/expires_at+唯一索引;新增 pending_registrations。 |
d4e5f6a7b8c9 | c2a3b4c5d6e7 | admin_audit_enrollment_hash | 新增 admin_audit_log;enrollment_tokens 改哈希存储(回填 + 收紧非空唯一)。 |
e5f6a7b8c9d0 | d4e5f6a7b8c9 | wireguard_key_escrow | nodes 加 wireguard_public_key + wireguard_private_key_escrow(节点级 WG 身份/私钥托管)。 |
f6a7b8c9d0e1 | e5f6a7b8c9d0 | remove_compose_runtime_fields | 去 docker-compose:从存量 base_template/snapshot JSON 剥 runtime.adapter/templates.compose/templates.systemd(strict schema 否则校验失败)。 |
a7b8c9d0e1f2 | f6a7b8c9d0e1 | slim_build_spec | 从存量 DesiredState JSON 剥 build.context/build.dockerfile(router 镜像改 agent 内存生成)。 |
b8c9d0e1f2a3 | a7b8c9d0e1f2 | node_lifecycle | nodes 加 lifecycle(active/decommissioned,server_default active)。 |
c9d0e1f2a3b4 | b8c9d0e1f2a3 | node_route_entries | 新增 node_route_entries 索引表,逐路由明细从 node_routing.routes 单 JSON 列拆出;旧列暂保留写 NULL。 |
d0e1f2a3b4c5 | c9d0e1f2a3b4 | dns_groups | DNS 从节点级 dns_zones 改共享组:新增 dns_groups/dns_group_zones/dns_records,nodes 加 dns_group_id。 |
e1f2a3b4c5d6 | d0e1f2a3b4c5 | drop_legacy_compat_shims | 剥 DB 残留以删运行时垫片:去 lookglass/looking-glass-* 服务、归一化字符串端口、删 aggregates 里 prefilter 的 unknown 键(downgrade no-op)。 |
f2a3b4c5d6e7 | e1f2a3b4c5d6 | drop_node_routing_routes | 删死列 node_routing.routes(明细已迁 node_route_entries)。 |
a3b4c5d6e7f8 | f2a3b4c5d6e7 | drop_rpki_unknown | 删死列 node_routing.rpki_unknown / node_routing_events.rpki_unknown(RPKI「未知」三态已移除)。 |
a4b5c6d7e8f9 | a3b4c5d6e7f8 | node_link_local | nodes 加 link_local(节点级 IPv6 LLA 单一真相源,materialize 派生注入外部 eBGP WG 接口)。 |
b5c6d7e8f9a0 | a4b5c6d7e8f9 | pending_reg_unique_pending | pending_registrations 加部分唯一索引 UNIQUE(requested_node_id) WHERE status='pending'。 |
c0d1e2f3a4b5 | b5c6d7e8f9a0 | node_traffic_rollup | 新增 node_traffic_rollup(WG 吞吐 5min 降采样持久存档)。 |
d1e2f3a4b5c6 | c0d1e2f3a4b5 | node_traffic_last_sample | 新增 node_traffic_last_sample(速率差分的持久 prev,Redis 不可用时兜底)。 |
e2f3a4b5c6d7 | d1e2f3a4b5c6 | registry_mirror | 新增 registry_asn / registry_meta(DN42 registry 本地只读副本)。 |
f3a4b5c6d7e8 | e2f3a4b5c6d7 | iface_traffic_rollup | 新增 node_iface_traffic_rollup / node_iface_traffic_last(按接口→对端 AS 划分流量的数据层)。 |
a5b6c7d8e9f0 | f3a4b5c6d7e8 | node_metrics_rollup | 新增 node_metrics_rollup / node_metrics_last(agent 自观测 5min 存档 + 失败数差分 prev,/ui/nodes/metrics 数据层)。 |
b6c7d8e9f0a1 | a5b6c7d8e9f0 | bgp_session_flaps | 新增 bgp_flap_state / bgp_flap_transitions(会话级 flap 检测 P0:比对基准 + 衰减分 + 累计计数 / append-only 转移历史;数据源为快照 bgp_protocols,零 agent 变更)。 |
c7d8e9f0a1b2 | b6c7d8e9f0a1 | prefix_flaps | 新增 node_prefix_flaps / node_prefix_flap_meta(前缀级 flap 检测 P1:agent flapfeed 上报的当前衰减打分(按节点整表替换)+ 喂送健康度元信息;ASN 列 BigInteger)。 |
d8e9f0a1b2c3 | c7d8e9f0a1b2 | flap_alerts | 新增 flap_alerts(P2 告警事件:背景评估循环边沿触发维护,上穿开单 / 下穿消警,让「昨晚谁抖过」可回答)。 |
e9f0a1b2c3d4 | d8e9f0a1b2c3 | prefix_flap_origin_asn | node_prefix_flaps 加 origin_asn(起源 AS 归因列,与 peer_asn 第一跳对称;可空 BigInteger)。 |
f0a1b2c3d4e5 | e9f0a1b2c3d4 | prefix_flap_paths | 新增 node_prefix_flap_paths(V2 根因区定位:每 (节点, 前缀) 的 AS_PATH 证据 JSON + 前缀级衰减计数,只存上榜前缀)。 |
a1b2c3d4e5f6 | f0a1b2c3d4e5 | flap_stats_rollup | 新增 prefix_flap_rate_rollup / flap_activity_rollup(60s 桶速率 / fleet 活跃度时序存档,保留 60 天);node_prefix_flaps 加 recent_changes / rate_per_s(榜单 Rate 列)。 |
b2c3d4e5f6a7 | a1b2c3d4e5f6 | admin_accounts | 新增 admin_users / admin_sessions(账号登录:argon2id 密码散列 + sha256 会话令牌摘要;静态 admin token 并行可用)。 |
链表当前的 head 为 b2c3d4e5f6a7。多处迁移用 op.batch_alter_table 以兼容 SQLite(无原生 DROP COLUMN)与 Postgres。
升级与迁移操作指引见 升级与迁移。
TS 面 schema(drizzle)
主控制面(TS 面)的 schema 权威定义在 apps/control-server-ts/src/schema.ts——单文件 36 张 pgTable,与 Python 面 ORM 同构(列名 / 类型 / FK / 索引逐项对应),列语义见 前文各表章节,此处不逐列重复。类型映射约定(schema.ts 头注):
| SQLAlchemy | drizzle |
|---|---|
BigInteger | bigint({ mode: 'number' })(DN42 ASN 超 int32,< 2^53 故 number 安全) |
JSON | jsonb().$type<...>() |
DateTime(timezone=True) | timestamp({ withTimezone: true }) |
String(n) / Text | varchar({ length: n }) / text |
表分组(schema.ts 定义顺序):
- 节点身份与凭证:
nodes、enrollment_tokens、agent_tokens、pending_registrations - 网络配置与世代:
peerings、wg_interfaces、bgp_sessions、generations - 共享 DNS 组:
dns_groups、dns_group_zones、dns_records - 运行健康:
node_status、node_status_events - 路由:
node_routing、node_route_entries、node_route_prefix_hashes、node_routing_events - 流量与指标 rollup:
node_traffic_rollup、node_traffic_last_sample、node_metrics_rollup、node_metrics_last、node_iface_traffic_rollup、node_iface_traffic_last - flap 检测:
bgp_flap_state、bgp_flap_transitions、flap_alerts、node_prefix_flaps、node_prefix_flap_meta、node_prefix_flap_paths、prefix_flap_rate_rollup、flap_activity_rollup - registry 副本:
registry_asn、registry_meta - 账号与审计:
admin_users、admin_sessions、admin_audit_log
迁移在 apps/control-server-ts/drizzle/(条目清单见 drizzle/meta/_journal.json):
| 迁移 | 内容 |
|---|---|
0000_init.sql | 初始 19 张核心表(节点 / 网络 / DNS / 世代 / 健康 / 路由 / 流量存档 / 审计)。 |
0001_brainy_deathstrike.sql | 新增 16 张表:flap 两级检测全家桶、registry 副本、账号会话、指标与 per-interface 流量存档。 |
0002_amusing_squadron_sinister.sql | node_status 加 bgp_summary / peer_rates(P2.7 预提取列)。 |
0003_route_prefix_hashes.sql | 新增 node_route_prefix_hashes(路由差分写基准,P1)。 |
应用方式:drizzle-kit 在 Worker 外部直连源 PostgreSQL(不经 Hyperdrive)—— npm run db:generate 从 schema 生成迁移 SQL,DATABASE_URL=postgres://... npm run db:migrate 应用;实际生产因源库自签 TLS 采用 psql 管道法,见 apps/control-server-ts/README.md。