diff --git a/apps/api/src/jobs/incident_lifecycle_reconciler.py b/apps/api/src/jobs/incident_lifecycle_reconciler.py index c90316c98..b85a31f59 100644 --- a/apps/api/src/jobs/incident_lifecycle_reconciler.py +++ b/apps/api/src/jobs/incident_lifecycle_reconciler.py @@ -23,6 +23,7 @@ import structlog from sqlalchemy import text from src.core.config import settings +from src.core.context import clear_project_context, set_project_context from src.db.base import get_db_context from src.utils.timezone import now_taipei @@ -31,6 +32,7 @@ logger = structlog.get_logger(__name__) BATCH_LIMIT = 100 INTERVAL_SECONDS = 1800 _PROMETHEUS_TIMEOUT_SECONDS = 5.0 +_PROJECT_ID = "awoooi" @dataclass(frozen=True) @@ -43,20 +45,32 @@ class LifecycleCandidate: async def run_incident_lifecycle_reconciler_loop() -> None: """每 30 分鐘收斂一小批已有完成證據的 stuck incident。""" - while True: - try: - resolved, errors = await reconcile_stuck_incidents() - if resolved > 0 or errors > 0: - logger.info( - "incident_lifecycle_reconciler_done", - resolved=resolved, - errors=errors, - batch_limit=BATCH_LIMIT, + context_tokens = set_project_context( + _PROJECT_ID, + source="incident_lifecycle_reconciler", + ) + try: + while True: + try: + resolved, errors = await reconcile_stuck_incidents() + if resolved > 0 or errors > 0: + logger.info( + "incident_lifecycle_reconciler_done", + project_id=_PROJECT_ID, + resolved=resolved, + errors=errors, + batch_limit=BATCH_LIMIT, + ) + except Exception as exc: + logger.warning( + "incident_lifecycle_reconciler_loop_failed", + project_id=_PROJECT_ID, + error=str(exc), ) - except Exception as exc: - logger.warning("incident_lifecycle_reconciler_loop_failed", error=str(exc)) - await asyncio.sleep(INTERVAL_SECONDS) + await asyncio.sleep(INTERVAL_SECONDS) + finally: + clear_project_context(context_tokens) async def reconcile_stuck_incidents(limit: int = BATCH_LIMIT) -> tuple[int, int]: diff --git a/apps/api/tests/test_incident_lifecycle_reconciler.py b/apps/api/tests/test_incident_lifecycle_reconciler.py index 68aaa2b86..342d932a7 100644 --- a/apps/api/tests/test_incident_lifecycle_reconciler.py +++ b/apps/api/tests/test_incident_lifecycle_reconciler.py @@ -1,8 +1,11 @@ +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock import pytest +from src.core.context import get_current_project_context +from src.jobs import incident_lifecycle_reconciler as reconciler from src.jobs.incident_lifecycle_reconciler import ( LifecycleCandidate, reconcile_stuck_incidents, @@ -50,3 +53,30 @@ async def test_reconcile_stuck_incidents_resolves_strong_evidence(monkeypatch): "resolution_type": "timeout", "emit_postmortem": False, } + + +@pytest.mark.asyncio +async def test_reconciler_loop_binds_and_clears_project_context(monkeypatch): + observed_contexts: list[dict[str, str | None]] = [] + + async def fake_reconcile() -> tuple[int, int]: + observed_contexts.append(get_current_project_context()) + return 0, 0 + + async def cancel_after_first_pass(_seconds: int) -> None: + raise asyncio.CancelledError + + monkeypatch.setattr(reconciler, "reconcile_stuck_incidents", fake_reconcile) + monkeypatch.setattr(reconciler.asyncio, "sleep", cancel_after_first_pass) + + with pytest.raises(asyncio.CancelledError): + await reconciler.run_incident_lifecycle_reconciler_loop() + + assert observed_contexts == [ + { + "project_id": "awoooi", + "source": "incident_lifecycle_reconciler", + "request_id": None, + } + ] + assert get_current_project_context()["project_id"] is None diff --git a/config/security/runtime-image-mirror-policy.json b/config/security/runtime-image-mirror-policy.json index e386d21a1..b3a3cb5b7 100644 --- a/config/security/runtime-image-mirror-policy.json +++ b/config/security/runtime-image-mirror-policy.json @@ -144,6 +144,20 @@ "container_kind": "init_container" } }, + { + "asset_id": "runtime-image:argocd-redis", + "source_index_digest": "sha256:08ad0b1d280850169a790dba1393ff7a90aef951fc19632cf4d3ce4f78e679ba", + "platform_digest": "sha256:e499175dfb27569cd40010c2eee346113db95fdd0efc88ab9fd70a9e807f4542", + "target_registry_digest": "sha256:1e1de310669186e8a85a676ed5352f6078b09658ee578508f7608fe7beff78f0", + "push_ref": "registry.wooo.work/awoooi/runtime-mirror/redis:8.2.3-alpine-linux-amd64", + "runtime_ref": "192.168.0.110:5000/awoooi/runtime-mirror/redis@sha256:1e1de310669186e8a85a676ed5352f6078b09658ee578508f7608fe7beff78f0", + "workload": { + "kind": "deployment", + "namespace": "argocd", + "name": "argocd-redis", + "container": "redis" + } + }, { "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 985c95f8a..d0b3f742e 100644 --- a/scripts/security/tests/test_runtime_image_mirror_controller.py +++ b/scripts/security/tests/test_runtime_image_mirror_controller.py @@ -52,7 +52,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), 13) + self.assertEqual(len(policy.images), 14) self.assertNotIn("github.com", raw) self.assertNotIn("ghcr.io", raw) self.assertIn('"external_pull_allowed": false', raw) @@ -77,7 +77,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): ) ) container_kinds = [image.workload.container_kind for image in policy.images] - self.assertEqual(container_kinds.count("container"), 10) + self.assertEqual(container_kinds.count("container"), 11) self.assertEqual(container_kinds.count("init_container"), 3) def test_policy_loads_and_validates_init_container_kind(self) -> None: @@ -358,6 +358,33 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase): ) self.assertTrue(image.runtime_ref.endswith("@" + image.target_registry_digest)) + def test_argocd_redis_main_reuses_verified_staging_artifact(self) -> None: + policy = controller.load_policy(POLICY_PATH) + staging = next( + image + for image in controller.load_staging_policy(STAGING_POLICY_PATH).images + if image.asset_id == "runtime-image:redis-8.2.3-canary" + ) + image = next( + image + for image in policy.images + if image.asset_id == "runtime-image:argocd-redis" + ) + + 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.workload.kind, "deployment") + self.assertEqual(image.workload.namespace, "argocd") + self.assertEqual(image.workload.name, "argocd-redis") + self.assertEqual(image.workload.container, "redis") + self.assertEqual(image.workload.container_kind, "container") + self.assertEqual( + image.target_registry_digest, + "sha256:1e1de310669186e8a85a676ed5352f6078b09658ee578508f7608fe7beff78f0", + ) + 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: