From 5a593fba5e110288185c5498509b43c37bc52859 Mon Sep 17 00:00:00 2001 From: ogt Date: Tue, 14 Jul 2026 23:57:10 +0800 Subject: [PATCH 1/3] feat(security): mirror Argo CD repo server --- .../security/runtime-image-mirror-policy.json | 29 ++++++++ .../test_runtime_image_mirror_controller.py | 66 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/config/security/runtime-image-mirror-policy.json b/config/security/runtime-image-mirror-policy.json index 351f5f0b7..745de9d6b 100644 --- a/config/security/runtime-image-mirror-policy.json +++ b/config/security/runtime-image-mirror-policy.json @@ -72,6 +72,35 @@ "container": "argocd-notifications-controller" } }, + { + "asset_id": "runtime-image:argocd-repo-server", + "source_index_digest": "sha256:16b92ba472fbb9287459cc52e0ecff07288dff461209955098edb56ce866fe49", + "platform_digest": "sha256:2f25a42949ea69c0dd33f4ce1918c6a01039d6c14a7ecc1d19088504a9d3e94f", + "target_registry_digest": "sha256:8b7e25d7e6036d6750c5bda8920cbd80d8902c25771666f6767dc97bd7fab8fc", + "push_ref": "registry.wooo.work/awoooi/runtime-mirror/argocd:v3.3.6-linux-amd64", + "runtime_ref": "192.168.0.110:5000/awoooi/runtime-mirror/argocd@sha256:8b7e25d7e6036d6750c5bda8920cbd80d8902c25771666f6767dc97bd7fab8fc", + "workload": { + "kind": "deployment", + "namespace": "argocd", + "name": "argocd-repo-server", + "container": "argocd-repo-server" + } + }, + { + "asset_id": "runtime-image:argocd-repo-server-copyutil", + "source_index_digest": "sha256:16b92ba472fbb9287459cc52e0ecff07288dff461209955098edb56ce866fe49", + "platform_digest": "sha256:2f25a42949ea69c0dd33f4ce1918c6a01039d6c14a7ecc1d19088504a9d3e94f", + "target_registry_digest": "sha256:8b7e25d7e6036d6750c5bda8920cbd80d8902c25771666f6767dc97bd7fab8fc", + "push_ref": "registry.wooo.work/awoooi/runtime-mirror/argocd:v3.3.6-linux-amd64", + "runtime_ref": "192.168.0.110:5000/awoooi/runtime-mirror/argocd@sha256:8b7e25d7e6036d6750c5bda8920cbd80d8902c25771666f6767dc97bd7fab8fc", + "workload": { + "kind": "deployment", + "namespace": "argocd", + "name": "argocd-repo-server", + "container": "copyutil", + "container_kind": "init_container" + } + }, { "asset_id": "runtime-image:kured", "source_index_digest": "sha256:2c5d73bb4517a269def38a6cd54d34d82be81793bea9ff1bb35c6533515ad209", diff --git a/scripts/security/tests/test_runtime_image_mirror_controller.py b/scripts/security/tests/test_runtime_image_mirror_controller.py index 58134a7a9..309405e7d 100644 --- a/scripts/security/tests/test_runtime_image_mirror_controller.py +++ b/scripts/security/tests/test_runtime_image_mirror_controller.py @@ -47,7 +47,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): self.assertEqual(policy.work_item_id, "AIA-P0-006-02A") self.assertEqual(policy.risk_level, "high") - self.assertEqual(len(policy.images), 8) + self.assertEqual(len(policy.images), 10) self.assertNotIn("github.com", raw) self.assertNotIn("ghcr.io", raw) self.assertIn('"external_pull_allowed": false', raw) @@ -72,8 +72,8 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): ) ) container_kinds = [image.workload.container_kind for image in policy.images] - self.assertEqual(container_kinds.count("container"), 7) - self.assertEqual(container_kinds.count("init_container"), 1) + self.assertEqual(container_kinds.count("container"), 8) + self.assertEqual(container_kinds.count("init_container"), 2) def test_policy_loads_and_validates_init_container_kind(self) -> None: image = self._image_with_container_kind("init_container") @@ -184,6 +184,66 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): image.runtime_ref.endswith("@" + image.target_registry_digest) ) + def test_argocd_repo_server_batch_reuses_verified_staging_artifact(self) -> None: + policy = controller.load_policy(POLICY_PATH) + staging = controller.load_staging_policy(STAGING_POLICY_PATH).images[0] + batch = { + image.asset_id: image + for image in policy.images + if image.asset_id + in { + "runtime-image:argocd-repo-server", + "runtime-image:argocd-repo-server-copyutil", + } + } + + self.assertEqual( + set(batch), + { + "runtime-image:argocd-repo-server", + "runtime-image:argocd-repo-server-copyutil", + }, + ) + self.assertEqual( + { + ( + image.workload.kind, + image.workload.namespace, + image.workload.name, + image.workload.container, + image.workload.container_kind, + ) + for image in batch.values() + }, + { + ( + "deployment", + "argocd", + "argocd-repo-server", + "argocd-repo-server", + "container", + ), + ( + "deployment", + "argocd", + "argocd-repo-server", + "copyutil", + "init_container", + ), + }, + ) + for image in batch.values(): + self.assertEqual(image.source_index_digest, staging.source_index_digest) + self.assertEqual(image.platform_digest, staging.platform_digest) + self.assertEqual(image.push_ref, staging.push_ref) + self.assertEqual( + image.target_registry_digest, + "sha256:8b7e25d7e6036d6750c5bda8920cbd80d8902c25771666f6767dc97bd7fab8fc", + ) + self.assertTrue( + image.runtime_ref.endswith("@" + image.target_registry_digest) + ) + def test_policy_rejects_external_pull_and_mutable_runtime_ref(self) -> None: payload = json.loads(POLICY_PATH.read_text(encoding="utf-8")) with tempfile.TemporaryDirectory() as temp: From 8e3684fc2c119cbfaa6748d361d348b92e31b740 Mon Sep 17 00:00:00 2001 From: AWOOOI CD Date: Wed, 15 Jul 2026 00:41:45 +0800 Subject: [PATCH 2/3] chore(cd): deploy 5a593fb [skip ci] --- k8s/awoooi-prod/06-deployment-api.yaml | 4 ++-- k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml | 2 +- k8s/awoooi-prod/08-deployment-worker.yaml | 2 +- k8s/awoooi-prod/kustomization.yaml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index dbaf78062..ae2109247 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -160,12 +160,12 @@ spec: - 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: "b20d633aa25be07cf1faace558c39e6a39160c53" + value: "5a593fba5e110288185c5498509b43c37bc52859" - name: AWOOOI_DESIRED_API_IMAGE_TAG # 2026-06-30 Codex: CD rewrites this alongside AWOOOI_BUILD_COMMIT_SHA. # Production readback compares runtime image truth against this # GitOps desired tag instead of doing a slow Gitea raw fetch. - value: "b20d633aa25be07cf1faace558c39e6a39160c53" + value: "5a593fba5e110288185c5498509b43c37bc52859" - name: DATABASE_POOL_SIZE # 2026-07-01 Codex: production role `awoooi` currently has a low # connection limit. Keep API pool conservative until DB role diff --git a/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml b/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml index 2a951144c..a5ade2cf5 100644 --- a/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml +++ b/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml @@ -66,7 +66,7 @@ spec: - name: DATABASE_NULL_POOL value: "true" - name: AWOOOI_BUILD_COMMIT_SHA - value: "b20d633aa25be07cf1faace558c39e6a39160c53" + value: "5a593fba5e110288185c5498509b43c37bc52859" - name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER value: "false" - name: ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER diff --git a/k8s/awoooi-prod/08-deployment-worker.yaml b/k8s/awoooi-prod/08-deployment-worker.yaml index c7076dc63..45cd29941 100644 --- a/k8s/awoooi-prod/08-deployment-worker.yaml +++ b/k8s/awoooi-prod/08-deployment-worker.yaml @@ -173,7 +173,7 @@ spec: - name: DATABASE_BOOTSTRAP_DDL_ENABLED value: "false" - name: AWOOOI_BUILD_COMMIT_SHA - value: "b20d633aa25be07cf1faace558c39e6a39160c53" + value: "5a593fba5e110288185c5498509b43c37bc52859" - name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER value: "true" - name: ENABLE_SECURITY_CONTROL_PLANE_MAINTENANCE_WORKER diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index bc2fcc0c5..e5f9e0c44 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -40,9 +40,9 @@ resources: # ⚠️ 重要: name 必須與 deployment YAML 中的 image 完全匹配 (含 tag) # newName + newTag 由 CI 透過 kustomize edit set image 注入 images: -- digest: sha256:7ef6d6dc72bf1737f7b69938d39705a39b94dc8a0f88067da6e0a0bab877676f +- digest: sha256:63787237ff5ace798b0978c0319969b9bff0a3cf44007dbd1c6eecadb4e9465e name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api -- digest: sha256:3d02958c11ae2b2a2e05a9a8c0512465f98f46e8a8eb70596df0c90ffb7f7d3e +- digest: sha256:c454f0af821455d1b33947a706f3464ad06689cf6d3a681f0dcc5d420f90bad6 name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web From 76da612d372401c5f8fe734ac7d70a55e0c1a9b2 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 00:41:34 +0800 Subject: [PATCH 3/3] fix(api): bound asset capability live readback --- .../ai_automation_asset_capability_matrix.py | 22 ++- ...t_ai_automation_asset_capability_matrix.py | 134 ++++++++++++++++++ 2 files changed, 152 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/ai_automation_asset_capability_matrix.py b/apps/api/src/services/ai_automation_asset_capability_matrix.py index 891cd9b41..6df7a1934 100644 --- a/apps/api/src/services/ai_automation_asset_capability_matrix.py +++ b/apps/api/src/services/ai_automation_asset_capability_matrix.py @@ -8,6 +8,7 @@ the database boundary. from __future__ import annotations +import asyncio import hashlib import json import re @@ -29,6 +30,7 @@ RECONCILIATION_SUMMARY_OPERATION = "asset_capability_reconciliation_completed" RECONCILIATION_WORK_ITEM_OPERATION = "asset_capability_reconciliation_work_item_created" FRESHNESS_SLO_SECONDS = 2 * 60 * 60 RECONCILIATION_FRESHNESS_SLO_SECONDS = 36 * 60 * 60 +LIVE_READBACK_TIMEOUT_SECONDS = 8.0 STAGE_IDS = ( "source_truth", @@ -1044,6 +1046,7 @@ async def _load_live_rows( "created_at": item.get("coverage_created_at"), } + asset_ids = list(assets_by_id) or [0] compliance_result = await db.execute( text( """ @@ -1054,7 +1057,7 @@ async def _load_live_rows( ORDER BY asset_id, dimension, detected_at DESC """ ), - {"asset_ids": list(assets_by_id) or [0]}, + {"asset_ids": asset_ids}, ) for row in compliance_result.mappings().all(): item = dict(row) @@ -1072,9 +1075,11 @@ async def _load_live_rows( FROM asset_change_event WHERE detected_at >= NOW() - INTERVAL '36 hours' AND asset_id IS NOT NULL + AND asset_id = ANY(:asset_ids) ORDER BY detected_at DESC """ - ) + ), + {"asset_ids": asset_ids}, ) for row in changes_result.mappings().all(): item = dict(row) @@ -1112,6 +1117,7 @@ async def build_asset_capability_matrix_with_live_readback( *, project_id: str = DEFAULT_PROJECT_ID, repo_root: Path | None = None, + timeout_seconds: float = LIVE_READBACK_TIMEOUT_SECONDS, ) -> dict[str, Any]: """Build the matrix from live DB truth, failing closed to declared scope.""" @@ -1120,12 +1126,17 @@ async def build_asset_capability_matrix_with_live_readback( ) scope_classes = get_ai_automation_program_scope_classes() + bounded_timeout_seconds = max(0.001, float(timeout_seconds)) try: - latest_run, live_assets, receipt = await _load_live_rows(project_id) + latest_run, live_assets, receipt = await asyncio.wait_for( + _load_live_rows(project_id), + timeout=bounded_timeout_seconds, + ) except Exception as exc: logger.warning( "asset_capability_matrix_live_readback_degraded", error_type=type(exc).__name__, + timeout_seconds=bounded_timeout_seconds, ) payload = build_asset_capability_matrix( scope_classes, @@ -1133,8 +1144,9 @@ async def build_asset_capability_matrix_with_live_readback( live_readback_status="degraded", ) payload["live_readback_error_type"] = type(exc).__name__ + payload["live_readback_timeout_seconds"] = bounded_timeout_seconds return payload - return build_asset_capability_matrix( + payload = build_asset_capability_matrix( scope_classes, live_assets=live_assets, latest_run=latest_run, @@ -1142,3 +1154,5 @@ async def build_asset_capability_matrix_with_live_readback( repo_root=repo_root, live_readback_status="ready", ) + payload["live_readback_timeout_seconds"] = bounded_timeout_seconds + return payload diff --git a/apps/api/tests/test_ai_automation_asset_capability_matrix.py b/apps/api/tests/test_ai_automation_asset_capability_matrix.py index 47b3bd417..cdb6acbbe 100644 --- a/apps/api/tests/test_ai_automation_asset_capability_matrix.py +++ b/apps/api/tests/test_ai_automation_asset_capability_matrix.py @@ -1,8 +1,11 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import Any +import src.services.ai_automation_asset_capability_matrix as matrix_service from src.jobs.asset_capability_reconciliation_job import ( build_reconciliation_summary_payload, build_reconciliation_work_item_input, @@ -10,7 +13,9 @@ from src.jobs.asset_capability_reconciliation_job import ( from src.services.ai_automation_asset_capability_matrix import ( REQUIRED_CONTROL_IDS, STAGE_IDS, + _load_live_rows, build_asset_capability_matrix, + build_asset_capability_matrix_with_live_readback, build_reconciliation_candidates, ) from src.services.awoooi_priority_work_order_readback import ( @@ -279,3 +284,132 @@ def test_priority_overlay_projects_aia_p0_006_runtime_controls() -> None: assert work_item["runtime_progress"]["required_control_count"] == len( REQUIRED_CONTROL_IDS ) + + +class _FakeMappings: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def first(self) -> dict[str, Any] | None: + return self._rows[0] if self._rows else None + + def all(self) -> list[dict[str, Any]]: + return self._rows + + +class _FakeResult: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def mappings(self) -> _FakeMappings: + return _FakeMappings(self._rows) + + +class _FakeLiveReadbackDb: + def __init__(self, now: datetime) -> None: + self.now = now + self.calls: list[tuple[str, dict[str, Any]]] = [] + + async def execute( + self, + statement: Any, + params: dict[str, Any] | None = None, + ) -> _FakeResult: + sql = str(statement) + bound = params or {} + self.calls.append((sql, bound)) + if "FROM asset_discovery_run" in sql: + return _FakeResult( + [ + { + "run_id": "00000000-0000-0000-0000-000000000001", + "status": "success", + "ended_at": self.now, + } + ] + ) + if "FROM asset_inventory" in sql: + return _FakeResult( + [ + { + "asset_id": 42, + "asset_key": "host:99", + "asset_type": "host", + "environment": "prod", + "namespace": "", + "name": "99", + "owner_team": "platform_sre", + "source_repo": "wooo/awoooi", + "source_commit_sha": "abc123", + "lifecycle_state": "active", + "last_seen_at": self.now, + "dimension": "auto_monitoring", + "coverage_status": "green", + "coverage_created_at": self.now, + } + ] + ) + if "FROM asset_compliance_snapshot" in sql: + return _FakeResult([]) + if "FROM asset_change_event" in sql: + return _FakeResult([{"asset_id": 42, "change_type": "asset_modified"}]) + if "FROM automation_operation_log" in sql: + return _FakeResult([]) + raise AssertionError(f"unexpected SQL: {sql}") + + +class _FakeDbContext: + def __init__(self, db: _FakeLiveReadbackDb) -> None: + self.db = db + + async def __aenter__(self) -> _FakeLiveReadbackDb: + return self.db + + async def __aexit__(self, *_args: object) -> None: + return None + + +def test_live_change_event_readback_is_scoped_to_selected_asset_ids( + monkeypatch: Any, +) -> None: + now = datetime(2026, 7, 15, 1, 0, tzinfo=UTC) + db = _FakeLiveReadbackDb(now) + + monkeypatch.setattr( + "src.db.base.get_db_context", + lambda _project_id: _FakeDbContext(db), + ) + + _latest_run, assets, _receipt = asyncio.run(_load_live_rows("awoooi")) + + change_sql, change_params = next( + (sql, params) for sql, params in db.calls if "FROM asset_change_event" in sql + ) + assert "asset_id = ANY(:asset_ids)" in change_sql + assert change_params == {"asset_ids": [42]} + assert assets[0]["change_types"] == ["asset_modified"] + + +def test_live_readback_timeout_fails_closed_without_hanging( + monkeypatch: Any, +) -> None: + async def _slow_live_readback( + _project_id: str, + ) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: + await asyncio.sleep(1) + return {}, [], {} + + monkeypatch.setattr(matrix_service, "_load_live_rows", _slow_live_readback) + + payload = asyncio.run( + build_asset_capability_matrix_with_live_readback( + repo_root=_REPO_ROOT, + timeout_seconds=0.01, + ) + ) + + assert payload["live_readback_status"] == "degraded" + assert payload["live_readback_error_type"] == "TimeoutError" + assert payload["live_readback_timeout_seconds"] == 0.01 + assert payload["summary"]["declared_asset_count"] == 193 + assert payload["operation_boundaries"]["host_write_performed"] is False