From eb93cc833ff4f87499bb695e6d3874d57298ed6c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 30 Jun 2026 08:21:01 +0800 Subject: [PATCH] fix(api): fail closed deploy runtime readback --- ...oooi_production_deploy_readback_blocker.py | 57 ++++++++++++++++--- .../services/delivery_closure_workbench.py | 41 +++++++++++++ ...oooi_production_deploy_readback_blocker.py | 47 ++++++++++++++- docs/LOGBOOK.md | 15 +++++ 4 files changed, 150 insertions(+), 10 deletions(-) diff --git a/apps/api/src/services/awoooi_production_deploy_readback_blocker.py b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py index 6e8c8c665..fcec59a51 100644 --- a/apps/api/src/services/awoooi_production_deploy_readback_blocker.py +++ b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py @@ -98,17 +98,49 @@ def _enrich_runtime_build_readback(payload: dict[str, Any]) -> None: return readback = _dict(payload.get("readback")) + rollups = _dict(payload.get("rollups")) + committed_source_sha = str(readback.get("observed_source_control_main_sha") or "") + committed_image_sha = str(readback.get("production_image_tag_sha") or "") + source_matches_runtime = committed_source_sha == build_sha + image_matches_runtime = committed_image_sha == build_sha + readback["runtime_build_commit_sha"] = build_sha readback["runtime_build_commit_short_sha"] = build_sha[:10] - readback["observed_source_control_main_sha"] = build_sha - readback["observed_source_control_main_short_sha"] = build_sha[:10] - readback["production_image_tag_sha"] = build_sha - readback["production_image_tag_short_sha"] = build_sha[:10] - readback["production_image_tag_matches_main"] = True + readback["runtime_build_matches_committed_source_control_readback"] = ( + source_matches_runtime + ) + readback["runtime_build_matches_committed_production_image_tag"] = ( + image_matches_runtime + ) + readback["runtime_build_readback_status"] = ( + "matches_committed_deploy_readback" + if source_matches_runtime and image_matches_runtime + else "runtime_build_diverges_from_committed_deploy_readback" + ) - rollups = _dict(payload.get("rollups")) - rollups["source_control_main_ready"] = True - rollups["production_image_tag_matches_main"] = True + if source_matches_runtime and image_matches_runtime: + return + + # The runtime build SHA is evidence about the currently running image, not + # permission to rewrite source-control truth. If it diverges from the + # committed deploy snapshot, fail closed so Workbench cannot report a false + # "image tag matches main" closure while production routes are stale. + readback["production_image_tag_matches_main"] = False + rollups["source_control_main_ready"] = False + rollups["production_image_tag_matches_main"] = False + payload["status"] = "runtime_build_readback_stale" + _append_unique( + payload, + "blockers", + "runtime_build_commit_sha_does_not_match_committed_production_readback", + ) + _append_unique( + payload, + "next_actions", + "refresh_production_deploy_readback_from_current_main_and_runtime_route_smoke", + ) + rollups["hard_blocker_count"] = len(_list(payload.get("blockers"))) + rollups["next_action_count"] = len(_list(payload.get("next_actions"))) def _require_no_internal_network_literals(value: Any, label: str) -> None: @@ -125,3 +157,12 @@ def _dict(value: Any) -> dict[str, Any]: def _list(value: Any) -> list[Any]: return value if isinstance(value, list) else [] + + +def _append_unique(payload: dict[str, Any], key: str, item: str) -> None: + values = payload.get(key) + if not isinstance(values, list): + values = [] + payload[key] = values + if item not in values: + values.append(item) diff --git a/apps/api/src/services/delivery_closure_workbench.py b/apps/api/src/services/delivery_closure_workbench.py index 1f46ffd5a..db23d16da 100644 --- a/apps/api/src/services/delivery_closure_workbench.py +++ b/apps/api/src/services/delivery_closure_workbench.py @@ -291,6 +291,26 @@ def build_delivery_closure_workbench( production_deploy_readback.get("production_image_tag_short_sha") or "" ), + "runtime_build_commit_short_sha": str( + production_deploy_readback.get("runtime_build_commit_short_sha") + or "" + ), + "runtime_build_readback_status": str( + production_deploy_readback.get("runtime_build_readback_status") + or "" + ), + "runtime_build_matches_committed_source_control_readback": ( + production_deploy_readback.get( + "runtime_build_matches_committed_source_control_readback" + ) + is True + ), + "runtime_build_matches_committed_production_image_tag": ( + production_deploy_readback.get( + "runtime_build_matches_committed_production_image_tag" + ) + is True + ), "production_image_tag_matches_main": production_deploy_readback.get( "production_image_tag_matches_main" ) @@ -1632,6 +1652,27 @@ def build_delivery_closure_workbench( "production_image_tag_matches_main" ) is True, + "production_deploy_runtime_build_commit_sha": str( + production_deploy_readback.get("runtime_build_commit_sha") or "" + ), + "production_deploy_runtime_build_commit_short_sha": str( + production_deploy_readback.get("runtime_build_commit_short_sha") or "" + ), + "production_deploy_runtime_build_readback_status": str( + production_deploy_readback.get("runtime_build_readback_status") or "" + ), + "production_deploy_runtime_build_matches_committed_source_control_readback": ( + production_deploy_readback.get( + "runtime_build_matches_committed_source_control_readback" + ) + is True + ), + "production_deploy_runtime_build_matches_committed_production_image_tag": ( + production_deploy_readback.get( + "runtime_build_matches_committed_production_image_tag" + ) + is True + ), "production_deploy_governance_fields_present": production_deploy_rollups.get( "production_governance_fields_present" ) diff --git a/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py b/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py index a2bb9d01b..bf83fb597 100644 --- a/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py +++ b/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py @@ -15,10 +15,53 @@ def test_production_deploy_readback_uses_runtime_build_commit(monkeypatch): assert readback["runtime_build_commit_sha"] == build_sha assert readback["runtime_build_commit_short_sha"] == build_sha[:10] - assert readback["observed_source_control_main_sha"] == build_sha - assert readback["production_image_tag_sha"] == build_sha + assert readback["observed_source_control_main_sha"] == ( + "a70c6756d9e76c33143676eef82bab7a49ac1839" + ) + assert readback["production_image_tag_sha"] == ( + "a70c6756d9e76c33143676eef82bab7a49ac1839" + ) + assert ( + readback["runtime_build_matches_committed_source_control_readback"] + is False + ) + assert ( + readback["runtime_build_matches_committed_production_image_tag"] + is False + ) + assert readback["runtime_build_readback_status"] == ( + "runtime_build_diverges_from_committed_deploy_readback" + ) + assert readback["production_image_tag_matches_main"] is False + assert payload["status"] == "runtime_build_readback_stale" + assert rollups["source_control_main_ready"] is False + assert rollups["production_image_tag_matches_main"] is False + assert rollups["hard_blocker_count"] == 1 + assert payload["blockers"] == [ + "runtime_build_commit_sha_does_not_match_committed_production_readback" + ] + + +def test_production_deploy_readback_keeps_closure_when_runtime_matches_snapshot( + monkeypatch, +): + build_sha = "a70c6756d9e76c33143676eef82bab7a49ac1839" + monkeypatch.setenv("AWOOOI_BUILD_COMMIT_SHA", build_sha) + + payload = load_latest_awoooi_production_deploy_readback_blocker() + readback = payload["readback"] + rollups = payload["rollups"] + + assert readback["runtime_build_commit_sha"] == build_sha + assert readback["runtime_build_matches_committed_source_control_readback"] is True + assert readback["runtime_build_matches_committed_production_image_tag"] is True + assert readback["runtime_build_readback_status"] == ( + "matches_committed_deploy_readback" + ) assert readback["production_image_tag_matches_main"] is True + assert payload["status"] == "closure_verified" assert rollups["production_image_tag_matches_main"] is True + assert rollups["hard_blocker_count"] == 0 def test_production_deploy_readback_ignores_non_sha_runtime_value(monkeypatch): diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 2cb45c848..9146f6022 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -11,6 +11,21 @@ **邊界**:未重啟主機,未刪 pod,未 rollout restart,未操作 DB / K8s write / Docker / firewall,未讀 secret / token / raw sessions / SQLite / `.env`,未使用 GitHub / `gh` / GitHub API。 +## 2026-06-30 — 08:18 production deploy readback false-positive fail-closed + +**照主線修正的問題**: +- Gitea CD `#4003` 對 `49c02e5b3 chore(api): retrigger log consumer deploy` 回 Failure;後續 `6083cb71a chore(cd): deploy 49c02e5 [skip ci]` 只代表 deploy marker 進 main,production 新 API endpoint 仍回 404,不得宣告部署完成。 +- Production Delivery Workbench 仍顯示 `production_deploy_image_tag_matches_main=true`,但新 `/api/v1/agents/gitea-workflow-runner-owner-attestation-request` 與 `/api/v1/agents/ai-agent-log-controlled-writeback-consumer-readback` 皆未上線,確認 deploy readback 有 false-positive 風險。 +- 修正 `awoooi_production_deploy_readback_blocker`:runtime `AWOOOI_BUILD_COMMIT_SHA` 只作為目前運行 build evidence,不再覆寫 `observed_source_control_main_sha` / `production_image_tag_sha`,若 runtime build 與 committed deploy snapshot 不一致,改標 `runtime_build_readback_stale`、`production_image_tag_matches_main=false` 並新增 blocker。 +- Delivery Workbench 新增 runtime build readback 欄位,讓主線可以直接看見 runtime build 是否匹配 committed source-control / production image readback。 + +**驗證**: +- Focused pytest:production deploy readback / Delivery Workbench / Gitea runner attestation / LOG consumer / autonomous runtime-control / CD profile `44 passed`。 +- `py_compile`、`git diff --check`、Gitea runner pressure guard:通過。 +- 3.11 本地 smoke 用 mismatch SHA 讀回 `production_deploy_status=runtime_build_readback_stale`、`production_deploy_image_tag_matches_main=false`。 + +**邊界**:未 workflow_dispatch,未重啟主機,未 restart Docker / K3s / Nginx / DB / firewall,未寫 K8s,未讀 secret / token / raw sessions / SQLite / `.env`,未使用 GitHub / `gh` / GitHub API。 + ## 2026-06-30 — 02:28 P1-LOG KM/RAG/PlayBook/MCP consumer readback **照主線完成的實作**: