diff --git a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py index 43c65f397..10e10a553 100644 --- a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py +++ b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py @@ -1,8 +1,8 @@ """P0-006 reboot auto-recovery SLO scorecard readback. -This loader promotes the committed reboot recovery scorecard into a stable API -contract. It does not reboot hosts, restart services, query runtime secrets, or -write StockPlatform data; it only reads the committed JSON scorecard. +This loader promotes the committed reboot recovery scorecard and the latest +runtime verifier artifact into a stable API contract. It does not reboot hosts, +restart services, query runtime secrets, or write StockPlatform data. """ from __future__ import annotations @@ -23,8 +23,8 @@ _API_SCHEMA_VERSION = "reboot_auto_recovery_slo_scorecard_readback_v1" _PUBLIC_MAINTENANCE_RUNTIME_FILE_ENV = ( "AWOOOI_PUBLIC_MAINTENANCE_FALLBACK_READBACK_FILE" ) -_PUBLIC_MAINTENANCE_RUNTIME_DIR_ENV = "AWOOOI_REBOOT_RECOVERY_LOG_DIR" _RUNTIME_SCORECARD_FILE_ENV = "AWOOOI_REBOOT_RECOVERY_SLO_SCORECARD_FILE" +_PUBLIC_MAINTENANCE_RUNTIME_DIR_ENV = "AWOOOI_REBOOT_RECOVERY_LOG_DIR" _RUNTIME_SCORECARD_DIR_ENV = "AWOOOI_REBOOT_RECOVERY_LOG_DIR" _DEFAULT_REBOOT_RECOVERY_LOG_DIR = Path("/home/wooo/reboot-recovery") _PUBLIC_MAINTENANCE_RUNTIME_PATTERN = ( @@ -45,7 +45,7 @@ def load_latest_reboot_auto_recovery_slo_scorecard( runtime_scorecard_path: Path | None = None, runtime_scorecard_dir: Path | None = None, ) -> dict[str, Any]: - """Load and validate the committed P0-006 reboot recovery scorecard.""" + """Load the committed P0-006 scorecard and overlay trusted runtime artifacts.""" directory = operations_dir or _DEFAULT_OPERATIONS_DIR path = directory / _SCORECARD_FILE with path.open(encoding="utf-8") as handle: @@ -106,9 +106,15 @@ def _load_latest_runtime_scorecard( return {}, None if not candidates: return {}, None - latest = max(candidates, key=lambda path: path.stat().st_mtime) - payload = _read_json_file(latest) - return _runtime_scorecard_if_supported(payload), latest + for latest in sorted( + candidates, + key=lambda path: path.stat().st_mtime, + reverse=True, + ): + payload = _runtime_scorecard_if_supported(_read_json_file(latest)) + if payload: + return payload, latest + return {}, None def _runtime_scorecard_if_supported(payload: dict[str, Any]) -> dict[str, Any]: @@ -446,6 +452,11 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: snapshot_generated_at = str(scorecard.get("generated_at") or "") runtime_readback_generated_at = _taipei_now_iso() latest_verify_metric = _dict(scorecard.get("latest_verify_only_metric")) + source_scorecard_ref = ( + f"docs/operations/{path.name}" + if path.name == _SCORECARD_FILE + else str(path) + ) active_blocker_count = len(active_blockers) observed_host_count = len(_strings(host_boot_detection.get("observed_hosts"))) missing_host_count = len(_strings(host_boot_detection.get("missing_hosts"))) @@ -673,7 +684,7 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: "readback": { "workplan_id": "P0-006", "workplan_title": "主機重啟自動偵測、自動觸發與 10 分鐘恢復 SLO", - "source_scorecard_ref": f"docs/operations/{path.name}", + "source_scorecard_ref": source_scorecard_ref, "generated_at": runtime_readback_generated_at, "runtime_readback_generated_at": runtime_readback_generated_at, "snapshot_generated_at": snapshot_generated_at, diff --git a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py index 522c4849f..696f6ceaa 100644 --- a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py +++ b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py @@ -71,6 +71,14 @@ PUBLIC_MAINTENANCE_RUNTIME_READY = { ], } +_REPO_ROOT = Path(__file__).resolve().parents[3] +_SOURCE_SCORECARD = ( + _REPO_ROOT + / "docs" + / "operations" + / "awoooi-reboot-auto-recovery-slo-scorecard.snapshot.json" +) + def test_reboot_auto_recovery_slo_scorecard_loader_exposes_stockplatform_gate(): payload = load_latest_reboot_auto_recovery_slo_scorecard() @@ -155,6 +163,69 @@ def test_reboot_auto_recovery_slo_scorecard_overlays_runtime_scorecard_artifact( assert payload["rollups"]["runtime_scorecard_readback_present"] is True +def test_reboot_auto_recovery_slo_scorecard_uses_latest_runtime_scorecard_from_dir( + tmp_path, +): + runtime_dir = tmp_path / "reboot-recovery" + artifact_dir = runtime_dir / "reboot-auto-recovery-slo-20260703-002055" + artifact_dir.mkdir(parents=True) + runtime_scorecard = json.loads(_SOURCE_SCORECARD.read_text(encoding="utf-8")) + runtime_scorecard["generated_at"] = "2026-07-03T00:20:55+08:00" + runtime_scorecard["active_blockers"] = [ + "all_required_hosts_not_in_10_minute_reboot_window", + "conversation_event_hot_path_index_migration_source_missing", + "host_boot_observation_older_than_target_window", + "host_unreachable_after_reboot", + "host_uptime_unknown", + "public_maintenance_fallback_runtime_readback_missing", + "reboot_event_required_host_unreachable", + "windows99_vmware_autostart_readback_missing", + ] + runtime_scorecard["windows99_management_channel"]["ssh_users"] = [ + "administrator", + "wooo", + ] + runtime_scorecard["windows99_management_channel"]["ssh_batch_candidates"] = [ + { + "user": "administrator", + "checked": True, + "ready": False, + "status": "permission_denied", + }, + { + "user": "wooo", + "checked": True, + "ready": False, + "status": "permission_denied", + }, + ] + (artifact_dir / "scorecard.json").write_text( + json.dumps(runtime_scorecard), + encoding="utf-8", + ) + + payload = load_latest_reboot_auto_recovery_slo_scorecard( + runtime_scorecard_dir=runtime_dir, + public_maintenance_runtime_dir=runtime_dir, + ) + + assert payload["runtime_scorecard_readback_present"] is True + assert payload["runtime_scorecard_generated_at"] == ( + "2026-07-03T00:20:55+08:00" + ) + assert payload["snapshot_generated_at"] == "2026-07-02T15:08:44+08:00" + assert payload["active_blocker_count"] == 8 + assert payload["readback"]["runtime_scorecard_source_ref"].endswith( + "reboot-auto-recovery-slo-20260703-002055/scorecard.json" + ) + assert ( + payload["windows99_management_channel"]["ssh_batch_candidates"][1][ + "user" + ] + == "wooo" + ) + + def test_reboot_auto_recovery_slo_scorecard_endpoint_returns_readback(monkeypatch): monkeypatch.setattr( agents,