25 KiB
发布端点接线准备 + 账本锚点运维流程
本轨性质:接线准备,不是发布端点本身。本轨不新建 live 发布端点、不碰生产、不跑
deploy.py、不合并、不部署。 发布端点是否立项、锚点存哪里、校验周期多长,均属用户侧 拍板项(见 §6),本文只给推荐值与判断依据,不替用户定。基底:堆叠于 PR #247(
feat-audit-wiring,217a27f), 合并顺序:#209 → #240 → #244 → #241 → #247 → 本轨。 本轨代码:cloud/release_gate.py(纯只读 evaluator)、cloud/tests/test_release_gate.py(11 项契约测试)、tools/ledger_anchor_check.py(只读校验脚本)、cloud/tests/test_ledger_anchor_check.py(脚本退出码测试)。 本轨不改 #247 / #241 / #244 / #240 / #209 的任何语义: 硬门禁五项、推回阻塞、CORRECTED-to-pass 不解封、链 fail-closed 规则,全部沿用,本文只说明它们在发布时 如何被重新执行。
1. 现状缺口(#247 留下的原话)
- 合并端点
POST /tasks/{id}/merge已在链上推回阻塞与硬门禁 五项之间做 AND:任一阻塞即 409、状态不变,并在响应里附audit_pushback对象(merge_blocked/release_blocked/blocked_by="audit_pushback"/round/failed_checks)。 - 但本仓库没有独立的发布端点:
release_blocked今天只是 随合并响应返回的一个字段,由“未来的发布端点用同一 reader 强制”。在那之前,合并放行后、发布前没有任何服务端检查 会再读一次链。 - 账本(#241)已有导出头哈希,但没有库外锚点存放与定期 校验的运维流程:头哈希只在导出响应里出现一次,不存下来 就等于没有锚点;尾巴删除单靠库内校验发现不了(#241 已明 说),必须靠“旧锚点 vs 当前链”的比对。
2. 未来发布端点接口契约(建议,待立项后实现)
2.1 端点
| 项 | 建议值 | 说明 |
|---|---|---|
| 方法 + 路径 | POST /tasks/{id}/release |
与 POST /tasks/{id}/merge 对仗;{id} 为任务 id,路径风格与现有任务端点一致 |
| 请求体 | {} 或 {"confirm": true}(二选一,立项时定) |
本轨不规定体字段;关键在服务端不信任体里的任何门禁结论,只信任自己重算的结果 |
| 成功 | 200 + 任务对象 + release_gate 决策对象 |
决策对象即 evaluate_release 的返回值原样返回,不另起一套字段 |
| 阻塞 | 409 + {"error": "release blocked", "release_gate": {...}} |
见 §2.4 的阻塞码;阻塞时不改任务状态、不发布、不写链 |
| 任务不存在 | 404 |
与 merge 端点一致 |
| 无权 | 403 |
见 §2.2 |
本轨没有实现这个端点:cloud/release_gate.py 只有 evaluator,
没有路由、没有 HTTP plumbing、没有任务状态变更、没有发布动作。
契约测试直接测 evaluator(见 §4),不测一个不存在的端点。
2.2 鉴权:与 verify/export 同源、但为写动作
- 身份来源与
GET /admin/audit-ledger/verify、GET /admin/audit-ledger/export一致:只认环境变量CODEX_GLOBAL_ADMINS(_is_global_admin),未配置即全体 403 fail-closed,绝不回落到项目角色推导。 - 但动作性质不一致:verify/export 是全局管理员只读;
发布是写动作(对外发布),所以发布端点必须是
“全局管理员写”,不能是“全局管理员只读”,更不能只凭
项目
approve_merge(合并权)就放行发布。合并与发布是 两道不同的授权:项目 maintainer 能合并不等于能发布全系统。 - 被拒(403)的审计口径沿用现有
_audit_admin惯例(成功与 被拒都记;注意AuthStore.audit会同事务写链,见 §2.5 的 头移动说明)。409 阻塞本身不写审计:阻塞只是只读检查 的结果,每次被拒查询都写链会无意义地移动链头、污染锚点。
2.3 接线点:发布前必做的三件事(顺序即 §3 的求值顺序)
未来端点在执行任何发布动作之前,必须按顺序做:
- 重读链:调
evaluate_release,它内部调cloud/audit_wiring.pushback_block_for_task(与 merge 端点 同一个 reader)。读到的必须是发布时刻的链状态。 - 重算硬门禁:用发布时刻的新证据重新跑
evaluate_hard_merge_gate(CI、目录、合同、协调员、评审、 分支保护五项 + 保护),把结果传给 evaluator。合并时的 gate 对象、合并时的 200,一律不作输入。 - 核任务状态:任务状态须为
merged才可发布(evaluator 的task_status参数;端点不得省略它)。
三件缺一即 fail-closed:链读不到 → 409;硬门禁证据缺失 →
409;状态不是 merged → 409。发布动作只在 evaluator 返回
allowed=true 后执行,且发布动作本身的成功记录(如需记账)
必须是显式的新动作,不是检查的副产品。
2.4 阻塞源区分(错误码与阻塞码对照)
release_gate 决策对象同时返回 code(首要阻塞码)、
blocked_by(首要阻塞源)、blocked_sources(全部阻塞源),
端点必须原样返回整个对象,不能只返回首要码——否则修好
一个源后才发现另一个源仍阻塞,运维会被误导。
| 阻塞源 | code |
blocked_by |
HTTP | 附加字段 | 修复路径 |
|---|---|---|---|---|---|
| 链读失败 fail-closed | release_blocked_state_unavailable |
audit_pushback_state_unavailable |
409 | 无(读不到即无可信细节) | 修读链通路(DB/权限),重调;绝不能“先放行、后补查” |
| 任务未合并 | release_requires_merged |
task_status |
409 | — | 先走合并流程到 merged |
| 硬门禁五项失败 | release_blocked_hard_gate |
hard_gate |
409 | blocks(硬门禁码原样,如 ci_not_green) |
补齐五项证据后重调;与 merge 的 blocks 码同一套,不解析文本 |
| 审计推回阻塞 | release_blocked_audit_pushback |
audit_pushback |
409 | failed_checks、round、modules(与 merge 响应里的 audit_pushback 对象同源) |
修问题 → 重审通过(见 §2.6),重调 |
| 硬门禁证据缺失 | release_hard_gate_evidence_missing |
hard_gate |
409 | — | 调用方必须提交发布时刻的新证据;这不是“证据不足放行”,是直接阻塞 |
例外(沿用 #247,不扩大):audit_ledger 表根本不存在时,
reader 返回“无推回状态”(链从未写过),不算读链失败。
这条沿用 merge 的口径;若未来组织要求发布比合并更严
(无链即不发布),那是立项时另议的语义变更,不在本轨
偷偷改(见 §6 待拍板项)。
2.5 只读性:检查绝不能写链
- evaluator 不 append、不 commit、不调
AuthStore.audit、 不关调用方的连接。契约测试test_evaluator_is_read_only钉住:两次求值后链头不变、链仍 verify ok。 - 为什么必须如此:
AuthStore.audit会同事务写一条链记录; 若检查写审计,检查本身就移动链头,检查前取的锚点立刻 失效,锚点校验将永远对不上——检查就不再是检查。 现有GET /admin/audit-ledger/verify成功路径同样不写审计, 正是同一道理。 - 推论:409 阻塞不写审计(§2.2);403 被拒沿用
_audit_admin惯例写审计时,运维须知链头已移动,下一次锚点必须在 该行之后重取(锚点轮换见 §5.4)。
2.6 release_blocked 与 merge_blocked 的关系
- 合并放行 ≠ 发布放行。
merge_blocked=false只说明 “合并那一刻链上无阻塞且五项全过”;发布时刻链可能已 新增推回、CI 可能已变红、合同可能已变——发布必须重新 读链、重新判定。拿合并时的 200 当发布通行证是本设计 明确禁止的用法,evaluator 的签名即防住了它:它不接受 “合并时的 gate 对象”作为输入,只接受调用方现算的hard_gate,且hard_gate=None直接阻塞。 - 反向亦然:发布阻塞不回滚合并状态。发布 409 时任务仍为
merged;修复后重调发布即可,不必重新合并。 release_blocked与merge_blocked在 reader 里是同一 布尔值(gate_block同时置两字段),但求值时刻不同: 同一个 reader、两次调用、两次时刻——这才是两字段并存的 意义,而不是“合并时顺带算好发布结论”。
2.7 CORRECTED 为 pass 在发布端如何体现
- 沿用 #247/#244 已裁决的语义,不发明新语义:审计的审计
把原结论纠正为
pass,本身不解封——它只表示“原判错了, 应安排重审”,不是一次重审。发布端与合并端共用同一 reader, 因此发布端自动继承该语义:纠正后、无新通过结论时,发布 仍 409(release_blocked_audit_pushback)。 - 解封的唯一路径:一条新的通过结论进链——新的
audit_verdict=pass,或一次通过的re_reviewed推回轮次 (且 QA 失败链接须按 #247 的 QA 护栏全部重跑通过,否则 该通过结论写不进链)。 - 契约测试
test_corrected_to_pass_still_blocks_until_fresh_pass用唯一阻塞构造钉住这一点:若纠正本身能解封,该测试 必失败(测试注释说明了为何不用“纠正 + 另一失败”的掩盖构造)。
3. evaluator 求值顺序(cloud/release_gate.py)
- 读链。异常 →
release_blocked_state_unavailable,短路 (没有可信链读数时,其它结论都不可信)。 - 任务状态(仅当调用方给了
task_status时)。非merged→release_requires_merged。 - 硬门禁。
hard_gate=None→release_hard_gate_evidence_missing;allowed=false→release_blocked_hard_gate,blocks原样带出。 - 审计推回。reader 说阻塞 →
release_blocked_audit_pushback,failed_checks/round/modules原样带出。 - 以上全过 →
allowed=true,http_status=200,code=release_allowed。
多源同时阻塞时,blocked_sources 列出全部,code /
blocked_by 只取求值顺序第一项。端点返回时不得丢弃
blocked_sources。
4. 契约测试(cloud/tests/test_release_gate.py,11 项)
测试只测 evaluator + 本地 SQLite 链(TemporaryDirectory,
跑完删除),不部署、不调用生产发布流程(生产本就没有
发布端点可调)。覆盖:
| 测试 | 断言 |
|---|---|
| 审计推回阻塞(硬门禁全过时) | 409 + release_blocked_audit_pushback + failed_checks 含 ci_green + round=1 |
| 重审通过后放行(新硬门禁全过) | 200 + release_allowed |
| 纠正为 pass 但无新通过结论时仍阻塞;新通过结论后放行 | 前 409、后 200(唯一阻塞构造,见 §2.7) |
| 链读失败(已关闭的连接) | 409 + release_blocked_state_unavailable |
| 硬门禁失败而链已清 | 409 + release_blocked_hard_gate + blocks 非空,且不含审计阻塞码 |
| 两源同时阻塞 | blocked_sources 同时含两码,blocks 与 failed_checks 均非空 |
| 硬门禁证据缺失 | 409 + release_hard_gate_evidence_missing |
| 任务未合并 | 409 + release_requires_merged |
| 合并时放行、发布时链已阻塞 | 前 200、后 409(证明每次调用都重读链) |
| 仅修复不解封 | fix 行后仍 409 |
| evaluator 只读 | 两次求值后链头不变、链 verify ok |
5. 账本锚点运维流程
5.1 存放位置(推荐一支 + 取舍 + 权限)
推荐:独立对象存储(与生产库不同机、不同账号、不同权限)。
- 每个锚点一个对象,命名
anchor-<YYYYMMDD-HHMMSS>-<count>-<head8>.json,内容即 §5.2 的锚点文件({version, head_hash, count, anchored_at, source},仅head_hash+count参与校验)。 - 存储桶开启版本控制 + 对象锁定/保留(WORM)或至少禁止
删除的桶策略:写锚点的账号只有
PutObject,没有DeleteObject、没有覆盖保留期的权限;读锚点的账号(审计 员)只有GetObject。写与读、锚点与生产库,账号全部分开。 current指针(如有)只是一个方便引用的独立小对象, 更新指针绝不删除旧锚点对象;历史锚点对象本身就是真相, 指针丢了可以从对象列表重建,对象丢了指针毫无意义。
取舍(为什么不选其它位置):
| 候选位置 | 否决/降级原因 |
|---|---|
| 生产库同一张表/同库另一张表 | 与生产库同权限:能改链的人就能改锚点,锚点失去独立见证意义 |
| 生产主机上的文件 | 与生产库同机:主机被拿下则锚点与链一起被改写;且主机文件无版本控制,易被覆盖 |
| 运维人员个人机器文件 | 可作过渡(见下),但单点、无审计、无保留策略,人员变动即丢失 |
| Git 仓库 | 历史可查是优点,但锚点文件进仓等于把“见证”交给与代码同一套合并权限,且 head 频繁更新会污染提交历史;不推荐为主存 |
过渡方案(在对象存储未就绪前,须用户拍板后才用):
锚点文件存到一台非生产主机的运维账号目录(0600),
该账号与生产库账号不同、无生产库写权限;同时把锚点
head_hash 抄送到第二渠道(如运维群/邮件归档)作人工见证。
过渡不是终态:对象存储就绪后,历史锚点文件应原样迁入
对象存储并保留过渡期的抄送记录,不得“迁入时重算头哈希”。
5.2 校验什么、多久一次、谁触发
每次校验(脚本 tools/ledger_anchor_check.py)做两层:
- 链完整性:
verify_chain/verify_export——逐条重算entry_hash+prev_hash链接。中间篡改、重排、删中间 记录,在此层失败。 - 锚点前缀比对:当前链条数 ≥ 锚点
count,且第count条的entry_hash仍等于锚点head_hash。条数变少 → 锚定后的尾巴被截断;该位置哈希变了 → 锚点前的链被 替换/重写。头哈希前进(条数更多、前缀一致)是正常 通过,不是失败。
周期(推荐值,待用户拍板,见 §6):
| 触发 | 动作 |
|---|---|
| 每日定时(cron/定时任务,运维账号) | 取最新锚点 → 全链校验 + 前缀比对;通过则取新锚点(轮换见 §5.4) |
| 每次发布前(未来发布端点立项后) | 发布流程先跑一次锚点校验;校验失败即发布 409(与 §2.4 的链读失败同级处理,属未来端点行为,本轨不实现) |
| 每次合并门禁异常/人工介入后 | 运维手动跑一次,确认链未被介入改动 |
| 锚点轮换时 | 新锚点必须先通过旧锚点的前缀校验,才能成为“当前锚点”(见 §5.4) |
校验脚本本身不触发任何业务动作:它只读(SQLite 用
mode=ro 打开;导出模式只读文件),不写链、不发通知——
通知由调用它的定时任务/运维流程负责(见 §5.3)。
5.3 校验失败的告警与处理(绝不能静默重建)
失败分两类,处理不同,但有一条共同红线:
红线:绝不能删除旧链、重建整链、重算头哈希后冒充 “没发生”。 任何恢复都必须保留原始链与旧锚点,恢复 动作本身也要留痕(新锚点系列注明事件编号,见 §5.4)。
- 告警给谁:全局管理员 + 当值运维(渠道由用户定: 本轨不假设已有告警通道;定时任务失败时至少要在定时 任务日志与运维值班渠道留一条可查记录,不能只打印到 没人看的日志文件里)。
- 是否冻结:校验失败期间,发布立即冻结(不发布; 未来发布端点立项后以校验失败作 409 条件之一;立项前由 运维人工冻结发布操作)。合并是否冻结由当值全局管理员 当场决定并记录——本轨不以代码自动冻结合并(那会是 新的门禁语义,须另行立项),但 runbook 要求管理员在 收到告警后 1 个工作日内给出“冻结合并与否”的书面决定, 不能无决定地继续合并。
- 恢复步骤:
- 立即对生产库做只读快照/备份,冻结现场(不要先修链)。
- 区分失败类:锚点文件笔误/取错(旧锚点系列中存在能 通过前缀校验的锚点)→ 以那个锚点为准重取当前锚点, 并记录“锚点取错事件”;链本身失败(重算哈希不符、 链接断裂、前缀对不上任何历史锚点)→ 链不可信。
- 链不可信时:旧链原样封存(只读,不删不改), 新链只能以“事件后新系列”起头(新 genesis 标记注明 事件编号与旧链封存位置),新锚点系列与旧系列分开存放、 不得混写;旧系列永不删除。
- 恢复后重新锚定:新锚点只能取自已通过内部校验且 通过最老可信锚点前缀校验的链;若无任何可信锚点, 新系列首锚点必须注明“无可信前缀、事件后重建”,不得 写成与旧系列连续的样子。
5.4 锚点轮换与历史锚点保留
- 轮换:每次校验通过后取新锚点(新文件、新对象), 旧锚点文件/对象永不覆盖、永不删除。新锚点生效前必须 通过旧锚点的前缀校验——通不过的链不配产生新锚点。
- 保留期:锚点文件极小(约 200 字节),建议永久保留 全部历史锚点,不设删除期;对象存储侧以版本控制 + 保留策略保证“删不掉”,而不是靠运维自觉不删。若存储 成本政策强制设限,则下限为:至少保留最近 12 个月的 每日锚点 + 每次发布前的锚点,且删除须全局管理员双人 确认并留审计记录——该下限须用户拍板(见 §6),本轨 不擅自把“永久”降级为某个期限。
- 交接:新旧锚点交接只认“新锚点通过旧锚点前缀校验”
这一条;指针(
current)更新失败不影响历史锚点的有效性, 下次校验可直接用任意历史锚点重跑。
5.5 校验脚本用法与退出码
# SQLite 本地链(只读打开,不写链):
python3 tools/ledger_anchor_check.py --db /path/to/control.db
python3 tools/ledger_anchor_check.py --db /path/to/control.db --anchor /path/to/anchor.json
# 生产 PG:先经 GET /admin/audit-ledger/export 导出,再验导出(脚本不开 PG 连接):
python3 tools/ledger_anchor_check.py --export /path/to/export.json --anchor /path/to/anchor.json
# 校验通过后取新锚点(仅 --export 模式;失败时拒绝写文件,见 §5.5 末):
python3 tools/ledger_anchor_check.py --export /path/to/export.json --write-anchor /path/to/new-anchor.json
# --db 模式取新锚点:用通过的那次检查输出的 head_hash/count,按 §5.2 格式写新锚点文件
# (脚本不代写,避免“检查脚本写文件”与“只读检查”的语义混淆;--export 模式的 --write-anchor
# 只写锚点文件、从不写链,失败时同样拒绝)。
退出码:0 通过;1 校验失败(stdout JSON 的 failures
逐条列出失败项,head_advanced_by 为负即链比锚点短);
2 用法/IO 错误(文件不存在、锚点格式非法、--db 传了
Postgres URL)。输出为单 JSON 对象,便于定时任务解析。
脚本的三类本地实测(测试链在 /tmp,跑完删除,不进提交):
| 用例 | 结果 |
|---|---|
| 正常链(2 条)+ 锚点(同链头) | 退出 0,head_advanced_by=0;再追加 1 条后重验仍退出 0,head_advanced_by=1(头前进属正常) |
| 特权篡改(删触发器后改第 1 条 detail;注:正常 SQL 的 UPDATE 本就被 SQLite 触发器挡下,实测先确认触发器拦截,再模拟特权篡改) | 退出 1,failures=["entry_hash mismatch at id 1 (record tampered)"] |
| 删尾巴(删触发器后删最后 1 条)后与旧锚点比对 | 退出 1,failures=["chain shorter than anchor: current count 1 < anchored count 2 ..."] |
附加:导出模式失败时 --write-anchor 被拒绝(无文件生成);不存在的 DB 与 Postgres URL 均退出 2 |
均符合 |
脚本退出码另有单元测试钉住(cloud/tests/test_ledger_anchor_check.py,
子进程跑脚本,断言三类用例的退出码与关键失败文案)。
5.6 真实 PG:本轨不做、只以常量/适配层证据写清
- 本机无真实 PG,PG 触发器(
cloud/ledger.py的PG_GUARD_SQL常量)未在真实 PG 上验证,沿用 #241 的标注,不在本轨改口称已验证。 - PG 路径的操作流程(生产 PG 须运维手动执行并验证后才认):
- 在生产 PG 上手动执行
PG_GUARD_SQL(函数 + 两触发器), 并实测 UPDATE/DELETE 被拒绝; - 经
GET /admin/audit-ledger/verify(全局管理员)确认ok=true; - 经
GET /admin/audit-ledger/export取导出,用本脚本--export模式 + 历史锚点校验; - 通过后按 §5.4 取新锚点存入对象存储。
- 在生产 PG 上手动执行
- 在 PG 验证完成前,生产 PG 的“已加固”状态一律写为 “未验证”,不得在 PR 正文或摘要里写成已验证。
6. deploy.py 口径:为什么不在门禁内、未来是否纳入
判断:deploy.py 不纳入合并/发布门禁,且未来也不应纳入。
- 走什么路径:
deploy/deploy.py是运维人员在本机跑的 CLI:打包/传包(tar 或主机 git 直拉)→ 主机host_deploy.sh换 release 目录 → 重启控制面 unit。 它部署的是平台本身(服务端代码),不是某个任务的 合并产物;它没有任务 id、没有调用方提交的门禁证据、 不走服务端 HTTP 鉴权(走 SSH deploy key),与POST /tasks/{id}/merge/ 未来POST /tasks/{id}/release根本不在同一层。 - 为什么不在门禁内:门禁的客体是“任务能否合并/发布”,
deploy.py 的客体是“平台代码能否上线”。把 deploy.py 绑到
任务链状态上,会在平台本身出问题、最需要部署修复与回滚
的时候反锁部署(回滚
rollback.sh同样在门禁外,理由相同)。 紧急修复被门禁挡住,比“部署时没查任务链”危险得多。 - 未来是否纳入:不纳入为原则;只加两条不阻塞的运维
配合(均不改 deploy.py 行为、不设卡):
- 部署完成后,运维手动跑一次 §5.2 的锚点校验(部署不 应改链;若校验失败,按 §5.3 处理,与部署成功与否分开判);
- 部署说明里写明“部署 ≠ 任务发布”:推新平台版本不等于 发布任何任务,任务发布仍走未来发布端点。
- 若未来有人提议“deploy.py 发布前查链”,须先回答:查哪 个任务的链?以谁的身份?回滚时查不查?答不上来就不纳入 ——本轨已把这三个问题写进 PR 正文,供立项时复核。
7. 未做项与诚实边界(本轨明确不声称的)
- 未建发布端点:无路由、无 HTTP 测试、无任务状态变更、
无真实发布动作;
release_gate.py只是 evaluator + 契约。 - 未部署、未跑
deploy.py、未动生产、主机 env、系统 key、 sub2api、生产网关、仓库可见性、GitHub App 权限、生产容器。 - 未在真实 PG 上验证:PG 触发器语法、PG 上的链校验与锚点 流程,均只以常量/导出模式写清,须生产 PG 手动执行并验证。
- 未替用户拍板:发布端点是否立项、锚点对象存储选型与 账号、校验周期(推荐每日 + 每次发布前)、告警渠道、 锚点保留期下限、合并冻结与否的默认策略——见 §8。
- 未改 v1.1 契约、分支保护决策、已有 PR 分支与主 checkout。
8. 用户侧仍需拍板的点(本轨不擅自决定)
- 发布端点是否立项:立项则按 §2 实现
POST /tasks/{id}/release; 不立项则release_blocked继续只随合并响应返回,发布仍无 服务端强制——该风险须用户知晓。 - 锚点存放:对象存储选型(厂商/桶/账号)与过渡方案是否启用。
- 校验周期:推荐每日定时 + 每次发布前;是否接受该周期。
- 告警渠道:校验失败通知全局管理员与当值运维的具体渠道。
- 锚点保留期:推荐永久保留;若设下限,接受“12 个月每日 + 每次发布前锚点、双人确认删除”与否。
- 合并冻结默认策略:校验失败时合并是“管理员当场决定”还是 “默认冻结”(本轨推荐前者、代码不自动冻结)。
- 发布比合并更严的口径:无链(表不存在)时发布是否也应阻塞 (本轨沿用 merge 的“无链即无阻塞”,改严须立项时定)。