fix(awooop): use ssh mcp transport for ansible check-mode
All checks were successful
CD Pipeline / tests (push) Successful in 1m19s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 3m45s
CD Pipeline / post-deploy-checks (push) Successful in 1m53s

This commit is contained in:
Your Name
2026-05-31 14:15:11 +08:00
parent db48ad8678
commit 1a72a2f664
5 changed files with 215 additions and 48 deletions

View File

@@ -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,

View File

@@ -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:

View File

@@ -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",

View File

@@ -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"]
)

View File

@@ -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 使用