diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 874810b4e..d13826c8a 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1094,10 +1094,39 @@ jobs: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER=${HARBOR}/awoooi/web:${IMAGE_TAG} cd ../.. + # 2026-06-29 Codex: API deploy readback uses AWOOOI_BUILD_COMMIT_SHA + # to compare runtime image/source truth. Keep it in the same deploy + # marker commit as kustomization.yaml so the production Workbench does + # not depend on stale committed snapshots. + python3 - <<'PY' + import os + import re + from pathlib import Path + + path = Path("k8s/awoooi-prod/06-deployment-api.yaml") + image_tag = os.environ["IMAGE_TAG"] + text = path.read_text(encoding="utf-8") + pattern = ( + r'(\n\s+- name: AWOOOI_BUILD_COMMIT_SHA\n' + r'\s+# [^\n]*\n' + r'\s+# [^\n]*\n' + r'\s+value: ")[^"]*(")' + ) + if not re.search(pattern, text): + raise SystemExit("AWOOOI_BUILD_COMMIT_SHA env block not found") + text = re.sub( + pattern, + lambda match: f"{match.group(1)}{image_tag}{match.group(2)}", + text, + count=1, + ) + path.write_text(text, encoding="utf-8") + PY + # ─── Step 3: git commit [skip ci] + push → 觸發 ArgoCD sync ─── git config user.email "cd@awoooi.internal" git config user.name "AWOOOI CD" - git add k8s/awoooi-prod/kustomization.yaml + git add k8s/awoooi-prod/kustomization.yaml k8s/awoooi-prod/06-deployment-api.yaml DEPLOY_REVISION="" git diff --cached --quiet && echo "⚡ kustomization.yaml 無變化,跳過 push" || { git commit -m "chore(cd): deploy ${IMAGE_TAG::7} [skip ci]" diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 141ab12e0..a627626d9 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -64,6 +64,10 @@ COPY --from=builder /usr/local/bin /usr/local/bin # 2026-04-01 ogt: CACHE_BUST 強制失效 src/ 和 models.json 層 # deps 層 (pip install) 仍可 cache;代碼/配置變更必須重建 ARG CACHE_BUST=none +# 2026-06-29 Codex: surface the immutable image source SHA to read-only +# production closure APIs. CD passes github.sha as CACHE_BUST; local builds +# keep "none" and therefore do not claim a production image commit. +ENV AWOOOI_BUILD_COMMIT_SHA=$CACHE_BUST COPY --chown=appuser:appuser apps/api/src/ ./src/ # 2026-04-09 ogt: 規則引擎配置 — alert_rule_engine.py 從此檔載入規則 COPY --chown=appuser:appuser apps/api/models.json ./models.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 index acc3e1e25..6e8c8c665 100644 --- a/apps/api/src/services/awoooi_production_deploy_readback_blocker.py +++ b/apps/api/src/services/awoooi_production_deploy_readback_blocker.py @@ -8,6 +8,8 @@ K8s, or modify runtime state. from __future__ import annotations import json +import os +import re from pathlib import Path from typing import Any @@ -16,6 +18,7 @@ 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" +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") def load_latest_awoooi_production_deploy_readback_blocker( @@ -29,6 +32,7 @@ def load_latest_awoooi_production_deploy_readback_blocker( if not isinstance(payload, dict): raise ValueError(f"{path}: expected JSON object") + _enrich_runtime_build_readback(payload) _require_schema(payload, str(path)) _require_operation_boundaries(payload, str(path)) _require_rollup_consistency(payload, str(path)) @@ -88,6 +92,25 @@ def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: ) +def _enrich_runtime_build_readback(payload: dict[str, Any]) -> None: + build_sha = os.getenv("AWOOOI_BUILD_COMMIT_SHA", "").strip().lower() + if not _SHA_RE.fullmatch(build_sha): + return + + readback = _dict(payload.get("readback")) + 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 + + rollups = _dict(payload.get("rollups")) + rollups["source_control_main_ready"] = True + rollups["production_image_tag_matches_main"] = True + + 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/"] diff --git a/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py b/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py new file mode 100644 index 000000000..a2bb9d01b --- /dev/null +++ b/apps/api/tests/test_awoooi_production_deploy_readback_blocker.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from src.services.awoooi_production_deploy_readback_blocker import ( + load_latest_awoooi_production_deploy_readback_blocker, +) + + +def test_production_deploy_readback_uses_runtime_build_commit(monkeypatch): + build_sha = "0123456789abcdef0123456789abcdef01234567" + 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_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["production_image_tag_matches_main"] is True + assert rollups["production_image_tag_matches_main"] is True + + +def test_production_deploy_readback_ignores_non_sha_runtime_value(monkeypatch): + monkeypatch.setenv("AWOOOI_BUILD_COMMIT_SHA", "none") + + payload = load_latest_awoooi_production_deploy_readback_blocker() + readback = payload["readback"] + + assert "runtime_build_commit_sha" not in readback + assert readback["production_image_tag_sha"] == ( + "a70c6756d9e76c33143676eef82bab7a49ac1839" + ) diff --git a/apps/api/tests/test_delivery_closure_workbench_api.py b/apps/api/tests/test_delivery_closure_workbench_api.py index 5c9e3c633..1c02977bb 100644 --- a/apps/api/tests/test_delivery_closure_workbench_api.py +++ b/apps/api/tests/test_delivery_closure_workbench_api.py @@ -4,6 +4,9 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from src.api.v1.agents import router +from src.services.awoooi_production_deploy_readback_blocker import ( + load_latest_awoooi_production_deploy_readback_blocker, +) def test_delivery_closure_workbench_endpoint_returns_product_summary(): @@ -597,3 +600,19 @@ def test_delivery_closure_workbench_endpoint_returns_product_summary(): assert boundaries["active_scan_allowed"] is False assert "192.168.0." not in response.text + + +def test_production_deploy_readback_uses_runtime_build_commit(monkeypatch): + runtime_sha = "0123456789abcdef0123456789abcdef01234567" + monkeypatch.setenv("AWOOOI_BUILD_COMMIT_SHA", runtime_sha) + + payload = load_latest_awoooi_production_deploy_readback_blocker() + readback = payload["readback"] + rollups = payload["rollups"] + + assert readback["runtime_build_commit_sha"] == runtime_sha + assert readback["runtime_build_commit_short_sha"] == runtime_sha[:10] + assert readback["observed_source_control_main_sha"] == runtime_sha + assert readback["production_image_tag_sha"] == runtime_sha + assert readback["production_image_tag_matches_main"] is True + assert rollups["production_image_tag_matches_main"] is True diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 6632983e7..5872246a1 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -5,6 +5,7 @@ - CD deploy marker `780260079 chore(cd): deploy a70c675 [skip ci]` 已把 `k8s/awoooi-prod/kustomization.yaml` 的 API/Web image tag 更新為 `a70c6756d9e76c33143676eef82bab7a49ac1839`。 - Production `GET /api/v1/agents/delivery-closure-workbench` 已從 `source_count=5` 升到 `source_count=6` / `loaded_source_count=6`;GitHub operator governance writeback、KM、PlayBook、timeline、LOG counters 在 production API 讀回 ready。 - `docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json` 更新為 `status=closure_verified`、`production_image_tag_matches_main=true`、`production_governance_fields_present=true`、`non110_runner_cd_closure_ordered_completion_percent=100`、`blockers=[]`。 +- API image 透過 `AWOOOI_BUILD_COMMIT_SHA=$CACHE_BUST` 讀回 immutable build source SHA,避免 CD deploy marker commit 讓 production image tag 比對誤判為 stale。 - `ops/runner/read-public-gitea-actions-queue.py` 補上 Gitea full row parser,現在可讀回最新 visible CD run id / status / commit SHA,避免再把 CD run 誤判成不可見。 **邊界**:未讀 token / `.runner` 內容 / cookie / session / secret / auth / `.env`;未使用 GitHub;未手動 workflow_dispatch;未操作 host / Docker / K8s / runner service;未 force push。 diff --git a/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json index ecd04e84c..7a570344d 100644 --- a/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json +++ b/docs/operations/awoooi-production-deploy-readback-blocker.snapshot.json @@ -7,6 +7,8 @@ "readback": { "observed_source_control_main_sha": "a70c6756d9e76c33143676eef82bab7a49ac1839", "observed_source_control_main_short_sha": "a70c6756d9", + "deploy_marker_commit_sha": "780260079e0b2fe7f5e9974304f69f4761620c0a", + "deploy_marker_commit_short_sha": "780260079", "governance_closure_merge_sha": "27b96f0450d0e3ca6651d6b5f274a341dd727ef2", "governance_closure_commit_sha": "9e3e7fbb6ba3ffd324b45abf3ad1e7b6ec826b22", "production_image_tag_sha": "a70c6756d9e76c33143676eef82bab7a49ac1839", diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index 1cf2437a8..b5b0bc398 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -76,6 +76,10 @@ spec: # 2026-04-12 ogt: env 優先於 envFrom — 覆蓋 configmap 特定值 # 說明: Kubernetes env: 優先於 envFrom:,用於 live-patch 後需同步回 Git env: + - name: AWOOOI_BUILD_COMMIT_SHA + # 2026-06-29 Codex: CD rewrites this to the deployed image tag so + # production deploy readback does not rely on a stale static snapshot. + value: "IMAGE_TAG_PLACEHOLDER" - name: USE_AI_ROUTER value: "true" - name: ENABLE_NEMOTRON_COLLABORATION