fix(awooop): use ssh mcp transport for ansible check-mode
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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 使用
|
||||
|
||||
Reference in New Issue
Block a user