diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index bc0e92e7..141ab12e 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -71,8 +71,11 @@ 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. +# playbook catalog in the API image. +# 2026-05-31 Codex: ansible-core is now installed through pyproject.toml so +# this catalog can graduate from visibility-only to check-mode runtime-ready +# once repair SSH material is mounted and readable. This still does not enable +# automatic apply; approval/execution code remains the gate. 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/ diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 533fbf26..5ded8959 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -46,6 +46,10 @@ dependencies = [ # 2026-04-16 ogt + Claude Sonnet 4.6: SSH MCP sensor 修復 — asyncssh 缺失導致 sensors_succeeded=0 # 根因: ssh_provider.py 中 import asyncssh 在 try/except 外,所有 15 個 SSH tool 直接 ImportError "asyncssh>=2.14.0", + # 2026-05-31 Codex: AwoooP truth-chain Ansible runtime gate 需要 + # production API image 內真的存在 ansible-playbook,否則只能顯示 + # candidate audit,無法進入 check-mode executor readiness。 + "ansible-core>=2.16.0,<2.18.0", ] # [tool.uv.sources] diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index a4bc70a0..db76ca46 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -58,3 +58,8 @@ pytest>=7.4.0 pytest-asyncio>=0.23.0 ruff>=0.1.0 sentry-sdk[fastapi]>=2.0.0 + +# AwoooP Ansible runtime readiness +# 2026-05-31 Codex: production API image must include ansible-playbook before +# truth-chain can honestly mark check-mode executor readiness as available. +ansible-core>=2.16.0,<2.18.0 diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index f5c3b2f5..5299b984 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import json +import os import shutil from datetime import UTC, date, datetime, timedelta from decimal import Decimal @@ -716,7 +717,15 @@ def _ansible_playbook_roots(module_path: Path | None = None) -> list[Path]: ] -def _ansible_runtime_readiness() -> dict[str, Any]: +def _path_readable(path: Path) -> bool: + return path.exists() and path.is_file() and os.access(path, os.R_OK) + + +def _ansible_runtime_readiness( + *, + repair_ssh_key_path: Path = Path("/etc/repair-ssh/id_ed25519"), + repair_known_hosts_path: Path = Path("/etc/repair-known-hosts/known_hosts"), +) -> dict[str, Any]: playbook_roots = _ansible_playbook_roots() playbook_root = next((path for path in playbook_roots if path.exists()), None) playbook_paths = ( @@ -726,6 +735,8 @@ def _ansible_runtime_readiness() -> dict[str, Any]: ) inventory_path = playbook_root / "inventory" / "hosts.yml" if playbook_root is not None else None binary_path = shutil.which("ansible-playbook") + repair_ssh_key_readable = _path_readable(repair_ssh_key_path) + repair_known_hosts_readable = _path_readable(repair_known_hosts_path) blockers: list[str] = [] if not binary_path: blockers.append("ansible_playbook_binary_missing") @@ -735,6 +746,10 @@ def _ansible_runtime_readiness() -> dict[str, Any]: blockers.append("ansible_inventory_missing") if not playbook_paths: blockers.append("ansible_playbooks_missing") + if not repair_ssh_key_readable: + blockers.append("ansible_repair_ssh_key_missing") + if not repair_known_hosts_readable: + blockers.append("ansible_repair_known_hosts_missing") return { "ansible_playbook_binary_present": bool(binary_path), @@ -743,6 +758,12 @@ def _ansible_runtime_readiness() -> dict[str, Any]: "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), + "repair_ssh_key_present": repair_ssh_key_path.exists(), + "repair_ssh_key_readable": repair_ssh_key_readable, + "repair_ssh_key_path": str(repair_ssh_key_path), + "repair_known_hosts_present": repair_known_hosts_path.exists(), + "repair_known_hosts_readable": repair_known_hosts_readable, + "repair_known_hosts_path": str(repair_known_hosts_path), "can_run_check_mode": not blockers, "blockers": blockers, } diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index ccf8cc6b..0fe1a590 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -706,6 +706,46 @@ def test_ansible_runtime_readiness_reports_check_mode_blockers() -> None: assert readiness["playbook_count"] >= 1 assert isinstance(readiness["can_run_check_mode"], bool) assert isinstance(readiness["blockers"], list) + assert "repair_ssh_key_readable" in readiness + assert "repair_known_hosts_readable" in readiness + + +def test_ansible_runtime_readiness_requires_repair_ssh_material(tmp_path: Path) -> None: + missing_key = tmp_path / "missing-id-ed25519" + missing_known_hosts = tmp_path / "missing-known-hosts" + + readiness = _ansible_runtime_readiness( + repair_ssh_key_path=missing_key, + repair_known_hosts_path=missing_known_hosts, + ) + + assert readiness["repair_ssh_key_present"] is False + assert readiness["repair_ssh_key_readable"] is False + assert readiness["repair_known_hosts_present"] is False + assert readiness["repair_known_hosts_readable"] is False + assert "ansible_repair_ssh_key_missing" in readiness["blockers"] + assert "ansible_repair_known_hosts_missing" in readiness["blockers"] + + +def test_ansible_runtime_readiness_accepts_readable_repair_ssh_material(tmp_path: Path) -> None: + key_path = tmp_path / "id_ed25519" + known_hosts_path = tmp_path / "known_hosts" + key_path.write_text("test-key", encoding="utf-8") + known_hosts_path.write_text("192.168.0.110 ssh-ed25519 AAAATEST", encoding="utf-8") + key_path.chmod(0o400) + known_hosts_path.chmod(0o400) + + readiness = _ansible_runtime_readiness( + repair_ssh_key_path=key_path, + repair_known_hosts_path=known_hosts_path, + ) + + assert readiness["repair_ssh_key_present"] is True + assert readiness["repair_ssh_key_readable"] is True + assert readiness["repair_known_hosts_present"] is True + assert readiness["repair_known_hosts_readable"] is True + assert "ansible_repair_ssh_key_missing" not in readiness["blockers"] + assert "ansible_repair_known_hosts_missing" not in readiness["blockers"] def test_ansible_playbook_roots_supports_flat_container_module_path() -> None: