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