diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 50345ef9..24326d22 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -639,6 +639,31 @@ class Settings(BaseSettings): le=900, description="Delay before the check-mode worker first tick after API startup.", ) + AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE: str = Field( + default="ssh_mcp", + description=( + "SSH transport profile used by Ansible check-mode. Production uses " + "the existing ssh-mcp key so repair-bot forced-command remains reserved " + "for whitelist repairs." + ), + ) + AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH: str = Field( + default="/run/secrets/ssh_mcp_key", + description="Private key path for Ansible check-mode SSH transport.", + ) + AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH: str = Field( + default="/etc/ssh-mcp/known_hosts", + description="known_hosts path for Ansible check-mode SSH transport.", + ) + AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS: int = Field( + default=24, + ge=1, + le=168, + description=( + "Only recent Ansible candidate audit rows are eligible for automatic " + "check-mode claims; older backlog remains visible but is not drained as noise." + ), + ) AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_COOLDOWN_SECONDS: int = Field( default=21_600, ge=300, diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index 859d8c85..d39eab23 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -32,6 +32,8 @@ _PLAYBOOK_PREFIX = Path("infra/ansible/playbooks") _STDOUT_LIMIT = 20_000 _STDERR_LIMIT = 12_000 FORCED_COMMAND_BLOCKER = "ansible_repair_ssh_forced_command_denies_ansible_bootstrap" +REPAIR_FORCED_COMMAND_KEY_PATH = Path("/etc/repair-ssh/id_ed25519") +REPAIR_FORCED_COMMAND_KNOWN_HOSTS_PATH = Path("/etc/repair-known-hosts/known_hosts") @dataclass(frozen=True) @@ -85,6 +87,18 @@ def _incident_id_from_payload(payload: dict[str, Any]) -> str: return str(payload.get("incident_id") or "").strip() +def _check_mode_ssh_key_path() -> Path: + return Path(settings.AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH) + + +def _check_mode_known_hosts_path() -> Path: + return Path(settings.AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH) + + +def _uses_repair_forced_command_transport(key_path: Path | None = None) -> bool: + return (key_path or _check_mode_ssh_key_path()) == REPAIR_FORCED_COMMAND_KEY_PATH + + def detect_ansible_transport_blockers(*values: Any) -> list[str]: combined = " ".join(str(value or "") for value in values) blockers: list[str] = [] @@ -105,10 +119,12 @@ def _playbook_roots(module_path: Path | None = None) -> list[Path]: def _runtime_blockers( *, playbook_root: Path | None = None, - repair_ssh_key_path: Path = Path("/etc/repair-ssh/id_ed25519"), - repair_known_hosts_path: Path = Path("/etc/repair-known-hosts/known_hosts"), + check_mode_ssh_key_path: Path | None = None, + check_mode_known_hosts_path: Path | None = None, ) -> list[str]: root = playbook_root or next((path for path in _playbook_roots() if path.exists()), None) + ssh_key_path = check_mode_ssh_key_path or _check_mode_ssh_key_path() + known_hosts_path = check_mode_known_hosts_path or _check_mode_known_hosts_path() blockers: list[str] = [] if shutil.which("ansible-playbook") is None: blockers.append("ansible_playbook_binary_missing") @@ -116,10 +132,10 @@ def _runtime_blockers( blockers.append("ansible_playbook_catalog_missing") elif not (root / "inventory" / "hosts.yml").exists(): blockers.append("ansible_inventory_missing") - if not repair_ssh_key_path.is_file() or not os.access(repair_ssh_key_path, os.R_OK): - blockers.append("ansible_repair_ssh_key_missing") - if not repair_known_hosts_path.is_file() or not os.access(repair_known_hosts_path, os.R_OK): - blockers.append("ansible_repair_known_hosts_missing") + if not ssh_key_path.is_file() or not os.access(ssh_key_path, os.R_OK): + blockers.append("ansible_check_mode_ssh_key_missing") + if not known_hosts_path.is_file() or not os.access(known_hosts_path, os.R_OK): + blockers.append("ansible_check_mode_known_hosts_missing") return blockers @@ -169,6 +185,7 @@ def build_ansible_check_mode_claim_input( "executor": "ansible", "execution_backend": "ansible", "execution_mode": "check_mode", + "transport_profile": settings.AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE, "check_mode": True, "diff": True, "apply_enabled": False, @@ -200,8 +217,8 @@ def build_ansible_check_mode_command( playbook_path: str, inventory_hosts: tuple[str, ...], playbook_root: Path | None = None, - repair_ssh_key_path: Path = Path("/etc/repair-ssh/id_ed25519"), - repair_known_hosts_path: Path = Path("/etc/repair-known-hosts/known_hosts"), + check_mode_ssh_key_path: Path | None = None, + check_mode_known_hosts_path: Path | None = None, ) -> AnsibleCommandSpec: root = playbook_root or next((path for path in _playbook_roots() if path.exists()), None) if root is None: @@ -213,12 +230,14 @@ def build_ansible_check_mode_command( raise ValueError("unsafe_inventory_hosts") playbook_abs = _resolve_playbook_path(root, playbook_path) + ssh_key_path = check_mode_ssh_key_path or _check_mode_ssh_key_path() + known_hosts_path = check_mode_known_hosts_path or _check_mode_known_hosts_path() ssh_common_args = ( - f"-o UserKnownHostsFile={repair_known_hosts_path} " + f"-o UserKnownHostsFile={known_hosts_path} " "-o IdentitiesOnly=yes -o BatchMode=yes" ) extra_vars = { - "ansible_ssh_private_key_file": str(repair_ssh_key_path), + "ansible_ssh_private_key_file": str(ssh_key_path), "ansible_ssh_common_args": ssh_common_args, } command = [ @@ -283,6 +302,9 @@ def _build_result_payload(result: AnsibleRunResult) -> tuple[str, dict[str, Any] output = { "executor": "ansible", "execution_mode": "check_mode", + "transport_profile": settings.AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE, + "ssh_key_path": settings.AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH, + "known_hosts_path": settings.AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH, "check_mode": True, "apply_enabled": False, "approval_required_before_apply": True, @@ -296,6 +318,9 @@ def _build_result_payload(result: AnsibleRunResult) -> tuple[str, dict[str, Any] "check_mode_executed": True, "apply_executed": False, "safe_to_apply_without_approval": False, + "transport_profile": settings.AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE, + "ssh_key_path": settings.AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH, + "known_hosts_path": settings.AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH, "returncode": result.returncode, "timed_out": result.timed_out, "stdout_tail": stdout_tail, @@ -309,10 +334,12 @@ async def claim_pending_check_modes( *, project_id: str = "awoooi", limit: int = 1, + candidate_max_age_hours: int | None = None, ) -> list[AnsibleCheckModeClaim]: """Claim pending Ansible candidates by inserting pending check-mode rows.""" claims: list[AnsibleCheckModeClaim] = [] + max_age_hours = candidate_max_age_hours or settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS async with get_db_context(project_id) as db: result = await db.execute( text(""" @@ -323,6 +350,7 @@ async def claim_pending_check_modes( WHERE candidate.operation_type = 'ansible_candidate_matched' AND candidate.status = 'dry_run' AND candidate.input ->> 'executor' = 'ansible' + AND candidate.created_at >= NOW() - (:candidate_max_age_hours * INTERVAL '1 hour') AND COALESCE((candidate.dry_run_result ->> 'check_mode_executed')::boolean, false) = false AND NOT EXISTS ( SELECT 1 @@ -337,7 +365,10 @@ async def claim_pending_check_modes( LIMIT :limit FOR UPDATE SKIP LOCKED """), - {"limit": max(1, limit)}, + { + "limit": max(1, limit), + "candidate_max_age_hours": max(1, max_age_hours), + }, ) rows = result.mappings().all() for row in rows: @@ -406,10 +437,16 @@ async def recent_ansible_transport_blockers( *, project_id: str = "awoooi", cooldown_seconds: int | None = None, + include_repair_forced_command_blocker: bool | None = None, ) -> list[str]: """Return transport blockers observed from recent failed check-mode rows.""" cooldown = cooldown_seconds or settings.AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_COOLDOWN_SECONDS + include_forced_blocker = ( + _uses_repair_forced_command_transport() + if include_repair_forced_command_blocker is None + else include_repair_forced_command_blocker + ) async with get_db_context(project_id) as db: result = await db.execute( text(""" @@ -429,13 +466,16 @@ async def recent_ansible_transport_blockers( ) blockers: set[str] = set() for row in result.mappings().all(): + detected = detect_ansible_transport_blockers( + row.get("output_text"), + row.get("dry_run_text"), + row.get("error_text"), + row.get("stderr_text"), + ) blockers.update( - detect_ansible_transport_blockers( - row.get("output_text"), - row.get("dry_run_text"), - row.get("error_text"), - row.get("stderr_text"), - ) + blocker + for blocker in detected + if blocker != FORCED_COMMAND_BLOCKER or include_forced_blocker ) return sorted(blockers) @@ -452,6 +492,7 @@ async def _insert_skipped_candidate( "executor": "ansible", "execution_backend": "ansible", "execution_mode": "check_mode", + "transport_profile": settings.AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE, "check_mode": True, "apply_enabled": False, "source_candidate_op_id": source_candidate_op_id, @@ -572,7 +613,11 @@ async def run_pending_check_modes_once( logger.warning("ansible_check_mode_transport_blocked", blockers=transport_blockers) return {"claimed": 0, "completed": 0, "failed": 0, "blockers": transport_blockers} - claims = await claim_pending_check_modes(project_id=project_id, limit=limit) + claims = await claim_pending_check_modes( + project_id=project_id, + limit=limit, + candidate_max_age_hours=settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS, + ) completed = 0 failed = 0 for claim in claims: diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 8cacbac9..4151d654 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -20,6 +20,7 @@ from uuid import UUID import structlog from sqlalchemy import text +from src.core.config import settings from src.db.base import get_db_context from src.services.awooop_ansible_check_mode_service import detect_ansible_transport_blockers from src.services.awooop_ansible_audit_service import build_ansible_truth @@ -758,8 +759,14 @@ def _path_readable(path: Path) -> bool: return path.exists() and path.is_file() and os.access(path, os.R_OK) +def _check_mode_uses_repair_forced_command_transport() -> bool: + return Path(settings.AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH) == Path("/etc/repair-ssh/id_ed25519") + + def _ansible_runtime_readiness( *, + check_mode_ssh_key_path: Path | None = None, + check_mode_known_hosts_path: Path | None = None, 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]: @@ -772,6 +779,12 @@ def _ansible_runtime_readiness( ) inventory_path = playbook_root / "inventory" / "hosts.yml" if playbook_root is not None else None binary_path = shutil.which("ansible-playbook") + selected_ssh_key_path = check_mode_ssh_key_path or Path(settings.AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH) + selected_known_hosts_path = check_mode_known_hosts_path or Path( + settings.AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH + ) + check_mode_ssh_key_readable = _path_readable(selected_ssh_key_path) + check_mode_known_hosts_readable = _path_readable(selected_known_hosts_path) repair_ssh_key_readable = _path_readable(repair_ssh_key_path) repair_known_hosts_readable = _path_readable(repair_known_hosts_path) blockers: list[str] = [] @@ -783,10 +796,10 @@ def _ansible_runtime_readiness( 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") + if not check_mode_ssh_key_readable: + blockers.append("ansible_check_mode_ssh_key_missing") + if not check_mode_known_hosts_readable: + blockers.append("ansible_check_mode_known_hosts_missing") return { "ansible_playbook_binary_present": bool(binary_path), @@ -795,6 +808,14 @@ def _ansible_runtime_readiness( "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), + "check_mode_transport_profile": settings.AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE, + "check_mode_ssh_key_present": selected_ssh_key_path.exists(), + "check_mode_ssh_key_readable": check_mode_ssh_key_readable, + "check_mode_ssh_key_path": str(selected_ssh_key_path), + "check_mode_known_hosts_present": selected_known_hosts_path.exists(), + "check_mode_known_hosts_readable": check_mode_known_hosts_readable, + "check_mode_known_hosts_path": str(selected_known_hosts_path), + "check_mode_candidate_max_age_hours": settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS, "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), @@ -902,10 +923,13 @@ def summarize_automation_quality_records( observed_ansible_blockers = _ansible_observed_runtime_blockers(records) if observed_ansible_blockers: ansible_runtime["observed_transport_blockers"] = observed_ansible_blockers - ansible_runtime["blockers"] = sorted( - set(ansible_runtime.get("blockers") or []) | set(observed_ansible_blockers) - ) - ansible_runtime["can_run_check_mode"] = False + if _check_mode_uses_repair_forced_command_transport(): + ansible_runtime["blockers"] = sorted( + set(ansible_runtime.get("blockers") or []) | set(observed_ansible_blockers) + ) + ansible_runtime["can_run_check_mode"] = False + else: + ansible_runtime["historical_transport_blockers"] = observed_ansible_blockers return { "schema_version": "automation_quality_summary_v1", diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 95709b13..36678007 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace +from src.core.config import settings from src.services.awooop_ansible_audit_service import ( build_ansible_decision_audit_payload, build_ansible_truth, @@ -96,6 +97,7 @@ def test_ansible_transport_cooldown_uses_asyncpg_safe_interval_parameter() -> No assert ":cooldown_seconds * INTERVAL '1 second'" in source assert "CAST(:cooldown AS interval)" not in source + assert "include_repair_forced_command_blocker" in source def test_fetch_truth_chain_returns_inbound_redacted_envelope_fields() -> None: @@ -742,28 +744,31 @@ 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 "check_mode_transport_profile" in readiness + assert "check_mode_ssh_key_readable" in readiness + assert "check_mode_known_hosts_readable" in readiness 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: +def test_ansible_runtime_readiness_requires_check_mode_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, + check_mode_ssh_key_path=missing_key, + check_mode_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"] + assert readiness["check_mode_ssh_key_present"] is False + assert readiness["check_mode_ssh_key_readable"] is False + assert readiness["check_mode_known_hosts_present"] is False + assert readiness["check_mode_known_hosts_readable"] is False + assert "ansible_check_mode_ssh_key_missing" in readiness["blockers"] + assert "ansible_check_mode_known_hosts_missing" in readiness["blockers"] -def test_ansible_runtime_readiness_accepts_readable_repair_ssh_material(tmp_path: Path) -> None: +def test_ansible_runtime_readiness_accepts_readable_check_mode_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") @@ -772,16 +777,16 @@ def test_ansible_runtime_readiness_accepts_readable_repair_ssh_material(tmp_path known_hosts_path.chmod(0o400) readiness = _ansible_runtime_readiness( - repair_ssh_key_path=key_path, - repair_known_hosts_path=known_hosts_path, + check_mode_ssh_key_path=key_path, + check_mode_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"] + assert readiness["check_mode_ssh_key_present"] is True + assert readiness["check_mode_ssh_key_readable"] is True + assert readiness["check_mode_known_hosts_present"] is True + assert readiness["check_mode_known_hosts_readable"] is True + assert "ansible_check_mode_ssh_key_missing" not in readiness["blockers"] + assert "ansible_check_mode_known_hosts_missing" not in readiness["blockers"] def test_ansible_playbook_roots_supports_flat_container_module_path() -> None: @@ -947,6 +952,7 @@ def test_ansible_check_mode_claim_input_keeps_apply_locked() -> None: assert claim["check_mode"] is True assert claim["diff"] is True assert claim["apply_enabled"] is False + assert claim["transport_profile"] assert claim["approval_required_before_apply"] is True assert claim["playbook_path"] == "infra/ansible/playbooks/188-ai-web.yml" @@ -976,7 +982,7 @@ def test_ansible_check_mode_claim_rejects_non_check_mode_catalog() -> None: raise AssertionError("non-check-mode catalog should be rejected") -def test_ansible_check_mode_command_uses_check_diff_and_repair_ssh(tmp_path: Path) -> None: +def test_ansible_check_mode_command_uses_check_diff_and_selected_ssh_transport(tmp_path: Path) -> None: playbook_root = tmp_path / "infra" / "ansible" playbook_dir = playbook_root / "playbooks" inventory_dir = playbook_root / "inventory" @@ -993,8 +999,8 @@ def test_ansible_check_mode_command_uses_check_diff_and_repair_ssh(tmp_path: Pat playbook_path="infra/ansible/playbooks/188-ai-web.yml", inventory_hosts=("host_188",), playbook_root=playbook_root, - repair_ssh_key_path=repair_key, - repair_known_hosts_path=known_hosts, + check_mode_ssh_key_path=repair_key, + check_mode_known_hosts_path=known_hosts, ) assert "--check" in spec.command @@ -1007,6 +1013,13 @@ def test_ansible_check_mode_command_uses_check_diff_and_repair_ssh(tmp_path: Pat assert "apply" not in " ".join(spec.command) +def test_ansible_claim_query_limits_recent_candidate_backlog() -> None: + source = inspect.getsource(claim_pending_check_modes) + + assert "candidate.created_at >= NOW() - (:candidate_max_age_hours * INTERVAL '1 hour')" in source + assert "AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS" in source + + def test_ansible_transport_blocker_detects_repair_forced_command_denial() -> None: blockers = detect_ansible_transport_blockers( "fatal: host unreachable REPAIR_DENIED:invalid_command", @@ -1047,7 +1060,12 @@ def test_execution_backend_summary_subtracts_completed_check_mode_parent() -> No assert summary["ansible_pending_check_mode_total"] == 0 -def test_quality_summary_marks_forced_command_denial_as_runtime_blocker() -> None: +def test_quality_summary_marks_forced_command_denial_as_runtime_blocker(monkeypatch) -> None: + monkeypatch.setattr( + settings, + "AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH", + "/etc/repair-ssh/id_ed25519", + ) summary = summarize_automation_quality_records( project_id="awoooi", window_hours=24, @@ -1084,3 +1102,50 @@ def test_quality_summary_marks_forced_command_denial_as_runtime_blocker() -> Non "ansible_repair_ssh_forced_command_denies_ansible_bootstrap" in summary["ansible_runtime"]["blockers"] ) + + +def test_quality_summary_keeps_repair_forced_blocker_historical_for_ssh_mcp(monkeypatch) -> None: + monkeypatch.setattr( + settings, + "AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH", + "/run/secrets/ssh_mcp_key", + ) + summary = summarize_automation_quality_records( + project_id="awoooi", + window_hours=24, + limit=20, + records=[ + { + "incident": {"incident_id": "INC-1", "alertname": "DockerContainerUnhealthy"}, + "truth_status": {}, + "automation_quality": {"applicable": True, "score": 50, "verdict": "observed"}, + "execution": { + "automation_operation_log": [], + "auto_repair_executions": [], + "ansible": { + "considered": True, + "candidate_catalog": {"candidates": [{"catalog_id": "ansible:110-devops"}]}, + "records": [ + { + "op_id": "check-1", + "operation_type": "ansible_check_mode_executed", + "status": "failed", + "dry_run_result": { + "stdout_tail": "REPAIR_DENIED:invalid_command", + }, + } + ], + }, + }, + } + ], + ) + + assert ( + "ansible_repair_ssh_forced_command_denies_ansible_bootstrap" + in summary["ansible_runtime"]["historical_transport_blockers"] + ) + assert ( + "ansible_repair_ssh_forced_command_denies_ansible_bootstrap" + not in summary["ansible_runtime"]["blockers"] + ) diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index 38d73273..c1d0b7f9 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -111,6 +111,14 @@ spec: value: "180" - name: AWOOOP_ANSIBLE_CHECK_MODE_STARTUP_SLEEP_SECONDS value: "120" + - name: AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_PROFILE + value: "ssh_mcp" + - name: AWOOOP_ANSIBLE_CHECK_MODE_SSH_KEY_PATH + value: "/run/secrets/ssh_mcp_key" + - name: AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH + value: "/etc/ssh-mcp/known_hosts" + - name: AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS + value: "24" - name: AWOOOP_ANSIBLE_CHECK_MODE_TRANSPORT_COOLDOWN_SECONDS value: "21600" # 2026-04-05 Claude Code: Sprint 3 — 掛載 SSH key 供 HostRepairAgent 使用