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