Files
awoooi/apps/api/src/services/awooop_ansible_check_mode_service.py
Your Name 4e4c56cae5
Some checks failed
CD Pipeline / tests (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled
fix(awooop): send controlled apply telegram receipts
2026-06-27 20:49:32 +08:00

1440 lines
55 KiB
Python

"""AwoooP Ansible controlled executor.
This service claims pending ``ansible_candidate_matched`` AOL rows, runs
``ansible-playbook --check --diff``, and for allowlisted low/medium/high risk
catalog rows performs controlled apply after the dry-run passes.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
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_audit_service import get_ansible_catalog_item
logger = structlog.get_logger(__name__)
_SAFE_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_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)
class AnsibleCheckModeClaim:
op_id: str
source_candidate_op_id: str
incident_id: str
catalog_id: str
playbook_path: str
apply_playbook_path: str
inventory_hosts: tuple[str, ...]
risk_level: str
input_payload: dict[str, Any]
@dataclass(frozen=True)
class AnsibleCommandSpec:
command: list[str]
cwd: Path
env: dict[str, str]
playbook_abs_path: Path
inventory_abs_path: Path
@dataclass(frozen=True)
class AnsibleRunResult:
returncode: int
stdout: str
stderr: str
duration_ms: int
timed_out: bool = False
def _tail(text_value: str, limit: int) -> str:
if len(text_value) <= limit:
return text_value
return text_value[-limit:]
def _json_loads(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _incident_id_from_payload(payload: dict[str, Any]) -> str:
return str(payload.get("incident_id") or "").strip()
def _automation_operation_log_incident_id(value: str) -> int | None:
# ADR-090 keeps external INC-* ids in input JSON; the DB column is BIGINT.
normalized = str(value or "").strip()
return int(normalized) if normalized.isdigit() else None
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] = []
if "REPAIR_DENIED:invalid_command" in combined:
blockers.append(FORCED_COMMAND_BLOCKER)
return blockers
def _playbook_roots(module_path: Path | None = None) -> list[Path]:
resolved_module_path = (module_path or Path(__file__)).resolve()
return [
Path("/app/infra/ansible"),
Path.cwd() / "infra" / "ansible",
*(parent / "infra" / "ansible" for parent in resolved_module_path.parents),
]
def _runtime_blockers(
*,
playbook_root: Path | None = None,
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")
if root is None:
blockers.append("ansible_playbook_catalog_missing")
elif not (root / "inventory" / "hosts.yml").exists():
blockers.append("ansible_inventory_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
def _safe_candidate(input_payload: dict[str, Any]) -> dict[str, Any]:
candidates = input_payload.get("executor_candidates")
if not isinstance(candidates, list) or not candidates:
raise ValueError("missing_executor_candidates")
for candidate in candidates:
if not isinstance(candidate, dict):
continue
catalog_id = str(candidate.get("catalog_id") or "")
catalog_item = get_ansible_catalog_item(catalog_id)
if not catalog_item:
continue
if catalog_item.get("supports_check_mode") is not True:
continue
catalog_playbook_path = str(catalog_item.get("playbook_path") or "")
candidate_playbook_path = str(candidate.get("playbook_path") or catalog_playbook_path)
check_mode_playbook_path = str(
candidate.get("check_mode_playbook_path")
or catalog_item.get("check_mode_playbook_path")
or catalog_playbook_path
)
if candidate_playbook_path not in {catalog_playbook_path, check_mode_playbook_path}:
continue
inventory_hosts = candidate.get("inventory_hosts") or catalog_item.get("inventory_hosts") or []
if (
isinstance(inventory_hosts, list)
and inventory_hosts
and all(isinstance(host, str) and _SAFE_HOST_RE.fullmatch(host) for host in inventory_hosts)
):
return {
"catalog_id": catalog_id,
"playbook_path": check_mode_playbook_path,
"catalog_playbook_path": catalog_playbook_path,
"source_candidate_playbook_path": candidate_playbook_path,
"check_mode_playbook_path": check_mode_playbook_path,
"inventory_hosts": tuple(inventory_hosts),
"risk_level": str(candidate.get("risk_level") or catalog_item.get("risk_level") or ""),
"auto_apply_enabled": bool(catalog_item.get("auto_apply_enabled") is True),
"approval_required": bool(catalog_item.get("approval_required") is True),
"break_glass_required": bool(catalog_item.get("break_glass_required") is True),
}
raise ValueError("no_safe_check_mode_candidate")
def _allowed_controlled_apply_risks() -> set[str]:
raw = str(settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_ALLOWED_RISK_LEVELS or "")
return {item.strip().lower() for item in raw.split(",") if item.strip()}
def _controlled_apply_allowed(candidate: dict[str, Any]) -> tuple[bool, str | None]:
risk = str(candidate.get("risk_level") or "").strip().lower()
if settings.ENABLE_AWOOOP_ANSIBLE_CONTROLLED_APPLY is not True:
return False, "controlled_apply_disabled_by_config"
if candidate.get("break_glass_required") is True:
return False, "break_glass_required"
if candidate.get("auto_apply_enabled") is not True:
return False, "catalog_auto_apply_disabled"
if candidate.get("approval_required") is True:
return False, "catalog_still_requires_approval"
if risk not in _allowed_controlled_apply_risks():
return False, f"risk_level_not_allowed:{risk or 'unknown'}"
return True, None
def build_ansible_check_mode_claim_input(
*,
source_candidate_op_id: str,
candidate_input: dict[str, Any],
) -> dict[str, Any]:
safe = _safe_candidate(candidate_input)
incident_id = _incident_id_from_payload(candidate_input)
controlled_apply_allowed, controlled_apply_blocker = _controlled_apply_allowed(safe)
return {
"incident_id": incident_id,
"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": controlled_apply_allowed,
"approval_required_before_apply": not controlled_apply_allowed,
"controlled_apply_allowed": controlled_apply_allowed,
"controlled_apply_blocker": controlled_apply_blocker,
"source_candidate_op_id": source_candidate_op_id,
"catalog_id": safe["catalog_id"],
"playbook_path": safe["playbook_path"],
"apply_playbook_path": safe["catalog_playbook_path"],
"catalog_playbook_path": safe["catalog_playbook_path"],
"source_candidate_playbook_path": safe["source_candidate_playbook_path"],
"check_mode_playbook_path": safe["check_mode_playbook_path"],
"inventory_hosts": list(safe["inventory_hosts"]),
"risk_level": safe["risk_level"],
}
def _resolve_playbook_path(playbook_root: Path, playbook_path: str) -> Path:
relative = Path(playbook_path)
if relative.is_absolute() or not str(relative).startswith(str(_PLAYBOOK_PREFIX)):
raise ValueError("unsafe_playbook_path")
repo_root = playbook_root.parent.parent
resolved = (repo_root / relative).resolve()
allowed_root = (repo_root / _PLAYBOOK_PREFIX).resolve()
if allowed_root not in resolved.parents:
raise ValueError("playbook_outside_catalog")
if resolved.suffix not in {".yml", ".yaml"} or not resolved.exists():
raise ValueError("playbook_not_found")
return resolved
def _ansible_command_env(playbook_root: Path) -> dict[str, str]:
roles_path = str((playbook_root / "roles").resolve())
existing_roles_path = os.environ.get("ANSIBLE_ROLES_PATH")
if existing_roles_path:
roles_path = os.pathsep.join([roles_path, existing_roles_path])
return {
**os.environ,
"ANSIBLE_HOST_KEY_CHECKING": "true",
"ANSIBLE_RETRY_FILES_ENABLED": "false",
"ANSIBLE_ROLES_PATH": roles_path,
}
def build_ansible_check_mode_command(
*,
playbook_path: str,
inventory_hosts: tuple[str, ...],
playbook_root: Path | None = None,
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:
raise ValueError("ansible_playbook_catalog_missing")
inventory_path = (root / "inventory" / "hosts.yml").resolve()
if not inventory_path.exists():
raise ValueError("ansible_inventory_missing")
if not inventory_hosts or not all(_SAFE_HOST_RE.fullmatch(host) for host in inventory_hosts):
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={known_hosts_path} "
"-o IdentitiesOnly=yes -o BatchMode=yes"
)
extra_vars = {
"ansible_ssh_private_key_file": str(ssh_key_path),
"ansible_ssh_common_args": ssh_common_args,
}
command = [
"ansible-playbook",
"-i",
str(inventory_path),
str(playbook_abs),
"--check",
"--diff",
"--limit",
",".join(inventory_hosts),
"--extra-vars",
json.dumps(extra_vars, ensure_ascii=False, separators=(",", ":")),
]
env = _ansible_command_env(root)
return AnsibleCommandSpec(
command=command,
cwd=root,
env=env,
playbook_abs_path=playbook_abs,
inventory_abs_path=inventory_path,
)
def build_ansible_apply_command(
*,
playbook_path: str,
inventory_hosts: tuple[str, ...],
playbook_root: Path | None = None,
check_mode_ssh_key_path: Path | None = None,
check_mode_known_hosts_path: Path | None = None,
) -> AnsibleCommandSpec:
"""Build the controlled apply command after check-mode has passed."""
root = playbook_root or next((path for path in _playbook_roots() if path.exists()), None)
if root is None:
raise ValueError("ansible_playbook_catalog_missing")
inventory_path = (root / "inventory" / "hosts.yml").resolve()
if not inventory_path.exists():
raise ValueError("ansible_inventory_missing")
if not inventory_hosts or not all(_SAFE_HOST_RE.fullmatch(host) for host in inventory_hosts):
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={known_hosts_path} "
"-o IdentitiesOnly=yes -o BatchMode=yes"
)
extra_vars = {
"ansible_ssh_private_key_file": str(ssh_key_path),
"ansible_ssh_common_args": ssh_common_args,
}
command = [
"ansible-playbook",
"-i",
str(inventory_path),
str(playbook_abs),
"--diff",
"--limit",
",".join(inventory_hosts),
"--extra-vars",
json.dumps(extra_vars, ensure_ascii=False, separators=(",", ":")),
]
env = _ansible_command_env(root)
return AnsibleCommandSpec(
command=command,
cwd=root,
env=env,
playbook_abs_path=playbook_abs,
inventory_abs_path=inventory_path,
)
async def _run_ansible_command(spec: AnsibleCommandSpec, *, timeout_seconds: int) -> AnsibleRunResult:
started = time.monotonic()
process = await asyncio.create_subprocess_exec(
*spec.command,
cwd=str(spec.cwd),
env=spec.env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
timed_out = False
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(),
timeout=timeout_seconds,
)
except TimeoutError:
timed_out = True
process.kill()
stdout_bytes, stderr_bytes = await process.communicate()
duration_ms = int((time.monotonic() - started) * 1000)
return AnsibleRunResult(
returncode=124 if timed_out else int(process.returncode or 0),
stdout=stdout_bytes.decode("utf-8", "replace"),
stderr=stderr_bytes.decode("utf-8", "replace"),
duration_ms=duration_ms,
timed_out=timed_out,
)
def _build_result_payload(
result: AnsibleRunResult,
*,
controlled_apply_allowed: bool = False,
controlled_apply_blocker: str | None = None,
) -> tuple[str, dict[str, Any], dict[str, Any], str | None]:
status = "success" if result.returncode == 0 else "failed"
stdout_tail = _tail(result.stdout, _STDOUT_LIMIT)
stderr_tail = _tail(result.stderr, _STDERR_LIMIT)
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": controlled_apply_allowed,
"approval_required_before_apply": not controlled_apply_allowed,
"controlled_apply_allowed": controlled_apply_allowed,
"controlled_apply_blocker": controlled_apply_blocker,
"returncode": result.returncode,
"timed_out": result.timed_out,
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
"next_required_step": (
"controlled_apply_queued"
if result.returncode == 0 and controlled_apply_allowed
else "ai_playbook_or_transport_repair_required"
if result.returncode != 0
else "controlled_apply_blocked"
),
}
dry_run_result = {
"check_mode_executed": True,
"apply_executed": False,
"safe_to_apply_without_approval": controlled_apply_allowed,
"controlled_apply_allowed": controlled_apply_allowed,
"controlled_apply_blocker": controlled_apply_blocker,
"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,
"stderr_tail": stderr_tail,
}
error = None if result.returncode == 0 else (stderr_tail or f"ansible_check_mode_failed_rc_{result.returncode}")
return status, output, dry_run_result, error
def _build_apply_result_payload(result: AnsibleRunResult) -> tuple[str, dict[str, Any], dict[str, Any], str | None]:
status = "success" if result.returncode == 0 else "failed"
stdout_tail = _tail(result.stdout, _STDOUT_LIMIT)
stderr_tail = _tail(result.stderr, _STDERR_LIMIT)
output = {
"executor": "ansible",
"execution_mode": "controlled_apply",
"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": False,
"apply_enabled": True,
"controlled_apply_executed": True,
"returncode": result.returncode,
"timed_out": result.timed_out,
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
"next_required_step": (
"post_apply_verifier_and_learning_writeback"
if result.returncode == 0
else "ai_rollback_or_playbook_repair_required"
),
}
dry_run_result = {
"check_mode_executed_before_apply": True,
"apply_executed": True,
"controlled_apply_executed": True,
"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,
"stderr_tail": stderr_tail,
}
error = None if result.returncode == 0 else (stderr_tail or f"ansible_apply_failed_rc_{result.returncode}")
return status, output, dry_run_result, error
def _build_auto_repair_execution_receipt(
claim: AnsibleCheckModeClaim,
result: AnsibleRunResult,
*,
apply_op_id: str,
) -> dict[str, Any]:
success = result.returncode == 0
return {
"incident_id": claim.incident_id,
"playbook_id": str(claim.catalog_id or "")[:36] or "ansible",
"playbook_name": f"Ansible controlled apply: {claim.apply_playbook_path}"[:200],
"success": success,
"executed_steps": [
f"candidate:{claim.source_candidate_op_id}",
f"check_mode:{claim.op_id}",
f"apply:{apply_op_id}",
f"catalog:{claim.catalog_id}",
f"returncode:{result.returncode}",
],
"error_message": None if success else _tail(result.stderr or result.stdout, 2000),
"triggered_by": "ansible_controlled_apply",
"similarity_score": None,
"risk_level": str(claim.risk_level or ""),
"execution_time_ms": result.duration_ms,
}
def _int_from_value(value: Any, *, default: int = 1) -> int:
if value in (None, ""):
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckModeClaim, AnsibleRunResult] | None:
input_payload = _json_loads(row.get("input"))
output_payload = _json_loads(row.get("output"))
dry_run_result = _json_loads(row.get("dry_run_result"))
catalog_id = str(input_payload.get("catalog_id") or "")
if not catalog_id:
return None
catalog_item = get_ansible_catalog_item(catalog_id) or {}
incident_id = str(row.get("incident_id") or input_payload.get("incident_id") or "")
if not incident_id:
return None
inventory_hosts = input_payload.get("inventory_hosts") or catalog_item.get("inventory_hosts") or []
if not isinstance(inventory_hosts, list) or not inventory_hosts:
return None
apply_playbook_path = str(
input_payload.get("apply_playbook_path")
or input_payload.get("playbook_path")
or catalog_item.get("playbook_path")
or ""
)
check_mode_playbook_path = str(
input_payload.get("check_mode_playbook_path")
or input_payload.get("playbook_path")
or apply_playbook_path
)
if not apply_playbook_path:
return None
returncode = _int_from_value(
output_payload.get("returncode", dry_run_result.get("returncode")),
default=0 if str(row.get("status") or "").lower() == "success" else 1,
)
claim = AnsibleCheckModeClaim(
op_id=str(input_payload.get("check_mode_op_id") or row.get("parent_op_id") or ""),
source_candidate_op_id=str(input_payload.get("source_candidate_op_id") or ""),
incident_id=incident_id,
catalog_id=catalog_id,
playbook_path=check_mode_playbook_path,
apply_playbook_path=apply_playbook_path,
inventory_hosts=tuple(str(host) for host in inventory_hosts),
risk_level=str(input_payload.get("risk_level") or catalog_item.get("risk_level") or ""),
input_payload=input_payload,
)
result = AnsibleRunResult(
returncode=returncode,
stdout=str(output_payload.get("stdout_tail") or ""),
stderr=str(row.get("error") or output_payload.get("stderr_tail") or ""),
duration_ms=_int_from_value(row.get("duration_ms"), default=0),
timed_out=bool(output_payload.get("timed_out") or dry_run_result.get("timed_out") or False),
)
return claim, result
async def _record_auto_repair_execution_receipt(
claim: AnsibleCheckModeClaim,
result: AnsibleRunResult,
*,
apply_op_id: str,
project_id: str,
) -> bool:
receipt = _build_auto_repair_execution_receipt(
claim,
result,
apply_op_id=apply_op_id,
)
try:
async with get_db_context(project_id) as db:
inserted = await db.execute(
text("""
INSERT INTO auto_repair_executions (
incident_id,
playbook_id,
playbook_name,
success,
executed_steps,
error_message,
triggered_by,
similarity_score,
risk_level,
execution_time_ms
)
SELECT
CAST(:incident_id AS varchar(30)),
CAST(:playbook_id AS varchar(36)),
CAST(:playbook_name AS varchar(200)),
:success,
CAST(:executed_steps AS jsonb),
:error_message,
CAST(:triggered_by AS varchar(50)),
:similarity_score,
CAST(:risk_level AS varchar(20)),
:execution_time_ms
WHERE NOT EXISTS (
SELECT 1
FROM auto_repair_executions existing
WHERE existing.incident_id = CAST(:incident_id AS varchar(30))
AND existing.triggered_by = CAST(:triggered_by AS varchar(50))
AND existing.executed_steps::text LIKE CAST(:apply_op_id_needle AS text)
)
RETURNING id
"""),
{
**receipt,
"executed_steps": json.dumps(receipt["executed_steps"], ensure_ascii=False),
"apply_op_id_needle": f"%{apply_op_id}%",
},
)
return inserted.scalar() is not None
except Exception as exc:
logger.warning(
"ansible_auto_repair_execution_receipt_failed",
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
apply_op_id=apply_op_id,
error=str(exc),
)
return False
def _post_apply_verification_result(result: AnsibleRunResult) -> str:
if result.timed_out:
return "timeout"
return "success" if result.returncode == 0 else "failed"
def _post_apply_km_path_type(apply_op_id: str) -> str:
return f"ansible_apply_receipt:{str(apply_op_id or '')[:8]}"
def _post_apply_action_label(claim: AnsibleCheckModeClaim, *, apply_op_id: str) -> str:
return (
f"ansible_controlled_apply:{claim.catalog_id}:"
f"{claim.apply_playbook_path}:apply_op={apply_op_id}"
)
async def _record_post_apply_verifier_and_learning(
claim: AnsibleCheckModeClaim,
result: AnsibleRunResult,
*,
apply_op_id: str,
project_id: str,
) -> dict[str, bool]:
"""Persist post-apply verifier evidence and KM learning for Ansible apply."""
verification_result = _post_apply_verification_result(result)
action_label = _post_apply_action_label(claim, apply_op_id=apply_op_id)
post_state = {
"schema_version": "ansible_apply_result_verifier_v1",
"verifier": "ansible_apply_result_verifier",
"apply_op_id": apply_op_id,
"catalog_id": claim.catalog_id,
"playbook_path": claim.apply_playbook_path,
"returncode": result.returncode,
"timed_out": result.timed_out,
"action_taken": action_label,
"next_required_step": (
"km_playbook_trust_writeback"
if verification_result == "success"
else "rollback_or_playbook_repair_candidate"
),
}
evidence_summary = (
"AI Agent Ansible controlled apply verifier: "
f"incident={claim.incident_id}; catalog={claim.catalog_id}; "
f"result={verification_result}; returncode={result.returncode}; apply_op={apply_op_id}"
)
status = {"verification": False, "learning": False}
try:
async with get_db_context(project_id) as db:
inserted = await db.execute(
text("""
INSERT INTO incident_evidence (
id,
incident_id,
matched_playbook_id,
schema_version,
mcp_health,
collection_duration_ms,
sensors_attempted,
sensors_succeeded,
evidence_summary,
post_execution_state,
verification_result
)
SELECT
gen_random_uuid()::text,
CAST(:incident_id AS varchar(30)),
CAST(:matched_playbook_id AS varchar(36)),
'v1',
CAST(:mcp_health AS jsonb),
:collection_duration_ms,
1,
:sensors_succeeded,
:evidence_summary,
CAST(:post_execution_state AS jsonb),
CAST(:verification_result AS varchar(20))
WHERE NOT EXISTS (
SELECT 1
FROM incident_evidence existing
WHERE existing.incident_id = CAST(:incident_id AS varchar(30))
AND existing.post_execution_state ->> 'apply_op_id' = CAST(:apply_op_id AS text)
)
RETURNING id
"""),
{
"incident_id": claim.incident_id,
"matched_playbook_id": str(claim.catalog_id or "")[:36],
"mcp_health": json.dumps({
"ansible_apply_result_verifier": verification_result == "success"
}, ensure_ascii=False),
"collection_duration_ms": max(0, int(result.duration_ms or 0)),
"sensors_succeeded": 1 if verification_result == "success" else 0,
"evidence_summary": evidence_summary,
"post_execution_state": json.dumps(post_state, ensure_ascii=False),
"verification_result": verification_result,
"apply_op_id": apply_op_id,
},
)
status["verification"] = inserted.scalar() is not None
except Exception as exc:
logger.warning(
"ansible_post_apply_verifier_receipt_failed",
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
apply_op_id=apply_op_id,
error=str(exc),
)
try:
from src.models.knowledge import (
EntrySource,
EntryStatus,
EntryType,
KnowledgeEntryCreate,
)
from src.repositories.knowledge_repository import KnowledgeDBRepository
async with get_db_context(project_id) as db:
repo = KnowledgeDBRepository(db)
await repo.create(
KnowledgeEntryCreate(
title=f"AI 自動修復沉澱:{claim.incident_id}",
content=(
"AI Agent 已完成 Ansible controlled apply 並寫入驗證摘要。\n\n"
f"- Incident: {claim.incident_id}\n"
f"- Catalog: {claim.catalog_id}\n"
f"- PlayBook: {claim.apply_playbook_path}\n"
f"- Apply operation: {apply_op_id}\n"
f"- Verification result: {verification_result}\n"
f"- Return code: {result.returncode}\n"
f"- Next step: {post_state['next_required_step']}\n"
),
entry_type=EntryType.INCIDENT_CASE,
category="AI自動化/Ansible受控修復",
tags=[
"ai_auto_repair",
"ansible_controlled_apply",
verification_result,
str(claim.catalog_id or ""),
],
source=EntrySource.AI_EXTRACTED,
status=EntryStatus.REVIEW,
related_incident_id=claim.incident_id,
related_playbook_id=str(claim.catalog_id or "")[:36] or None,
path_type=_post_apply_km_path_type(apply_op_id),
created_by="ai_agent_ansible_worker",
)
)
status["learning"] = True
except Exception as exc:
logger.warning(
"ansible_post_apply_learning_writeback_failed",
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
apply_op_id=apply_op_id,
error=str(exc),
)
return status
async def _send_controlled_apply_telegram_receipt(
claim: AnsibleCheckModeClaim,
result: AnsibleRunResult,
*,
apply_op_id: str,
writeback: dict[str, bool],
project_id: str,
) -> bool:
try:
from src.services.telegram_gateway import get_telegram_gateway
response = await get_telegram_gateway().send_controlled_apply_result_receipt(
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
apply_op_id=apply_op_id,
playbook_path=claim.apply_playbook_path,
verification_result=_post_apply_verification_result(result),
returncode=result.returncode,
duration_ms=result.duration_ms,
verifier_written=bool(writeback.get("verification")),
learning_written=bool(writeback.get("learning")),
project_id=project_id,
)
return bool(response)
except Exception as exc:
logger.warning(
"ansible_controlled_apply_telegram_receipt_failed",
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
apply_op_id=apply_op_id,
error=str(exc),
)
return False
async def backfill_missing_auto_repair_execution_receipts_once(
*,
project_id: str = "awoooi",
window_hours: int = 24,
limit: int = 10,
) -> dict[str, Any]:
"""Create auto_repair_executions receipts for existing Ansible apply rows."""
stats = {
"scanned": 0,
"written": 0,
"verification_written": 0,
"learning_written": 0,
"skipped": 0,
"error": None,
}
try:
async with get_db_context(project_id) as db:
await db.execute(text("SET LOCAL statement_timeout = '5000ms'"))
result = await db.execute(
text("""
SELECT
apply.op_id::text AS op_id,
apply.parent_op_id::text AS parent_op_id,
coalesce(apply.incident_id::text, apply.input ->> 'incident_id') AS incident_id,
apply.input,
apply.output,
apply.dry_run_result,
apply.error,
apply.duration_ms,
apply.status,
apply.created_at
FROM automation_operation_log apply
WHERE apply.operation_type = 'ansible_apply_executed'
AND apply.created_at >= NOW() - (:window_hours * INTERVAL '1 hour')
AND (
NOT EXISTS (
SELECT 1
FROM auto_repair_executions existing
WHERE existing.created_at >= NOW() - (:window_hours * INTERVAL '1 hour')
AND existing.triggered_by = 'ansible_controlled_apply'
AND existing.executed_steps::text LIKE '%' || apply.op_id::text || '%'
)
OR NOT EXISTS (
SELECT 1
FROM incident_evidence evidence
WHERE evidence.incident_id = coalesce(
apply.incident_id::text,
apply.input ->> 'incident_id'
)
AND evidence.post_execution_state ->> 'apply_op_id' = apply.op_id::text
)
OR NOT EXISTS (
SELECT 1
FROM knowledge_entries km
WHERE km.related_incident_id = coalesce(
apply.incident_id::text,
apply.input ->> 'incident_id'
)
AND km.path_type = 'ansible_apply_receipt:' || left(apply.op_id::text, 8)
)
)
ORDER BY apply.created_at DESC
LIMIT :limit
"""),
{"window_hours": max(1, window_hours), "limit": max(1, limit)},
)
rows = [dict(row) for row in result.mappings().all()]
stats["scanned"] = len(rows)
for row in rows:
reconstructed = _claim_from_apply_operation_row(row)
if reconstructed is None:
stats["skipped"] += 1
continue
claim, result = reconstructed
if await _record_auto_repair_execution_receipt(
claim,
result,
apply_op_id=str(row.get("op_id") or ""),
project_id=project_id,
):
stats["written"] += 1
else:
stats["skipped"] += 1
writeback = await _record_post_apply_verifier_and_learning(
claim,
result,
apply_op_id=str(row.get("op_id") or ""),
project_id=project_id,
)
if writeback.get("verification"):
stats["verification_written"] += 1
if writeback.get("learning"):
stats["learning_written"] += 1
except Exception as exc:
stats["error"] = f"{type(exc).__name__}: {exc}"[:500]
logger.warning("ansible_auto_repair_execution_receipt_backfill_failed", **stats)
return stats
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("""
SELECT
candidate.op_id,
candidate.input
FROM automation_operation_log candidate
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
FROM automation_operation_log existing
WHERE existing.parent_op_id = candidate.op_id
AND existing.operation_type IN (
'ansible_check_mode_executed',
'ansible_execution_skipped'
)
)
ORDER BY candidate.created_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
"""),
{
"limit": max(1, limit),
"candidate_max_age_hours": max(1, max_age_hours),
},
)
rows = result.mappings().all()
for row in rows:
source_op_id = str(row["op_id"])
candidate_input = _json_loads(row["input"])
try:
claim_input = build_ansible_check_mode_claim_input(
source_candidate_op_id=source_op_id,
candidate_input=candidate_input,
)
except ValueError as exc:
await _insert_skipped_candidate(
db,
source_candidate_op_id=source_op_id,
candidate_input=candidate_input,
reason=str(exc),
)
continue
inserted = await db.execute(
text("""
INSERT INTO automation_operation_log (
operation_type, actor, status, incident_id,
input, output, dry_run_result,
parent_op_id, tags
) VALUES (
'ansible_check_mode_executed',
'ansible_check_mode_worker',
'pending',
:incident_db_id,
CAST(:input AS jsonb),
'{}'::jsonb,
CAST(:dry_run_result AS jsonb),
CAST(:parent_op_id AS uuid),
:tags
)
RETURNING op_id
"""),
{
"incident_id": _incident_id_from_payload(claim_input),
"incident_db_id": _automation_operation_log_incident_id(
_incident_id_from_payload(claim_input)
),
"input": json.dumps(claim_input, ensure_ascii=False),
"dry_run_result": json.dumps({
"check_mode_executed": False,
"apply_executed": False,
"claim_state": "claimed",
"controlled_apply_allowed": bool(
claim_input.get("controlled_apply_allowed")
),
"controlled_apply_blocker": claim_input.get("controlled_apply_blocker"),
}, ensure_ascii=False),
"parent_op_id": source_op_id,
"tags": [
"ansible",
"check_mode",
"pending",
"controlled_apply_allowed"
if claim_input.get("controlled_apply_allowed")
else "controlled_apply_blocked",
],
},
)
op_id = str(inserted.scalar_one())
claims.append(
AnsibleCheckModeClaim(
op_id=op_id,
source_candidate_op_id=source_op_id,
incident_id=str(claim_input.get("incident_id") or ""),
catalog_id=str(claim_input["catalog_id"]),
playbook_path=str(claim_input["playbook_path"]),
apply_playbook_path=str(claim_input["apply_playbook_path"]),
inventory_hosts=tuple(str(host) for host in claim_input["inventory_hosts"]),
risk_level=str(claim_input.get("risk_level") or ""),
input_payload=claim_input,
)
)
return claims
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("""
SELECT
coalesce(output::text, '') AS output_text,
coalesce(dry_run_result::text, '') AS dry_run_text,
coalesce(error, '') AS error_text,
coalesce(stderr_feed_back, '') AS stderr_text
FROM automation_operation_log
WHERE operation_type = 'ansible_check_mode_executed'
AND status = 'failed'
AND created_at >= NOW() - (:cooldown_seconds * INTERVAL '1 second')
ORDER BY created_at DESC
LIMIT 20
"""),
{"cooldown_seconds": max(60, cooldown)},
)
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(
blocker
for blocker in detected
if blocker != FORCED_COMMAND_BLOCKER or include_forced_blocker
)
return sorted(blockers)
async def _insert_skipped_candidate(
db: Any,
*,
source_candidate_op_id: str,
candidate_input: dict[str, Any],
reason: str,
) -> None:
input_payload = {
"incident_id": _incident_id_from_payload(candidate_input),
"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,
"not_used_reason": reason,
}
await db.execute(
text("""
INSERT INTO automation_operation_log (
operation_type, actor, status, incident_id,
input, output, dry_run_result,
parent_op_id, tags
) VALUES (
'ansible_execution_skipped',
'ansible_check_mode_worker',
'dry_run',
:incident_db_id,
CAST(:input AS jsonb),
CAST(:output AS jsonb),
CAST(:dry_run_result AS jsonb),
CAST(:parent_op_id AS uuid),
:tags
)
"""),
{
"incident_id": _incident_id_from_payload(input_payload),
"incident_db_id": _automation_operation_log_incident_id(
_incident_id_from_payload(input_payload)
),
"input": json.dumps(input_payload, ensure_ascii=False),
"output": json.dumps({
"not_used_reason": reason,
"decision_effect": "skipped_before_runtime",
}, ensure_ascii=False),
"dry_run_result": json.dumps({
"check_mode_executed": False,
"apply_executed": False,
"skipped": True,
"reason": reason,
}, ensure_ascii=False),
"parent_op_id": source_candidate_op_id,
"tags": ["ansible", "check_mode", "skipped", "apply_locked"],
},
)
async def finalize_check_mode_claim(
claim: AnsibleCheckModeClaim,
result: AnsibleRunResult,
*,
project_id: str = "awoooi",
) -> None:
controlled_apply_allowed = bool(claim.input_payload.get("controlled_apply_allowed"))
controlled_apply_blocker = (
str(claim.input_payload.get("controlled_apply_blocker"))
if claim.input_payload.get("controlled_apply_blocker")
else None
)
status, output, dry_run_result, error = _build_result_payload(
result,
controlled_apply_allowed=controlled_apply_allowed,
controlled_apply_blocker=controlled_apply_blocker,
)
async with get_db_context(project_id) as db:
await db.execute(
text("""
UPDATE automation_operation_log
SET status = :status,
output = CAST(:output AS jsonb),
dry_run_result = CAST(:dry_run_result AS jsonb),
error = :error,
duration_ms = :duration_ms,
stderr_feed_back = :stderr
WHERE op_id = CAST(:op_id AS uuid)
"""),
{
"status": status,
"output": json.dumps(output, ensure_ascii=False),
"dry_run_result": json.dumps(dry_run_result, ensure_ascii=False),
"error": _tail(error or "", 2000) or None,
"duration_ms": result.duration_ms,
"stderr": _tail(result.stderr, _STDERR_LIMIT),
"op_id": claim.op_id,
},
)
async def run_claimed_check_mode(
claim: AnsibleCheckModeClaim,
*,
timeout_seconds: int,
project_id: str = "awoooi",
) -> AnsibleRunResult:
try:
spec = build_ansible_check_mode_command(
playbook_path=claim.playbook_path,
inventory_hosts=claim.inventory_hosts,
)
result = await _run_ansible_command(spec, timeout_seconds=timeout_seconds)
except Exception as exc:
result = AnsibleRunResult(
returncode=1,
stdout="",
stderr=f"ansible_check_mode_runtime_error: {exc}",
duration_ms=0,
)
await finalize_check_mode_claim(claim, result, project_id=project_id)
logger.info(
"ansible_check_mode_claim_completed",
op_id=claim.op_id,
source_candidate_op_id=claim.source_candidate_op_id,
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
returncode=result.returncode,
timed_out=result.timed_out,
)
return result
async def run_controlled_apply_for_claim(
claim: AnsibleCheckModeClaim,
*,
timeout_seconds: int,
project_id: str = "awoooi",
) -> AnsibleRunResult | None:
"""Execute apply for an allowlisted claim after check-mode succeeds."""
if claim.input_payload.get("controlled_apply_allowed") is not True:
logger.info(
"ansible_controlled_apply_blocked",
op_id=claim.op_id,
source_candidate_op_id=claim.source_candidate_op_id,
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
blocker=claim.input_payload.get("controlled_apply_blocker"),
)
return None
async with get_db_context(project_id) as db:
inserted = await db.execute(
text("""
INSERT INTO automation_operation_log (
operation_type, actor, status, incident_id,
input, output, dry_run_result,
parent_op_id, tags
) VALUES (
'ansible_apply_executed',
'ansible_controlled_apply_worker',
'pending',
:incident_db_id,
CAST(:input AS jsonb),
'{}'::jsonb,
CAST(:dry_run_result AS jsonb),
CAST(:parent_op_id AS uuid),
:tags
)
RETURNING op_id
"""),
{
"incident_id": claim.incident_id,
"incident_db_id": _automation_operation_log_incident_id(claim.incident_id),
"input": json.dumps({
**claim.input_payload,
"execution_mode": "controlled_apply",
"check_mode_op_id": claim.op_id,
"playbook_path": claim.apply_playbook_path,
"check_mode_playbook_path": claim.playbook_path,
"apply_playbook_path": claim.apply_playbook_path,
"check_mode": False,
"apply_enabled": True,
"approval_required_before_apply": False,
"controlled_apply_allowed": True,
}, ensure_ascii=False),
"dry_run_result": json.dumps({
"check_mode_executed_before_apply": True,
"apply_executed": False,
"claim_state": "claimed",
"source_check_mode_op_id": claim.op_id,
}, ensure_ascii=False),
"parent_op_id": claim.op_id,
"tags": [
"ansible",
"controlled_apply",
str(claim.risk_level or "unknown").lower(),
"ai_agent_auto_execution",
],
},
)
apply_op_id = str(inserted.scalar_one())
try:
spec = build_ansible_apply_command(
playbook_path=claim.apply_playbook_path,
inventory_hosts=claim.inventory_hosts,
)
result = await _run_ansible_command(spec, timeout_seconds=timeout_seconds)
except Exception as exc:
result = AnsibleRunResult(
returncode=1,
stdout="",
stderr=f"ansible_controlled_apply_runtime_error: {exc}",
duration_ms=0,
)
status, output, dry_run_result, error = _build_apply_result_payload(result)
async with get_db_context(project_id) as db:
await db.execute(
text("""
UPDATE automation_operation_log
SET status = :status,
output = CAST(:output AS jsonb),
dry_run_result = CAST(:dry_run_result AS jsonb),
error = :error,
duration_ms = :duration_ms,
stderr_feed_back = :stderr
WHERE op_id = CAST(:op_id AS uuid)
"""),
{
"status": status,
"output": json.dumps(output, ensure_ascii=False),
"dry_run_result": json.dumps(dry_run_result, ensure_ascii=False),
"error": _tail(error or "", 2000) or None,
"duration_ms": result.duration_ms,
"stderr": _tail(result.stderr, _STDERR_LIMIT),
"op_id": apply_op_id,
},
)
receipt_written = await _record_auto_repair_execution_receipt(
claim,
result,
apply_op_id=apply_op_id,
project_id=project_id,
)
writeback = await _record_post_apply_verifier_and_learning(
claim,
result,
apply_op_id=apply_op_id,
project_id=project_id,
)
telegram_receipt_sent = await _send_controlled_apply_telegram_receipt(
claim,
result,
apply_op_id=apply_op_id,
writeback=writeback,
project_id=project_id,
)
logger.info(
"ansible_controlled_apply_completed",
op_id=apply_op_id,
check_mode_op_id=claim.op_id,
source_candidate_op_id=claim.source_candidate_op_id,
incident_id=claim.incident_id,
catalog_id=claim.catalog_id,
returncode=result.returncode,
timed_out=result.timed_out,
auto_repair_receipt_written=receipt_written,
post_apply_verification_written=writeback.get("verification"),
post_apply_learning_written=writeback.get("learning"),
telegram_receipt_sent=telegram_receipt_sent,
)
return result
async def run_pending_check_modes_once(
*,
project_id: str = "awoooi",
limit: int = 1,
timeout_seconds: int | None = None,
) -> dict[str, Any]:
blockers = _runtime_blockers()
if blockers:
logger.warning("ansible_check_mode_runtime_blocked", blockers=blockers)
return {"claimed": 0, "completed": 0, "failed": 0, "blockers": blockers}
transport_blockers = await recent_ansible_transport_blockers(project_id=project_id)
if transport_blockers:
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,
candidate_max_age_hours=settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS,
)
completed = 0
failed = 0
apply_completed = 0
apply_failed = 0
apply_blocked = 0
for claim in claims:
result = await run_claimed_check_mode(
claim,
timeout_seconds=timeout_seconds or settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS,
project_id=project_id,
)
completed += 1
if result.returncode != 0:
failed += 1
continue
apply_result = await run_controlled_apply_for_claim(
claim,
timeout_seconds=settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_TIMEOUT_SECONDS,
project_id=project_id,
)
if apply_result is None:
apply_blocked += 1
else:
apply_completed += 1
if apply_result.returncode != 0:
apply_failed += 1
return {
"claimed": len(claims),
"completed": completed,
"failed": failed,
"apply_completed": apply_completed,
"apply_failed": apply_failed,
"apply_blocked": apply_blocked,
"blockers": [],
}