From 40dc4315f2835c78e9eff6a4505fd848bd2c85a8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 28 Jun 2026 19:32:36 +0800 Subject: [PATCH] feat(delivery): expose production deploy readback blocker --- ...oooi_production_deploy_readback_blocker.py | 104 ++++++++++++++++++ .../services/delivery_closure_workbench.py | 76 +++++++++++++ .../test_delivery_closure_workbench_api.py | 53 ++++++++- docs/LOGBOOK.md | 29 +++++ ...tion-deploy-readback-blocker.snapshot.json | 64 +++++++++++ 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/awoooi_production_deploy_readback_blocker.py create mode 100644 docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json diff --git a/apps/api/src/services/awoooi_production_deploy_readback_blocker.py b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py new file mode 100644 index 000000000..acc3e1e25 --- /dev/null +++ b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py @@ -0,0 +1,104 @@ +"""AWOOOI production deploy readback blocker. + +Loads the committed production deploy/readback blocker snapshot. This service is +read-only: it does not call Gitea, trigger workflows, inspect credentials, touch +K8s, or modify runtime state. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.services.snapshot_paths import default_operations_dir + +_DEFAULT_OPERATIONS_DIR = default_operations_dir(Path(__file__)) +_SNAPSHOT_FILE = "awoooi-production-deploy-readback-blocker.snapshot.json" +_SCHEMA_VERSION = "awoooi_production_deploy_readback_blocker_v1" + + +def load_latest_awoooi_production_deploy_readback_blocker( + operations_dir: Path | None = None, +) -> dict[str, Any]: + """Load the committed production deploy/readback blocker snapshot.""" + directory = operations_dir or _DEFAULT_OPERATIONS_DIR + path = directory / _SNAPSHOT_FILE + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + _require_schema(payload, str(path)) + _require_operation_boundaries(payload, str(path)) + _require_rollup_consistency(payload, str(path)) + _require_no_internal_network_literals(payload, str(path)) + return payload + + +def _require_schema(payload: dict[str, Any], label: str) -> None: + actual = payload.get("schema_version") + if actual != _SCHEMA_VERSION: + raise ValueError( + f"{label}: expected schema_version={_SCHEMA_VERSION}, got {actual!r}" + ) + + +def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None: + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + + blocked_flags = { + "deploy_trigger_allowed", + "workflow_modification_allowed", + "gitea_api_write_allowed", + "host_write_allowed", + "k8s_write_allowed", + "secret_read_allowed", + "github_api_allowed", + "raw_session_or_sqlite_read_allowed", + } + allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if allowed: + raise ValueError(f"{label}: operation boundaries must remain false: {allowed}") + + +def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: + readback = _dict(payload.get("readback")) + rollups = _dict(payload.get("rollups")) + blockers = _list(payload.get("blockers")) + next_actions = _list(payload.get("next_actions")) + + if rollups.get("hard_blocker_count") != len(blockers): + raise ValueError(f"{label}: hard_blocker_count must match blockers") + if rollups.get("next_action_count") != len(next_actions): + raise ValueError(f"{label}: next_action_count must match next_actions") + if rollups.get("production_image_tag_matches_main") is not ( + readback.get("production_image_tag_matches_main") is True + ): + raise ValueError( + f"{label}: production_image_tag_matches_main must match readback" + ) + if rollups.get("authorized_dispatch_channel_ready") is not ( + readback.get("authorized_dispatch_channel_ready") is True + ): + raise ValueError( + f"{label}: authorized_dispatch_channel_ready must match readback" + ) + + +def _require_no_internal_network_literals(value: Any, label: str) -> None: + text = json.dumps(value, ensure_ascii=False, sort_keys=True) + forbidden = ["192.168.0.", "api.github.com", "github.com/"] + hits = [item for item in forbidden if item in text] + if hits: + raise ValueError(f"{label}: forbidden literal(s) in public readback: {hits}") + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] diff --git a/apps/api/src/services/delivery_closure_workbench.py b/apps/api/src/services/delivery_closure_workbench.py index 356ee3e5e..08688ce81 100644 --- a/apps/api/src/services/delivery_closure_workbench.py +++ b/apps/api/src/services/delivery_closure_workbench.py @@ -9,6 +9,9 @@ from __future__ import annotations from typing import Any +from src.services.awoooi_production_deploy_readback_blocker import ( + load_latest_awoooi_production_deploy_readback_blocker, +) from src.services.awoooi_status_cleanup_dashboard import ( load_latest_awoooi_status_cleanup_dashboard, ) @@ -28,12 +31,14 @@ _SCHEMA_VERSION = "delivery_closure_workbench_v1" def load_delivery_closure_workbench() -> dict[str, Any]: """Load existing delivery snapshots and return a compact workbench model.""" status_cleanup = load_latest_awoooi_status_cleanup_dashboard() + production_deploy = load_latest_awoooi_production_deploy_readback_blocker() github = _load_github_private_backup_evidence_gate() gitea = load_latest_gitea_workflow_runner_health() runtime = load_latest_runtime_surface_inventory() backup = load_latest_backup_dr_readiness_matrix() return build_delivery_closure_workbench( status_cleanup=status_cleanup, + production_deploy=production_deploy, github=github, gitea=gitea, runtime=runtime, @@ -44,6 +49,7 @@ def load_delivery_closure_workbench() -> dict[str, Any]: def build_delivery_closure_workbench( *, status_cleanup: dict[str, Any], + production_deploy: dict[str, Any], github: dict[str, Any], gitea: dict[str, Any], runtime: dict[str, Any], @@ -59,6 +65,8 @@ def build_delivery_closure_workbench( github_preflight.get("internal_governance_writeback") or github.get("internal_governance_writeback") ) + production_deploy_readback = _dict(production_deploy.get("readback")) + production_deploy_rollups = _dict(production_deploy.get("rollups")) gitea_status = _dict(gitea.get("program_status")) gitea_rollups = _dict(gitea.get("rollups")) runtime_status = _dict(runtime.get("program_status")) @@ -97,6 +105,46 @@ def build_delivery_closure_workbench( "href": "/governance?tab=automation-inventory", "next_action": _first_string(status_cleanup.get("next_actions")), }, + { + "id": "production_deploy", + "source_id": "production_deploy_readback", + "completion_percent": _percent( + 100 + if production_deploy_rollups.get("production_image_tag_matches_main") + is True + else 40 + ), + "status": str(production_deploy.get("status") or "unknown"), + "blocker_count": _int(production_deploy_rollups.get("hard_blocker_count")), + "metric": { + "kind": "deploy_readback", + "source_control_main_short_sha": str( + production_deploy_readback.get("source_control_main_short_sha") + or "" + ), + "production_image_tag_short_sha": str( + production_deploy_readback.get("production_image_tag_short_sha") + or "" + ), + "production_image_tag_matches_main": production_deploy_readback.get( + "production_image_tag_matches_main" + ) + is True, + "current_main_cd_run_visible": production_deploy_readback.get( + "current_main_cd_run_visible" + ) + is True, + "authorized_dispatch_channel_ready": production_deploy_readback.get( + "authorized_dispatch_channel_ready" + ) + is True, + "latest_visible_cd_run_id": str( + production_deploy_readback.get("latest_visible_cd_run_id") or "" + ), + }, + "href": "/deployments", + "next_action": _first_string(production_deploy.get("next_actions")), + }, { "id": "github", "source_id": "github_private_backup", @@ -208,6 +256,7 @@ def build_delivery_closure_workbench( source_statuses = [ _source_status("status_cleanup", status_cleanup), + _source_status("production_deploy_readback", production_deploy), _source_status("github_private_backup", github), _source_status("gitea_ci_cd", gitea), _source_status("runtime_surface", runtime), @@ -257,6 +306,29 @@ def build_delivery_closure_workbench( "workflow_trigger_authorized" ) is True, + "production_deploy_status": str(production_deploy.get("status") or ""), + "production_deploy_source_control_main_ready": production_deploy_rollups.get( + "source_control_main_ready" + ) + is True, + "production_deploy_image_tag_matches_main": production_deploy_rollups.get( + "production_image_tag_matches_main" + ) + is True, + "production_deploy_governance_fields_present": production_deploy_rollups.get( + "production_governance_fields_present" + ) + is True, + "production_deploy_authorized_dispatch_channel_ready": ( + production_deploy_rollups.get("authorized_dispatch_channel_ready") + is True + ), + "production_deploy_hard_blocker_count": _int( + production_deploy_rollups.get("hard_blocker_count") + ), + "production_deploy_latest_visible_cd_run_id": str( + production_deploy_readback.get("latest_visible_cd_run_id") or "" + ), "github_write_channel_ready": github_preflight.get( "github_write_channel_ready" ) @@ -321,6 +393,10 @@ def build_delivery_closure_workbench( "workflow_trigger_allowed" ) is True, + "production_deploy_trigger_allowed": _dict( + production_deploy.get("operation_boundaries") + ).get("deploy_trigger_allowed") + is True, "github_write_channel_ready": github_preflight.get( "github_write_channel_ready" ) diff --git a/apps/api/tests/test_delivery_closure_workbench_api.py b/apps/api/tests/test_delivery_closure_workbench_api.py index 4dc8af225..02746e15a 100644 --- a/apps/api/tests/test_delivery_closure_workbench_api.py +++ b/apps/api/tests/test_delivery_closure_workbench_api.py @@ -16,14 +16,26 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): assert response.status_code == 200 data = response.json() assert data["schema_version"] == "delivery_closure_workbench_v1" - assert data["summary"]["source_count"] == 5 - assert data["summary"]["loaded_source_count"] == 5 + assert data["summary"]["source_count"] == 6 + assert data["summary"]["loaded_source_count"] == 6 assert data["summary"]["runtime_execution_authorized"] is False assert data["summary"]["remote_write_authorized"] is True assert data["summary"]["repo_creation_authorized"] is True assert data["summary"]["visibility_change_authorized"] is True assert data["summary"]["refs_sync_authorized"] is True assert data["summary"]["workflow_trigger_authorized"] is True + assert data["summary"]["production_deploy_status"] == ( + "blocked_waiting_authorized_gitea_workflow_dispatch" + ) + assert data["summary"]["production_deploy_source_control_main_ready"] is True + assert data["summary"]["production_deploy_image_tag_matches_main"] is False + assert data["summary"]["production_deploy_governance_fields_present"] is False + assert ( + data["summary"]["production_deploy_authorized_dispatch_channel_ready"] + is False + ) + assert data["summary"]["production_deploy_hard_blocker_count"] == 1 + assert data["summary"]["production_deploy_latest_visible_cd_run_id"] == "3848" assert data["summary"]["github_write_channel_ready"] is False assert data["summary"]["github_account_status"] == "suspended" assert data["summary"]["github_account_suspended"] is True @@ -47,17 +59,51 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): lanes = {lane["id"]: lane for lane in data["lanes"]} sources = {source["id"]: source for source in data["source_statuses"]} - assert sorted(lanes) == ["backup", "gitea", "github", "release", "runtime"] + assert sorted(lanes) == [ + "backup", + "gitea", + "github", + "production_deploy", + "release", + "runtime", + ] assert lanes["release"]["metric"]["kind"] == "blocked_gate" assert lanes["release"]["status"] == "controlled_status_cleanup_package_ready" assert lanes["release"]["blocker_count"] == 0 assert lanes["release"]["metric"]["blocked"] == 0 assert lanes["release"]["metric"]["total"] == 5 + assert lanes["production_deploy"]["metric"]["kind"] == "deploy_readback" + assert lanes["production_deploy"]["status"] == ( + "blocked_waiting_authorized_gitea_workflow_dispatch" + ) + assert lanes["production_deploy"]["blocker_count"] == 1 + assert lanes["production_deploy"]["metric"]["source_control_main_short_sha"] == ( + "b1da5cc7d324" + ) + assert lanes["production_deploy"]["metric"][ + "production_image_tag_short_sha" + ] == "af45811e87" + assert ( + lanes["production_deploy"]["metric"]["production_image_tag_matches_main"] + is False + ) + assert lanes["production_deploy"]["metric"]["current_main_cd_run_visible"] is False + assert ( + lanes["production_deploy"]["metric"]["authorized_dispatch_channel_ready"] + is False + ) + assert lanes["production_deploy"]["metric"]["latest_visible_cd_run_id"] == "3848" assert lanes["github"]["metric"]["kind"] == "private_backup_verified" assert lanes["gitea"]["metric"]["kind"] == "workflow_count" assert lanes["runtime"]["metric"]["kind"] == "surface_count" assert lanes["backup"]["metric"]["kind"] == "readiness_row_count" assert sources["github_private_backup"]["loaded"] is True + assert sources["production_deploy_readback"]["loaded"] is True + assert ( + sources["production_deploy_readback"]["schema_version"] + == "awoooi_production_deploy_readback_blocker_v1" + ) + assert sources["production_deploy_readback"]["missing_reason"] == "" assert ( sources["github_private_backup"]["schema_version"] == "github_target_private_backup_evidence_gate_v1" @@ -111,6 +157,7 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): assert boundaries["visibility_change_allowed"] is True assert boundaries["refs_sync_allowed"] is True assert boundaries["workflow_trigger_allowed"] is True + assert boundaries["production_deploy_trigger_allowed"] is False assert boundaries["github_write_channel_ready"] is False assert boundaries["github_controlled_apply_allowed"] is False assert boundaries["secret_value_collection_allowed"] is False diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index a96879bcc..c32cc9caf 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -48726,6 +48726,35 @@ production browser smoke: **本段驗證目標**: - JSON parse、`py_compile`、focused GitHub gate API tests、Delivery Workbench test、`git diff --check`。 - 此段只關閉內部 MCP / RAG / KM / PlayBook / LOG governance gap;GitHub executable channel 仍維持 freeze / external unblock。 + +## 2026-06-28 — 19:28 Production deploy readback blocker 機器可讀化 + +**背景**: +- GitHub operator unblock governance closure 已在 Gitea `main` 歷史內,最新 source control main 為 `b1da5cc7d324`。 +- Production 仍讀回舊 image tag `af45811e876fda322ee63c036fbc39c9f07ffd76`,`/api/v1/agents/github-target-controlled-execution-preflight` 與 `/api/v1/agents/delivery-closure-workbench` 尚未出現 `internal_governance_writeback` / KM / PlayBook 新欄位。 +- Gitea actions 公開頁僅見舊 CD run `#3848` 指向 `af45811e87`;未登入狀態沒有 Run workflow 按鈕,未帶 token 的 workflow dispatch 回 `401 token is required`。 + +**完成內容**: +- 新增 `docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json`,把 source main、production tag、CD run 與 dispatch blocker 收斂為 P0 read-only snapshot。 +- 新增 `awoooi_production_deploy_readback_blocker` loader,強制 `deploy_trigger_allowed=false`、`workflow_modification_allowed=false`、`gitea_api_write_allowed=false`、`host_write_allowed=false`、`k8s_write_allowed=false`、`secret_read_allowed=false`。 +- `delivery_closure_workbench` 新增 `production_deploy` lane 與 summary counters:`production_deploy_image_tag_matches_main=false`、`production_deploy_authorized_dispatch_channel_ready=false`、`production_deploy_hard_blocker_count=1`。 + +**驗證結果**: +- JSON parse:通過。 +- `py_compile`:通過。 +- `ruff check`:通過。 +- focused pytest:`20 passed`。 +- `git diff --check`:通過。 + +**仍維持**: +- Production 尚未部署最新 `main`,不能宣稱 runtime closure 完成。 +- 沒有使用 GitHub app / connector / MCP、`gh`、GitHub API、GitHub Actions、PR / issue / search。 +- 沒有讀 raw sessions / SQLite / auth / `.env` / token / cookie;沒有 host / Docker / K8s / firewall / Wazuh runtime 操作。 +- 沒有修改 Gitea workflow trigger、沒有 workflow_dispatch、沒有手改 K8s image tag、沒有重開 110 runner。 + +**下一個 P0**: +- 取得合法的 Gitea manual workflow_dispatch channel 後觸發 `cd.yaml ref=main`。 +- CD 完成後重跑 production readback,目標是 Workbench 與 GitHub preflight production payload 出現 `internal_governance_writeback` 與 KM / PlayBook / LOG counters。 ## 2026-06-28 — 19:09 Wazuh allowlisted check-mode dry-run 本地完成 **時間與來源**: diff --git a/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json new file mode 100644 index 000000000..b2d62e407 --- /dev/null +++ b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json @@ -0,0 +1,64 @@ +{ + "schema_version": "awoooi_production_deploy_readback_blocker_v1", + "generated_at": "2026-06-28T19:28:54+08:00", + "status": "blocked_waiting_authorized_gitea_workflow_dispatch", + "priority": "P0", + "scope": "awoooi_production_truth", + "readback": { + "source_control_main_sha": "b1da5cc7d32415667a1a74288874f817bb97f639", + "source_control_main_short_sha": "b1da5cc7d324", + "governance_closure_merge_sha": "27b96f0450d0e3ca6651d6b5f274a341dd727ef2", + "governance_closure_commit_sha": "9e3e7fbb6ba3ffd324b45abf3ad1e7b6ec826b22", + "production_image_tag_sha": "af45811e876fda322ee63c036fbc39c9f07ffd76", + "production_image_tag_short_sha": "af45811e87", + "production_image_tag_matches_main": false, + "production_preflight_http_status": 200, + "production_workbench_http_status": 200, + "production_internal_governance_writeback_present": false, + "production_workbench_governance_ready_present": false, + "latest_visible_cd_run_id": "3848", + "latest_visible_cd_run_commit_short_sha": "af45811e87", + "current_main_cd_run_visible": false, + "manual_run_button_visible": false, + "gitea_sign_in_required": true, + "dispatch_without_token_http_status": 401, + "dispatch_without_token_message": "token is required", + "authorized_dispatch_channel_ready": false + }, + "blockers": [ + { + "id": "authorized_gitea_workflow_dispatch_channel_missing", + "kind": "external_authorized_control_channel", + "severity": "P0", + "description": "Source 已進 Gitea main,但 production image tag 仍停在舊 SHA;目前沒有可用的已授權 Gitea workflow_dispatch channel 觸發 CD。", + "blocked_action": "deploy_current_gitea_main_to_production", + "safe_boundary": "不得讀 token/cookie/session,不得改 workflow 為 push trigger,不得手改 K8s tag,不得重開 110 runner 或 host/K8s runtime。" + } + ], + "next_actions": [ + "使用已授權的 Gitea manual workflow_dispatch channel 觸發 cd.yaml ref=main。", + "CD 完成後讀回 production image tag,確認不再是 af45811e87。", + "重新讀回 /api/v1/agents/github-target-controlled-execution-preflight 與 /api/v1/agents/delivery-closure-workbench,確認 internal_governance_writeback 與 KM / PlayBook counters 出現。" + ], + "rollups": { + "hard_blocker_count": 1, + "next_action_count": 3, + "source_control_main_ready": true, + "production_image_tag_matches_main": false, + "production_governance_fields_present": false, + "authorized_dispatch_channel_ready": false, + "runtime_write_performed": false, + "secret_values_collected": false + }, + "operation_boundaries": { + "read_only_api_allowed": true, + "deploy_trigger_allowed": false, + "workflow_modification_allowed": false, + "gitea_api_write_allowed": false, + "host_write_allowed": false, + "k8s_write_allowed": false, + "secret_read_allowed": false, + "github_api_allowed": false, + "raw_session_or_sqlite_read_allowed": false + } +}