feat(db): 新表授权面收敛机制落地(白名单驱动 · 增量锚定 · fail-closed · 只增不减)
依据:A 侧(wecom-yanxue)裁定 编号 2「准予但需调整」+ R1(裁定件 yanxue-20260928-newtable-grant-surface-impl-plan-ruling.md,md5 272d1a8b4db0db0e9f35f5e62695ae50) 三条开口 (a) 空锚=合法零集 / (b) --anchor-file 载体等价 / (c) drill WARN 不阻断(两条独立分支) 改动面(3 件,全部开发工作树): - 新增 backend/scripts/grant_surface_sync.py:项目侧原型逐字复制 + Δ1/Δ2/Δ3/Δ4 + R1 Δ1 锚语义(未给=缺锚 rc=2 / 空集=合法零集)、Δ2 连接身份与 db_migrate 同口径并拒绝 cma_api、 Δ3 --anchor-file(读 db-migrate-journal 指针件)、Δ4 anchor_source 入 JSON R1-a 同源自证(journal 在册 + status=applied + finished_at 一致 + 目录内最新 -applied) R1-b 未收敛锚累积由 db_migrate.write_anchor_pointer 实现、R1-c 收敛回写 converged_at - db_migrate.py 加性 +2 处:新增 write_anchor_pointer()(原子写 journal/last-anchor.json); cmd_apply 成功/失败两路径各调一次;零变更面未动(列级差异只告警 / create_all 幂等 / --check 不写指针 / --rollback 与 resolve_journal 语义 / JSON 键集) - deploy.sh 加性 1 步 [4b-2/5] 授权面收敛(插在「✓ 结构迁移完成」与「# 5. 重启后端服务」之间): apply 跑 --apply --anchor-file ... --drift-policy warn,rc!=0 ⇒ 打印缺裁定清单 + exit 1 (后端不重启、旧码继续服务);drill 跑 --check,读数原样打印、WARN 不阻断 证据(全部零生产面): - py_compile x3 通过;bash -n deploy.sh 通过(PYTHONPYCACHEPREFIX 隔离,仓内零新增 pyc) - 纯函数用例 11/11(零连库):语句均为 GRANT / 无 REVOKE / 语句表 ⊆ write_tables / 域外权限 SystemExit - 沙箱 S1–S9 回归 9/9 一致(SUMMARY.txt 与项目侧基线逐字节相同 md5 1a697e002a2cba8ae9b5ad9cc71b022d) - 锚/R1 新例 18/18:缺锚/缺件/status=failed/R1-a 四负例均 rc=2 零写入;空锚 rc=0 executed=[] - R1 验收链:发布1 建表 X(∉白名单) ⇒ rc=3 零写入 + 片段 exit 1 ⇒ 不修白名单重跑 ⇒ 仍 rc=3 ⇒ 补白名单 ⇒ rc=0 + X 获授权 + converged_at 落定 ⇒ 三次重跑 executed=[](幂等) - 既有 backend/tests:1509 passed / 0 failed,改前(HEAD 导出树)与改后失败集零差异 边界:本地 commit,零 push / 零部署 / 零重启 / 零连生产库 / 零 DDL-DML-GRANT-REVOKE; 上线(push + 部署 ⇒ 授权面生效)另单另铸 approval ID 且必附 R2 预演 diff(本单不为任何动作铸 ID)
This commit is contained in:
@@ -177,6 +177,63 @@ def write_journal(payload: dict) -> str:
|
||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
def write_anchor_pointer(payload: dict, journal_path: str) -> str:
|
||||
"""原子写 journal/last-anchor.json —— 授权面收敛步(grant_surface_sync.py)消费的锚(R1)
|
||||
|
||||
键:journal(本次 journal 文件名,供 R1-a 同源自证)/ status / created_tables / created_new /
|
||||
anchor_csv / finished_at / git_head / db_name / actor / carried_over / carried_from /
|
||||
written_at / converged_at
|
||||
R1-b:已存在指针且其 status=applied 而未收敛(无 converged_at)时,本次 created_tables =
|
||||
旧集合 ∪ 本次 created,并记 carried_over / carried_from;无旧指针或旧指针已收敛时 = 本次
|
||||
加性写:不改 journal、不改任何既有键语义;失败由调用方按告警处理(收敛步会据此 rc=2 中止)
|
||||
"""
|
||||
d = journal_dir()
|
||||
path = os.path.join(d, "last-anchor.json")
|
||||
prev = None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
prev = json.load(fh)
|
||||
except Exception:
|
||||
prev = None
|
||||
created = [t for t in (payload.get("created_tables") or [])]
|
||||
carried_over, carried_from = False, None
|
||||
if (isinstance(prev, dict) and prev.get("status") == "applied"
|
||||
and not prev.get("converged_at") and (prev.get("created_tables") or [])):
|
||||
old = list(prev.get("created_tables") or [])
|
||||
created = sorted(set(old) | set(created))
|
||||
carried_from = os.path.basename(str(prev.get("journal") or "")) or None
|
||||
carried_over = True
|
||||
doc = {
|
||||
"pointer": "last-anchor.json",
|
||||
"journal": os.path.basename(journal_path),
|
||||
"status": payload.get("status"),
|
||||
"created_tables": created,
|
||||
"created_new": payload.get("created_new"),
|
||||
"anchor_csv": ",".join(created),
|
||||
"carried_over": carried_over,
|
||||
"carried_from": carried_from,
|
||||
"finished_at": payload.get("finished_at"),
|
||||
"git_head": payload.get("git_head"),
|
||||
"db_name": payload.get("db_name"),
|
||||
"actor": payload.get("actor"),
|
||||
"written_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"converged_at": None,
|
||||
}
|
||||
tmp = "%s.tmp.%d" % (path, os.getpid())
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(doc, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
try:
|
||||
ref = path if os.path.exists(path) else journal_path
|
||||
if os.path.exists(ref):
|
||||
st = os.stat(ref)
|
||||
os.chmod(tmp, st.st_mode & 0o777)
|
||||
os.chown(tmp, st.st_uid, st.st_gid)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
|
||||
def list_journals():
|
||||
d = journal_dir()
|
||||
@@ -282,6 +339,11 @@ def cmd_apply(args, engine, conn_desc) -> int:
|
||||
structural=structural,
|
||||
)
|
||||
path = write_journal(payload)
|
||||
try:
|
||||
write_anchor_pointer(payload, path)
|
||||
except Exception as exc: # 加性:指针写失败不阻断迁移,由收敛步 fail-closed 兜住
|
||||
print(f"[apply] [warn] 锚指针写入失败(收敛步会据此 rc=2 中止部署):"
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
except Exception as exc: # DDL 无事务:失败时记录已建对象,供 --rollback 恢复
|
||||
try:
|
||||
after_missing, _ = schema_diff(engine)
|
||||
@@ -296,6 +358,10 @@ def cmd_apply(args, engine, conn_desc) -> int:
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
path = write_journal(payload)
|
||||
try:
|
||||
write_anchor_pointer(payload, path)
|
||||
except Exception as exc: # 加性:失败路径同样留锚(status=failed),收敛步只消费 applied
|
||||
print(f"[apply] [warn] 锚指针写入失败(失败路径):{type(exc).__name__}: {exc}")
|
||||
print(f"[FAIL] 迁移失败:{payload['error']}")
|
||||
print(f" 已建对象 {created}(journal: {path})")
|
||||
print(f" 回滚:python3 scripts/db_migrate.py --rollback {os.path.basename(path)}")
|
||||
|
||||
Reference in New Issue
Block a user