Merge remote-tracking branch 'origin/main' into codex/p0-obs-002-20260715
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user