From 1322216f73bfd9bf679de75c06e4354c44de2f91 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 24 May 2026 15:01:51 +0800 Subject: [PATCH] feat(awooop): expose ansible runtime readiness --- apps/api/Dockerfile | 4 ++ .../services/awooop_truth_chain_service.py | 38 +++++++++++++++++++ .../tests/test_awooop_truth_chain_service.py | 15 ++++++++ apps/web/messages/en.json | 4 +- apps/web/messages/zh-TW.json | 4 +- .../dashboard/automation-evidence-card.tsx | 16 ++++++++ 6 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index ffe226648..bc0e92e71 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -70,6 +70,10 @@ COPY --chown=appuser:appuser apps/api/models.json ./models.json COPY --chown=appuser:appuser apps/api/alert_rules.yaml ./alert_rules.yaml # 2026-04-10 Claude Sonnet 4.6: drift_detector 需要 k8s/ YAML 做 Git state 比對 COPY --chown=appuser:appuser k8s/ ./k8s/ +# 2026-05-24 Codex: truth-chain / Ansible readiness needs the repo-known +# playbook catalog in the API image. This does not install ansible-core or +# enable apply; it only lets operators see whether check-mode can be wired. +COPY --chown=appuser:appuser infra/ansible/ ./infra/ansible/ # 2026-04-10 Claude Sonnet 4.6: RAG 知識庫索引來源 (ADR-067 Phase 33) COPY --chown=appuser:appuser docs/ ./docs/ COPY --chown=appuser:appuser .agents/skills/ ./.agents/skills/ diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index ebcedef9f..ec54c6a5c 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -9,8 +9,10 @@ from __future__ import annotations import asyncio import json +import shutil from datetime import UTC, date, datetime, timedelta from decimal import Decimal +from pathlib import Path from typing import Any from uuid import UUID @@ -705,6 +707,41 @@ def _execution_backend_summary(records: list[dict[str, Any]]) -> dict[str, Any]: return summary +def _ansible_runtime_readiness() -> dict[str, Any]: + playbook_roots = [ + Path("/app/infra/ansible"), + Path.cwd() / "infra" / "ansible", + ] + playbook_root = next((path for path in playbook_roots if path.exists()), None) + playbook_paths = ( + sorted((playbook_root / "playbooks").glob("*.yml")) + if playbook_root is not None and (playbook_root / "playbooks").exists() + else [] + ) + inventory_path = playbook_root / "inventory" / "hosts.yml" if playbook_root is not None else None + binary_path = shutil.which("ansible-playbook") + blockers: list[str] = [] + if not binary_path: + blockers.append("ansible_playbook_binary_missing") + if playbook_root is None: + blockers.append("ansible_playbook_catalog_missing") + if inventory_path is None or not inventory_path.exists(): + blockers.append("ansible_inventory_missing") + if not playbook_paths: + blockers.append("ansible_playbooks_missing") + + return { + "ansible_playbook_binary_present": bool(binary_path), + "ansible_playbook_binary_path": binary_path, + "playbook_root_present": playbook_root is not None, + "playbook_root": str(playbook_root) if playbook_root is not None else None, + "inventory_present": bool(inventory_path and inventory_path.exists()), + "playbook_count": len(playbook_paths), + "can_run_check_mode": not blockers, + "blockers": blockers, + } + + def summarize_automation_quality_records( *, project_id: str, @@ -810,6 +847,7 @@ def summarize_automation_quality_records( "by_verdict": by_verdict, "gate_failures": failing_gates, "execution_backend_summary": _execution_backend_summary(records), + "ansible_runtime": _ansible_runtime_readiness(), "examples": examples[:25], "production_claim": { "can_claim_full_auto_repair": evaluated_total > 0 and verified_total == evaluated_total, diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 4aaab2bb2..19e366506 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -9,6 +9,7 @@ from src.services.awooop_ansible_audit_service import ( build_ansible_truth, ) from src.services.awooop_truth_chain_service import ( + _ansible_runtime_readiness, _automation_quality_score_bucket, _clean_row, _incident_fingerprints, @@ -687,10 +688,24 @@ def test_automation_quality_summary_denies_full_claim_when_unverified() -> None: "ansible_rollback_total": 0, "ansible_pending_check_mode_total": 1, } + assert summary["ansible_runtime"]["playbook_root_present"] is True + assert summary["ansible_runtime"]["inventory_present"] is True + assert summary["ansible_runtime"]["playbook_count"] >= 1 + assert "ansible_playbook_binary_present" in summary["ansible_runtime"] assert summary["examples"][1]["incident_id"] == "INC-GAP" assert summary["examples"][1]["score_bucket"] == "yellow" +def test_ansible_runtime_readiness_reports_check_mode_blockers() -> None: + readiness = _ansible_runtime_readiness() + + assert readiness["playbook_root_present"] is True + assert readiness["inventory_present"] is True + assert readiness["playbook_count"] >= 1 + assert isinstance(readiness["can_run_check_mode"], bool) + assert isinstance(readiness["blockers"], list) + + def test_reconciliation_marks_consistent_resolved_execution() -> None: reconciliation = build_incident_reconciliation( incident={"incident_id": "INC-2", "status": "RESOLVED"}, diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9f48f2ffc..81e7a6172 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -252,7 +252,9 @@ "autoRepair": "Auto repair", "qualityDetail": "Average {score}, red {red}", "qualityPending": "Quality summary is still calculating; other evidence is already shown", - "executionBackendDetail": "Execution evidence: operations {operations} (effective {effective} / audit {auditOnly}), auto-repair {autoRepair}; Ansible audit {ansibleRecords}, candidates {ansibleCandidates}, check-mode {checkMode}, apply {apply}, pending wiring {pending}", + "executionBackendDetail": "Execution evidence: operations {operations} (effective {effective} / audit {auditOnly}), auto-repair {autoRepair}; Ansible audit {ansibleRecords}, candidates {ansibleCandidates}, check-mode {checkMode}, apply {apply}, pending wiring {pending}; runtime {runtime}", + "ansibleRuntimeReady": "check-mode ready", + "ansibleRuntimeBlocked": "not ready: {blockers}", "humanGap": "Human gap", "humanGapDetail": "{gate} missing {count}", "humanGapClear": "Quality summary has no top gap", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index ed1877009..d3885cbc0 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -253,7 +253,9 @@ "autoRepair": "自動修復", "qualityDetail": "平均 {score},紅燈 {red}", "qualityPending": "品質摘要計算中,其他證據已先顯示", - "executionBackendDetail": "執行證據:操作 {operations}(有效 {effective} / 稽核 {auditOnly}),自動修復 {autoRepair};Ansible 稽核 {ansibleRecords},候選 {ansibleCandidates},check-mode {checkMode},apply {apply},待接線 {pending}", + "executionBackendDetail": "執行證據:操作 {operations}(有效 {effective} / 稽核 {auditOnly}),自動修復 {autoRepair};Ansible 稽核 {ansibleRecords},候選 {ansibleCandidates},check-mode {checkMode},apply {apply},待接線 {pending};runtime {runtime}", + "ansibleRuntimeReady": "可跑 check-mode", + "ansibleRuntimeBlocked": "未就緒:{blockers}", "humanGap": "人工缺口", "humanGapDetail": "{gate} 缺 {count} 筆", "humanGapClear": "品質摘要未列出主要缺口", diff --git a/apps/web/src/components/dashboard/automation-evidence-card.tsx b/apps/web/src/components/dashboard/automation-evidence-card.tsx index 217949002..68bd4ae60 100644 --- a/apps/web/src/components/dashboard/automation-evidence-card.tsx +++ b/apps/web/src/components/dashboard/automation-evidence-card.tsx @@ -51,6 +51,14 @@ interface AutomationQualitySummary { ansible_apply_total?: number ansible_pending_check_mode_total?: number } + ansible_runtime?: { + ansible_playbook_binary_present?: boolean + playbook_root_present?: boolean + inventory_present?: boolean + playbook_count?: number + can_run_check_mode?: boolean + blockers?: string[] + } } interface DossierCoverageResponse { @@ -338,6 +346,7 @@ export function AutomationEvidenceCard() { const topGate = quality?.gate_failures?.[0] const executionBackend = quality?.execution_backend_summary ?? null + const ansibleRuntime = quality?.ansible_runtime ?? null const qualityLoaded = Boolean(quality) const claimReady = Boolean(quality?.production_claim?.can_claim_full_auto_repair) const route = snapshot?.route ?? null @@ -374,6 +383,7 @@ export function AutomationEvidenceCard() { routeDetail, routeTone: routeTone(route), executionBackend, + ansibleRuntime, } }, [snapshot, t]) @@ -525,6 +535,12 @@ export function AutomationEvidenceCard() { checkMode: derived.executionBackend.ansible_check_mode_total ?? 0, apply: derived.executionBackend.ansible_apply_total ?? 0, pending: derived.executionBackend.ansible_pending_check_mode_total ?? 0, + runtime: derived.ansibleRuntime?.can_run_check_mode + ? t('ansibleRuntimeReady') + : t('ansibleRuntimeBlocked', { + blockers: (derived.ansibleRuntime?.blockers ?? []).slice(0, 2).join(', ') || '--', + }), + }), })}