1440 lines
55 KiB
Python
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": [],
|
|
}
|