Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
9296 lines
380 KiB
Python
9296 lines
380 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 signal
|
|
import time
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, replace
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
|
|
import structlog
|
|
from sqlalchemy import select, text
|
|
|
|
from src.core.config import settings
|
|
from src.db.base import get_db_context
|
|
from src.db.models import ApprovalRecord, PlaybookRecord
|
|
from src.models.approval import ApprovalStatus
|
|
from src.services.ai_automation_runtime_contract import (
|
|
AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION,
|
|
)
|
|
from src.services.awooop_ansible_audit_service import (
|
|
get_ansible_catalog_candidates,
|
|
get_ansible_catalog_item,
|
|
)
|
|
from src.services.awooop_ansible_learning_writeback import (
|
|
canonical_ansible_playbook_id,
|
|
ensure_ansible_rag_writeback,
|
|
record_ansible_playbook_trust_writeback,
|
|
)
|
|
from src.services.awooop_ansible_post_verifier import (
|
|
run_ansible_asset_post_verifier,
|
|
)
|
|
from src.services.playbook_evolver import TRUST_ARCHIVE_THRESHOLD
|
|
|
|
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
|
|
_CONTROLLED_RETRY_MAX_ATTEMPTS = 1
|
|
_CONTROLLED_RETRY_ERROR_BACKOFF_MIN_SECONDS = 15
|
|
_CONTROLLED_RETRY_ERROR_BACKOFF_MAX_SECONDS = 30
|
|
_STDIN_BOUNDARY_REPLAY_WINDOW_HOURS = 7 * 24
|
|
_CATALOG_REPAIR_REPLAY_WINDOW_HOURS = 72
|
|
_STALE_CLAIM_REPLAY_CONTROL_FIELDS = (
|
|
"bounded_execution_scope",
|
|
"catalog_drift_replay",
|
|
"catalog_repair_of_apply_op_id",
|
|
"catalog_replay_revision",
|
|
"previous_catalog_revision",
|
|
"previous_check_mode_playbook_path",
|
|
"replay_of_check_mode_op_id",
|
|
"semantic_repair_of_apply_op_id",
|
|
"semantic_route_reconciliation",
|
|
)
|
|
_HISTORICAL_SHADOW_REPLAY_TAGS = frozenset(
|
|
{"catalog_drift_replay", "semantic_catalog_reconciliation"}
|
|
)
|
|
_STDIN_BOUNDARY_TERMINAL_PROJECTION_BATCH_LIMIT = 5
|
|
_EXECUTION_CAPABILITY_MIN_TTL_SECONDS = 300
|
|
_EXECUTION_CAPABILITY_MAX_TTL_SECONDS = 1_800
|
|
_EXECUTION_CAPABILITY_SAFETY_MARGIN_SECONDS = 5
|
|
_WORKING_MEMORY_PROJECTION_WINDOW_HOURS = 8 * 24
|
|
_WORKING_MEMORY_PROJECTION_CURSOR: dict[str, tuple[datetime, str]] = {}
|
|
_EXECUTION_CAPABILITY_FALLBACK_OPERATION_TYPE = "remediation_executed"
|
|
_EXECUTION_CAPABILITY_OPERATION_TYPES = frozenset(
|
|
{
|
|
"ansible_executor_capability_issued",
|
|
"ansible_executor_capability_revoked",
|
|
"ansible_executor_capability_expired",
|
|
}
|
|
)
|
|
_VERIFIED_CLOSURE_REQUIRED_STAGE_IDS = frozenset(
|
|
{
|
|
"mcp_context",
|
|
"service_log_evidence",
|
|
"normalized_asset_identity",
|
|
"source_truth_diff",
|
|
"risk_policy_decision",
|
|
"executor_log_projection",
|
|
"retry_or_rollback",
|
|
"km_playbook_writeback",
|
|
"rag_writeback",
|
|
"playbook_trust",
|
|
"timeline_projection",
|
|
}
|
|
)
|
|
_NO_WRITE_REPLAY_REQUIRED_STAGE_IDS = _VERIFIED_CLOSURE_REQUIRED_STAGE_IDS
|
|
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
|
|
post_verifier_passed: bool | None = None
|
|
|
|
|
|
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 ""),
|
|
"catalog_revision": str(
|
|
candidate.get("catalog_revision")
|
|
or catalog_item.get("catalog_revision")
|
|
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 _apply_ansible_playbook_trust_gate(
|
|
candidate_input: dict[str, Any],
|
|
trust_rows: list[Mapping[str, Any]],
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""Filter failed PlayBooks while allowing one repaired revision canary."""
|
|
|
|
candidates = candidate_input.get("executor_candidates")
|
|
if not isinstance(candidates, list):
|
|
candidates = []
|
|
trust_by_id = {
|
|
str(row.get("playbook_id") or ""): row
|
|
for row in trust_rows
|
|
if str(row.get("playbook_id") or "")
|
|
}
|
|
eligible: list[dict[str, Any]] = []
|
|
decisions: list[dict[str, Any]] = []
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
catalog_id = str(candidate.get("catalog_id") or "")
|
|
canonical_id = canonical_ansible_playbook_id(catalog_id)
|
|
row = trust_by_id.get(canonical_id)
|
|
status = str((row or {}).get("status") or "").strip().lower()
|
|
trust_score = float((row or {}).get("trust_score") or 0.0)
|
|
success_count = int((row or {}).get("success_count") or 0)
|
|
failure_count = int((row or {}).get("failure_count") or 0)
|
|
catalog_item = get_ansible_catalog_item(catalog_id) or {}
|
|
candidate_catalog_revision = str(
|
|
candidate.get("catalog_revision") or ""
|
|
).strip()
|
|
allowlisted_catalog_revision = str(
|
|
catalog_item.get("catalog_revision") or ""
|
|
).strip()
|
|
current_catalog_revision = str(
|
|
allowlisted_catalog_revision
|
|
or candidate_catalog_revision
|
|
or ""
|
|
).strip()
|
|
observed_pattern = _json_loads(
|
|
(row or {}).get("symptom_pattern")
|
|
)
|
|
observed_catalog_revision = str(
|
|
observed_pattern.get("catalog_revision") or ""
|
|
).strip()
|
|
retired = status in {"deprecated", "archived"}
|
|
learned_failure = bool(
|
|
row
|
|
and failure_count > success_count
|
|
and trust_score < TRUST_ARCHIVE_THRESHOLD
|
|
)
|
|
bounded_repair_canary = bool(
|
|
not retired
|
|
and learned_failure
|
|
and current_catalog_revision
|
|
and current_catalog_revision != observed_catalog_revision
|
|
)
|
|
circuit_open = retired or (
|
|
learned_failure and not bounded_repair_canary
|
|
)
|
|
reason = (
|
|
"playbook_status_retired"
|
|
if retired
|
|
else "repaired_catalog_revision_canary_allowed"
|
|
if bounded_repair_canary
|
|
else "playbook_trust_below_archive_threshold"
|
|
if learned_failure
|
|
else "playbook_trust_allows_controlled_canary"
|
|
if row
|
|
else "playbook_unobserved_canary_allowed"
|
|
)
|
|
decisions.append(
|
|
{
|
|
"catalog_id": catalog_id,
|
|
"canonical_playbook_id": canonical_id,
|
|
"trust_record_present": row is not None,
|
|
"status": status or None,
|
|
"trust_score": round(trust_score, 6) if row else None,
|
|
"success_count": success_count,
|
|
"failure_count": failure_count,
|
|
"review_required": bool((row or {}).get("review_required")),
|
|
"current_catalog_revision": current_catalog_revision or None,
|
|
"catalog_revision_source": (
|
|
"allowlisted_catalog"
|
|
if allowlisted_catalog_revision
|
|
else "candidate_fallback"
|
|
if candidate_catalog_revision
|
|
else None
|
|
),
|
|
"candidate_catalog_revision_mismatch": bool(
|
|
allowlisted_catalog_revision
|
|
and candidate_catalog_revision
|
|
and candidate_catalog_revision
|
|
!= allowlisted_catalog_revision
|
|
),
|
|
"observed_catalog_revision": observed_catalog_revision or None,
|
|
"bounded_repair_canary": bounded_repair_canary,
|
|
"circuit_open": circuit_open,
|
|
"reason": reason,
|
|
}
|
|
)
|
|
if not circuit_open:
|
|
eligible.append(candidate)
|
|
|
|
blocked_count = sum(1 for item in decisions if item["circuit_open"])
|
|
status = (
|
|
"circuit_open"
|
|
if decisions and not eligible
|
|
else "degraded_candidates_filtered"
|
|
if blocked_count
|
|
else "closed"
|
|
)
|
|
gate = {
|
|
"schema_version": "ansible_playbook_trust_claim_gate_v2",
|
|
"policy_source": "playbook_evolver.TRUST_ARCHIVE_THRESHOLD",
|
|
"trust_archive_threshold": TRUST_ARCHIVE_THRESHOLD,
|
|
"status": status,
|
|
"candidate_count": len(decisions),
|
|
"eligible_candidate_count": len(eligible),
|
|
"blocked_candidate_count": blocked_count,
|
|
"owner_review_used_as_terminal": False,
|
|
"next_controlled_action": (
|
|
"generate_or_verify_playbook_repair_candidate"
|
|
if status == "circuit_open"
|
|
else "continue_with_highest_ranked_eligible_candidate"
|
|
),
|
|
"decisions": decisions,
|
|
}
|
|
return (
|
|
{
|
|
**candidate_input,
|
|
"executor_candidates": eligible,
|
|
"playbook_trust_gate": gate,
|
|
},
|
|
gate,
|
|
)
|
|
|
|
|
|
async def _guard_candidate_input_by_playbook_trust(
|
|
db: Any,
|
|
candidate_input: dict[str, Any],
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
candidates = candidate_input.get("executor_candidates")
|
|
canonical_ids = sorted(
|
|
{
|
|
canonical_ansible_playbook_id(str(candidate.get("catalog_id") or ""))
|
|
for candidate in candidates or []
|
|
if isinstance(candidate, dict) and candidate.get("catalog_id")
|
|
}
|
|
)
|
|
trust_rows: list[Mapping[str, Any]] = []
|
|
if canonical_ids:
|
|
result = await db.execute(
|
|
select(
|
|
PlaybookRecord.playbook_id,
|
|
PlaybookRecord.status,
|
|
PlaybookRecord.trust_score,
|
|
PlaybookRecord.success_count,
|
|
PlaybookRecord.failure_count,
|
|
PlaybookRecord.review_required,
|
|
PlaybookRecord.symptom_pattern,
|
|
).where(PlaybookRecord.playbook_id.in_(canonical_ids))
|
|
)
|
|
trust_rows = list(result.mappings().all())
|
|
return _apply_ansible_playbook_trust_gate(candidate_input, trust_rows)
|
|
|
|
|
|
async def _revalidate_claim_playbook_trust(
|
|
db: Any,
|
|
claim: AnsibleCheckModeClaim,
|
|
) -> bool:
|
|
candidate_input = {
|
|
**claim.input_payload,
|
|
"executor_candidates": [
|
|
{
|
|
"catalog_id": claim.catalog_id,
|
|
"playbook_path": claim.apply_playbook_path,
|
|
"check_mode_playbook_path": claim.playbook_path,
|
|
"inventory_hosts": list(claim.inventory_hosts),
|
|
"risk_level": claim.risk_level,
|
|
"catalog_revision": str(
|
|
claim.input_payload.get("catalog_revision") or ""
|
|
),
|
|
}
|
|
],
|
|
}
|
|
_, gate = await _guard_candidate_input_by_playbook_trust(
|
|
db,
|
|
candidate_input,
|
|
)
|
|
claim.input_payload["playbook_trust_gate"] = gate
|
|
if gate.get("status") != "circuit_open":
|
|
return True
|
|
claim.input_payload["controlled_apply_allowed"] = False
|
|
claim.input_payload["controlled_apply_blocker"] = (
|
|
"playbook_trust_circuit_open"
|
|
)
|
|
await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log
|
|
SET input = coalesce(input, '{}'::jsonb) || jsonb_build_object(
|
|
'controlled_apply_allowed', false,
|
|
'controlled_apply_blocker', 'playbook_trust_circuit_open',
|
|
'playbook_trust_gate', CAST(:trust_gate AS jsonb)
|
|
),
|
|
dry_run_result = coalesce(
|
|
dry_run_result,
|
|
'{}'::jsonb
|
|
) || jsonb_build_object(
|
|
'controlled_apply_allowed', false,
|
|
'controlled_apply_blocker', 'playbook_trust_circuit_open',
|
|
'playbook_trust_gate', CAST(:trust_gate AS jsonb)
|
|
)
|
|
WHERE op_id = CAST(:op_id AS uuid)
|
|
AND operation_type = 'ansible_check_mode_executed'
|
|
"""),
|
|
{
|
|
"trust_gate": json.dumps(gate, ensure_ascii=False),
|
|
"op_id": claim.op_id,
|
|
},
|
|
)
|
|
return False
|
|
|
|
|
|
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)
|
|
proposal_risk_level = str(
|
|
candidate_input.get("proposal_risk_level") or ""
|
|
).strip().lower()
|
|
if proposal_risk_level == "critical":
|
|
controlled_apply_allowed = False
|
|
controlled_apply_blocker = "critical_break_glass_required"
|
|
return {
|
|
"automation_run_id": str(source_candidate_op_id),
|
|
"incident_id": incident_id,
|
|
"approval_id": str(candidate_input.get("approval_id") or ""),
|
|
"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,
|
|
"candidate_idempotency_key": str(
|
|
candidate_input.get("idempotency_key") or ""
|
|
),
|
|
"apply_idempotency_key": (
|
|
f"ansible-apply:{source_candidate_op_id}:{safe['catalog_id']}"
|
|
),
|
|
"single_writer_executor": "awoooi-ansible-executor-broker",
|
|
"decision_path": str(candidate_input.get("decision_path") or ""),
|
|
"router_source_sha": str(candidate_input.get("router_source_sha") or "")
|
|
.strip()
|
|
.lower(),
|
|
"target_selector": candidate_input.get("target_selector") or {},
|
|
"source_truth_diff": candidate_input.get("source_truth_diff") or {},
|
|
"risk_policy_decision": candidate_input.get("risk_policy_decision") or {},
|
|
"playbook_trust_gate": candidate_input.get("playbook_trust_gate") or {},
|
|
"source_candidate_op_id": source_candidate_op_id,
|
|
"catalog_id": safe["catalog_id"],
|
|
"catalog_revision": safe["catalog_revision"],
|
|
"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,
|
|
start_new_session=True,
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
def kill_process_group() -> None:
|
|
if process.returncode is not None:
|
|
return
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
return
|
|
except PermissionError:
|
|
process.kill()
|
|
|
|
timed_out = False
|
|
try:
|
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
process.communicate(),
|
|
timeout=timeout_seconds,
|
|
)
|
|
except TimeoutError:
|
|
timed_out = True
|
|
kill_process_group()
|
|
stdout_bytes, stderr_bytes = await process.communicate()
|
|
except asyncio.CancelledError:
|
|
kill_process_group()
|
|
await process.communicate()
|
|
raise
|
|
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 _approval_id_for_claim(claim: AnsibleCheckModeClaim) -> str:
|
|
raw_value = str(claim.input_payload.get("approval_id") or "").strip()
|
|
if not raw_value:
|
|
return ""
|
|
try:
|
|
return str(UUID(raw_value))
|
|
except ValueError:
|
|
return ""
|
|
|
|
|
|
async def _append_alert_lifecycle_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
event_type: str,
|
|
*,
|
|
apply_op_id: str,
|
|
success: bool,
|
|
action_detail: str,
|
|
project_id: str,
|
|
error_message: str | None = None,
|
|
post_verifier_passed: bool | None = None,
|
|
) -> bool:
|
|
from src.repositories.alert_operation_log_repository import (
|
|
get_alert_operation_log_repository,
|
|
)
|
|
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
existing = await db.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log
|
|
WHERE incident_id = :incident_id
|
|
AND event_type::text = :event_type
|
|
AND context ->> 'apply_op_id' = :apply_op_id
|
|
AND context ->> 'automation_run_id' = :automation_run_id
|
|
)
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"event_type": event_type,
|
|
"apply_op_id": apply_op_id,
|
|
"automation_run_id": automation_run_id,
|
|
},
|
|
)
|
|
if existing.scalar() is True:
|
|
return True
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_alert_lifecycle_receipt_readback_failed",
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
event_type=event_type,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
|
|
record = await get_alert_operation_log_repository().append(
|
|
event_type,
|
|
incident_id=claim.incident_id,
|
|
approval_id=_approval_id_for_claim(claim) or None,
|
|
actor="awoooi-ansible-executor-broker",
|
|
action_detail=action_detail,
|
|
success=success,
|
|
error_message=error_message,
|
|
context={
|
|
"automation_run_id": automation_run_id,
|
|
"catalog_id": claim.catalog_id,
|
|
"check_mode_op_id": claim.op_id,
|
|
"apply_op_id": apply_op_id,
|
|
"single_writer_executor": "awoooi-ansible-executor-broker",
|
|
"post_verifier_passed": post_verifier_passed,
|
|
},
|
|
project_id=project_id,
|
|
)
|
|
return record is not None
|
|
|
|
|
|
async def _finalize_controlled_approval_projection(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
) -> bool:
|
|
approval_id = _approval_id_for_claim(claim)
|
|
if not approval_id:
|
|
return False
|
|
success = bool(
|
|
result.returncode == 0 and result.post_verifier_passed is True
|
|
)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
selected = await db.execute(
|
|
select(ApprovalRecord).where(ApprovalRecord.id == approval_id)
|
|
)
|
|
approval = selected.scalar_one_or_none()
|
|
if approval is None:
|
|
return False
|
|
metadata = dict(approval.extra_metadata or {})
|
|
metadata.update({
|
|
"automation_run_id": _automation_run_id_for_claim(claim),
|
|
"single_writer_executor": "awoooi-ansible-executor-broker",
|
|
"execution_kind": "controlled_apply",
|
|
"repair_attempted": True,
|
|
"repair_executed": result.returncode == 0,
|
|
"post_verifier_passed": result.post_verifier_passed is True,
|
|
"apply_op_id": apply_op_id,
|
|
})
|
|
approval.extra_metadata = metadata
|
|
approval.status = (
|
|
ApprovalStatus.EXECUTION_SUCCESS
|
|
if success
|
|
else ApprovalStatus.EXECUTION_FAILED
|
|
)
|
|
approval.resolved_at = datetime.now(UTC)
|
|
if not success:
|
|
approval.rejection_reason = _tail(
|
|
result.stderr or "independent_post_verifier_failed",
|
|
2000,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_controlled_apply_approval_projection_failed",
|
|
incident_id=claim.incident_id,
|
|
approval_id=approval_id,
|
|
apply_op_id=apply_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _build_auto_repair_execution_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
) -> dict[str, Any]:
|
|
success = bool(
|
|
result.returncode == 0 and result.post_verifier_passed is True
|
|
)
|
|
if success:
|
|
error_message = None
|
|
elif result.returncode != 0:
|
|
error_message = _tail(result.stderr or result.stdout, 2000)
|
|
else:
|
|
error_message = "independent_post_verifier_failed"
|
|
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"automation_run_id:{claim.input_payload.get('automation_run_id') or claim.source_candidate_op_id}",
|
|
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}",
|
|
f"post_verifier_passed:{str(result.post_verifier_passed is True).lower()}",
|
|
],
|
|
"error_message": error_message,
|
|
"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 row.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 row.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 row.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 row.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
|
|
|
|
|
|
def _claim_from_stale_check_mode_row(
|
|
row: dict[str, Any],
|
|
) -> AnsibleCheckModeClaim | None:
|
|
input_payload = _json_loads(row.get("input"))
|
|
source_candidate_op_id = str(
|
|
input_payload.get("source_candidate_op_id")
|
|
or row.get("parent_op_id")
|
|
or ""
|
|
)
|
|
incident_id = str(
|
|
row.get("incident_id")
|
|
or input_payload.get("incident_id")
|
|
or ""
|
|
)
|
|
previous_catalog_id = str(input_payload.get("catalog_id") or "")
|
|
catalog_id = previous_catalog_id
|
|
incident_routing_evaluated = bool(
|
|
row.get("incident_alertname")
|
|
or row.get("incident_alert_category")
|
|
or row.get("incident_affected_services")
|
|
)
|
|
if incident_routing_evaluated:
|
|
candidates = get_ansible_catalog_candidates(
|
|
{
|
|
"alertname": row.get("incident_alertname"),
|
|
"alert_category": row.get("incident_alert_category"),
|
|
"affected_services": row.get("incident_affected_services") or [],
|
|
}
|
|
)
|
|
if not candidates:
|
|
return None
|
|
catalog_id = str(candidates[0].get("catalog_id") or "")
|
|
catalog_item = get_ansible_catalog_item(catalog_id) or {}
|
|
inventory_hosts = catalog_item.get("inventory_hosts") or input_payload.get(
|
|
"inventory_hosts"
|
|
)
|
|
source_playbook_path = str(
|
|
input_payload.get("source_candidate_playbook_path")
|
|
or input_payload.get("catalog_playbook_path")
|
|
or input_payload.get("apply_playbook_path")
|
|
or input_payload.get("playbook_path")
|
|
or ""
|
|
)
|
|
if (
|
|
not source_candidate_op_id
|
|
or not incident_id
|
|
or not catalog_id
|
|
or not source_playbook_path
|
|
or not isinstance(inventory_hosts, list)
|
|
or not inventory_hosts
|
|
):
|
|
return None
|
|
canonical_playbook_path = str(catalog_item.get("playbook_path") or "")
|
|
if not canonical_playbook_path:
|
|
return None
|
|
try:
|
|
claim_input = build_ansible_check_mode_claim_input(
|
|
source_candidate_op_id=source_candidate_op_id,
|
|
candidate_input={
|
|
"incident_id": incident_id,
|
|
"executor": "ansible",
|
|
"executor_candidates": [
|
|
{
|
|
"catalog_id": catalog_id,
|
|
# A stale row is evidence, never execution authority. Path
|
|
# migrations must resolve through the current allowlisted
|
|
# catalog so a repaired bounded playbook can replace an
|
|
# unsafe or retired predecessor without trusting DB input.
|
|
"playbook_path": canonical_playbook_path,
|
|
"inventory_hosts": inventory_hosts,
|
|
"risk_level": catalog_item.get("risk_level"),
|
|
}
|
|
],
|
|
},
|
|
)
|
|
except ValueError:
|
|
return None
|
|
claim_input.update(
|
|
{
|
|
"previous_catalog_id": previous_catalog_id or None,
|
|
"catalog_route_changed": catalog_id != previous_catalog_id,
|
|
"incident_semantic_route_rechecked": incident_routing_evaluated,
|
|
}
|
|
)
|
|
for field_name in _STALE_CLAIM_REPLAY_CONTROL_FIELDS:
|
|
if field_name in input_payload:
|
|
claim_input[field_name] = input_payload[field_name]
|
|
row_tags = {
|
|
str(tag)
|
|
for tag in (row.get("tags") or [])
|
|
if isinstance(tag, str)
|
|
}
|
|
historical_shadow_replay = bool(
|
|
row_tags & _HISTORICAL_SHADOW_REPLAY_TAGS
|
|
)
|
|
if (
|
|
historical_shadow_replay
|
|
or input_payload.get("historical_projection_backfill") is True
|
|
or input_payload.get("telegram_provider_delivery") == "shadow_only"
|
|
):
|
|
claim_input["historical_projection_backfill"] = True
|
|
claim_input["telegram_provider_delivery"] = "shadow_only"
|
|
return AnsibleCheckModeClaim(
|
|
op_id=str(row.get("op_id") or ""),
|
|
source_candidate_op_id=source_candidate_op_id,
|
|
incident_id=incident_id,
|
|
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,
|
|
)
|
|
|
|
|
|
def _claim_from_failed_apply_catalog_repair_row(
|
|
row: dict[str, Any],
|
|
) -> AnsibleCheckModeClaim | None:
|
|
"""Build a new bounded claim when a failed apply's catalog was repaired."""
|
|
|
|
input_payload = _json_loads(row.get("input"))
|
|
apply_op_id = str(row.get("op_id") or "")
|
|
source_candidate_op_id = str(
|
|
input_payload.get("source_candidate_op_id") or ""
|
|
)
|
|
incident_id = str(
|
|
row.get("incident_id")
|
|
or input_payload.get("incident_id")
|
|
or ""
|
|
)
|
|
catalog_id = str(input_payload.get("catalog_id") or "")
|
|
catalog_item = get_ansible_catalog_item(catalog_id) or {}
|
|
current_revision = str(catalog_item.get("catalog_revision") or "")
|
|
previous_revision = str(input_payload.get("catalog_revision") or "")
|
|
current_playbook_path = str(catalog_item.get("playbook_path") or "")
|
|
inventory_hosts = catalog_item.get("inventory_hosts") or []
|
|
if not (
|
|
apply_op_id
|
|
and source_candidate_op_id
|
|
and incident_id
|
|
and catalog_id
|
|
and current_revision
|
|
and current_revision != previous_revision
|
|
and current_playbook_path
|
|
and isinstance(inventory_hosts, list)
|
|
and inventory_hosts
|
|
):
|
|
return None
|
|
|
|
try:
|
|
claim_input = build_ansible_check_mode_claim_input(
|
|
source_candidate_op_id=source_candidate_op_id,
|
|
candidate_input={
|
|
"incident_id": incident_id,
|
|
"approval_id": input_payload.get("approval_id"),
|
|
"proposal_risk_level": input_payload.get(
|
|
"proposal_risk_level"
|
|
),
|
|
"decision_path": "failed_apply_catalog_repair_replay",
|
|
"router_source_sha": input_payload.get("router_source_sha"),
|
|
"target_selector": input_payload.get("target_selector") or {},
|
|
"source_truth_diff": input_payload.get("source_truth_diff")
|
|
or {},
|
|
"risk_policy_decision": input_payload.get(
|
|
"risk_policy_decision"
|
|
)
|
|
or {},
|
|
"executor_candidates": [
|
|
{
|
|
"catalog_id": catalog_id,
|
|
"catalog_revision": current_revision,
|
|
"playbook_path": current_playbook_path,
|
|
"check_mode_playbook_path": str(
|
|
catalog_item.get("check_mode_playbook_path")
|
|
or current_playbook_path
|
|
),
|
|
"inventory_hosts": inventory_hosts,
|
|
"risk_level": catalog_item.get("risk_level"),
|
|
}
|
|
],
|
|
},
|
|
)
|
|
except ValueError:
|
|
return None
|
|
|
|
automation_run_id = str(
|
|
input_payload.get("automation_run_id")
|
|
or source_candidate_op_id
|
|
)
|
|
claim_input.update(
|
|
{
|
|
"automation_run_id": automation_run_id,
|
|
"catalog_repair_of_apply_op_id": apply_op_id,
|
|
"previous_catalog_revision": previous_revision or None,
|
|
"catalog_replay_revision": current_revision,
|
|
"apply_idempotency_key": (
|
|
f"ansible-repair-apply:{apply_op_id}:{current_revision}"
|
|
),
|
|
"bounded_execution_scope": "catalog_repair_single_apply",
|
|
}
|
|
)
|
|
return AnsibleCheckModeClaim(
|
|
op_id="",
|
|
source_candidate_op_id=source_candidate_op_id,
|
|
incident_id=incident_id,
|
|
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,
|
|
)
|
|
|
|
|
|
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,
|
|
verifier_receipt: Mapping[str, Any] | None = None,
|
|
) -> str:
|
|
if result.timed_out:
|
|
return "timeout"
|
|
if result.returncode != 0:
|
|
return "failed"
|
|
if not isinstance(verifier_receipt, Mapping):
|
|
return "failed"
|
|
return (
|
|
"success"
|
|
if verifier_receipt.get("verification_result") == "success"
|
|
and verifier_receipt.get("all_postconditions_passed") is True
|
|
and verifier_receipt.get("executor_returncode_trusted") is False
|
|
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}"
|
|
)
|
|
|
|
|
|
def _automation_run_id_for_claim(claim: AnsibleCheckModeClaim) -> str:
|
|
return str(
|
|
claim.input_payload.get("automation_run_id")
|
|
or claim.source_candidate_op_id
|
|
)
|
|
|
|
|
|
async def _resolve_execution_capability_operation_type(
|
|
db: Any,
|
|
*,
|
|
semantic_operation_type: str,
|
|
) -> str:
|
|
"""Use a schema-safe semantic receipt until the native type is allowed."""
|
|
|
|
if semantic_operation_type not in _EXECUTION_CAPABILITY_OPERATION_TYPES:
|
|
raise ValueError("unsupported_execution_capability_operation_type")
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT CASE
|
|
WHEN EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint
|
|
WHERE conname = 'automation_operation_log_type_valid'
|
|
AND pg_get_constraintdef(oid)
|
|
LIKE '%' || :semantic_operation_type || '%'
|
|
)
|
|
THEN :semantic_operation_type
|
|
ELSE :fallback_operation_type
|
|
END
|
|
"""),
|
|
{
|
|
"semantic_operation_type": semantic_operation_type,
|
|
"fallback_operation_type": (
|
|
_EXECUTION_CAPABILITY_FALLBACK_OPERATION_TYPE
|
|
),
|
|
},
|
|
)
|
|
return str(
|
|
result.scalar_one()
|
|
or _EXECUTION_CAPABILITY_FALLBACK_OPERATION_TYPE
|
|
)
|
|
|
|
|
|
def _execution_capability_timeout_seconds(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
execution_mode: str,
|
|
requested_timeout_seconds: int,
|
|
now: datetime | None = None,
|
|
) -> int:
|
|
"""Validate the broker lease and keep execution inside its deadline."""
|
|
|
|
capability = claim.input_payload.get("execution_capability")
|
|
if not isinstance(capability, dict):
|
|
raise ValueError("execution_capability_missing")
|
|
capability_op_id = str(capability.get("capability_op_id") or "")
|
|
if not capability_op_id:
|
|
raise ValueError("execution_capability_id_missing")
|
|
if str(capability.get("automation_run_id") or "") != (
|
|
_automation_run_id_for_claim(claim)
|
|
):
|
|
raise ValueError("execution_capability_run_id_mismatch")
|
|
if str(capability.get("check_mode_op_id") or "") != claim.op_id:
|
|
raise ValueError("execution_capability_check_mode_mismatch")
|
|
|
|
scope = capability.get("scope")
|
|
if not isinstance(scope, dict):
|
|
raise ValueError("execution_capability_scope_missing")
|
|
expected_scope = {
|
|
"catalog_id": claim.catalog_id,
|
|
"playbook_path": claim.playbook_path,
|
|
"apply_playbook_path": claim.apply_playbook_path,
|
|
"inventory_hosts": list(claim.inventory_hosts),
|
|
"risk_level": claim.risk_level,
|
|
}
|
|
for key, expected_value in expected_scope.items():
|
|
if scope.get(key) != expected_value:
|
|
raise ValueError(f"execution_capability_scope_mismatch:{key}")
|
|
allowed_execution_modes = scope.get("allowed_execution_modes")
|
|
if (
|
|
not isinstance(allowed_execution_modes, list)
|
|
or execution_mode not in allowed_execution_modes
|
|
):
|
|
raise ValueError("execution_capability_mode_not_allowed")
|
|
|
|
try:
|
|
issued_at = datetime.fromisoformat(
|
|
str(capability.get("issued_at") or "").replace("Z", "+00:00")
|
|
)
|
|
expires_at = datetime.fromisoformat(
|
|
str(capability.get("expires_at") or "").replace("Z", "+00:00")
|
|
)
|
|
except ValueError as exc:
|
|
raise ValueError("execution_capability_timestamp_invalid") from exc
|
|
if issued_at.tzinfo is None or expires_at.tzinfo is None:
|
|
raise ValueError("execution_capability_timestamp_timezone_missing")
|
|
current_time = (now or datetime.now(UTC)).astimezone(UTC)
|
|
issued_at = issued_at.astimezone(UTC)
|
|
expires_at = expires_at.astimezone(UTC)
|
|
if current_time < issued_at:
|
|
raise ValueError("execution_capability_not_yet_valid")
|
|
remaining_seconds = int((expires_at - current_time).total_seconds())
|
|
bounded_timeout = min(
|
|
int(requested_timeout_seconds),
|
|
remaining_seconds - _EXECUTION_CAPABILITY_SAFETY_MARGIN_SECONDS,
|
|
)
|
|
if bounded_timeout < 1:
|
|
raise ValueError("execution_capability_expired")
|
|
return bounded_timeout
|
|
|
|
|
|
async def _issue_ansible_execution_capability(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
ttl_seconds: int,
|
|
project_id: str,
|
|
) -> tuple[AnsibleCheckModeClaim, str]:
|
|
"""Issue a bounded broker lease before check/apply can touch a target."""
|
|
|
|
catalog_item = get_ansible_catalog_item(claim.catalog_id)
|
|
if not catalog_item:
|
|
raise ValueError("execution_capability_catalog_not_allowlisted")
|
|
catalog_hosts = {
|
|
str(host) for host in catalog_item.get("inventory_hosts") or []
|
|
}
|
|
if not claim.inventory_hosts or not set(claim.inventory_hosts).issubset(
|
|
catalog_hosts
|
|
):
|
|
raise ValueError("execution_capability_inventory_outside_allowlist")
|
|
|
|
normalized_ttl_seconds = max(
|
|
_EXECUTION_CAPABILITY_MIN_TTL_SECONDS,
|
|
min(_EXECUTION_CAPABILITY_MAX_TTL_SECONDS, int(ttl_seconds)),
|
|
)
|
|
issued_at = datetime.now(UTC)
|
|
expires_at = issued_at + timedelta(seconds=normalized_ttl_seconds)
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
allowed_execution_modes = ["check_mode"]
|
|
if claim.input_payload.get("controlled_apply_allowed") is True:
|
|
allowed_execution_modes.append("controlled_apply")
|
|
capability_scope = {
|
|
"catalog_id": claim.catalog_id,
|
|
"playbook_path": claim.playbook_path,
|
|
"apply_playbook_path": claim.apply_playbook_path,
|
|
"inventory_hosts": list(claim.inventory_hosts),
|
|
"risk_level": claim.risk_level,
|
|
"allowed_execution_modes": allowed_execution_modes,
|
|
}
|
|
async with get_db_context(project_id) as db:
|
|
ledger_operation_type = await _resolve_execution_capability_operation_type(
|
|
db,
|
|
semantic_operation_type="ansible_executor_capability_issued",
|
|
)
|
|
inserted = await db.execute(
|
|
text("""
|
|
INSERT INTO automation_operation_log (
|
|
operation_type, actor, status, incident_id,
|
|
input, output, parent_op_id, tags
|
|
)
|
|
SELECT
|
|
:ledger_operation_type,
|
|
'ansible_execution_broker',
|
|
'success',
|
|
:incident_db_id,
|
|
CAST(:input AS jsonb),
|
|
CAST(:output AS jsonb),
|
|
CAST(:parent_op_id AS uuid),
|
|
:tags
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log issued
|
|
WHERE issued.parent_op_id = CAST(:parent_op_id AS uuid)
|
|
AND coalesce(
|
|
issued.input ->> 'semantic_operation_type',
|
|
issued.operation_type
|
|
) = 'ansible_executor_capability_issued'
|
|
AND NULLIF(issued.input ->> 'expires_at', '')::timestamptz
|
|
> NOW()
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log terminal
|
|
WHERE terminal.parent_op_id = issued.op_id
|
|
AND coalesce(
|
|
terminal.input ->> 'semantic_operation_type',
|
|
terminal.operation_type
|
|
) IN (
|
|
'ansible_executor_capability_revoked',
|
|
'ansible_executor_capability_expired'
|
|
)
|
|
)
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
claim.incident_id
|
|
),
|
|
"input": json.dumps(
|
|
{
|
|
"automation_run_id": automation_run_id,
|
|
"semantic_operation_type": (
|
|
"ansible_executor_capability_issued"
|
|
),
|
|
"source_candidate_op_id": claim.source_candidate_op_id,
|
|
"check_mode_op_id": claim.op_id,
|
|
"issued_at": issued_at.isoformat(),
|
|
"expires_at": expires_at.isoformat(),
|
|
"ttl_seconds": normalized_ttl_seconds,
|
|
"capability_scope": capability_scope,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"output": json.dumps(
|
|
{
|
|
"issuance_status": "issued",
|
|
"revoke_required": True,
|
|
"raw_credential_exposed_to_requester": False,
|
|
"broker_enforced_allowlist": True,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": claim.op_id,
|
|
"ledger_operation_type": ledger_operation_type,
|
|
"tags": [
|
|
"ansible",
|
|
"execution_broker",
|
|
"capability_issued",
|
|
f"automation_run_id:{automation_run_id}",
|
|
],
|
|
},
|
|
)
|
|
inserted_op_id = inserted.scalar_one_or_none()
|
|
if inserted_op_id is None:
|
|
raise RuntimeError("execution_capability_active_lease_exists")
|
|
capability_op_id = str(inserted_op_id)
|
|
|
|
return (
|
|
replace(
|
|
claim,
|
|
input_payload={
|
|
**claim.input_payload,
|
|
"execution_capability": {
|
|
"capability_op_id": capability_op_id,
|
|
"automation_run_id": automation_run_id,
|
|
"check_mode_op_id": claim.op_id,
|
|
"issued_at": issued_at.isoformat(),
|
|
"expires_at": expires_at.isoformat(),
|
|
"scope": capability_scope,
|
|
},
|
|
},
|
|
),
|
|
capability_op_id,
|
|
)
|
|
|
|
|
|
async def _revoke_ansible_execution_capability(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
capability_op_id: str,
|
|
terminal_status: str,
|
|
project_id: str,
|
|
) -> bool:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
async with get_db_context(project_id) as db:
|
|
ledger_operation_type = await _resolve_execution_capability_operation_type(
|
|
db,
|
|
semantic_operation_type="ansible_executor_capability_revoked",
|
|
)
|
|
result = await db.execute(
|
|
text("""
|
|
INSERT INTO automation_operation_log (
|
|
operation_type, actor, status, incident_id,
|
|
input, output, parent_op_id, tags
|
|
)
|
|
SELECT
|
|
:ledger_operation_type,
|
|
'ansible_execution_broker',
|
|
'success',
|
|
:incident_db_id,
|
|
CAST(:input AS jsonb),
|
|
CAST(:output AS jsonb),
|
|
CAST(:parent_op_id AS uuid),
|
|
:tags
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log existing
|
|
WHERE existing.parent_op_id = CAST(:parent_op_id AS uuid)
|
|
AND coalesce(
|
|
existing.input ->> 'semantic_operation_type',
|
|
existing.operation_type
|
|
) IN (
|
|
'ansible_executor_capability_revoked',
|
|
'ansible_executor_capability_expired'
|
|
)
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
claim.incident_id
|
|
),
|
|
"input": json.dumps(
|
|
{
|
|
"automation_run_id": automation_run_id,
|
|
"semantic_operation_type": (
|
|
"ansible_executor_capability_revoked"
|
|
),
|
|
"capability_op_id": capability_op_id,
|
|
"check_mode_op_id": claim.op_id,
|
|
"terminal_status": terminal_status,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"output": json.dumps(
|
|
{
|
|
"revocation_status": "revoked",
|
|
"raw_credential_persisted_in_receipt": False,
|
|
"further_execution_allowed": False,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": capability_op_id,
|
|
"ledger_operation_type": ledger_operation_type,
|
|
"tags": [
|
|
"ansible",
|
|
"execution_broker",
|
|
"capability_revoked",
|
|
f"automation_run_id:{automation_run_id}",
|
|
],
|
|
},
|
|
)
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
|
|
async def _expire_stale_ansible_execution_capabilities(
|
|
*,
|
|
project_id: str,
|
|
limit: int = 20,
|
|
) -> int:
|
|
"""Close leases left open by a terminated broker process."""
|
|
|
|
async with get_db_context(project_id) as db:
|
|
ledger_operation_type = await _resolve_execution_capability_operation_type(
|
|
db,
|
|
semantic_operation_type="ansible_executor_capability_expired",
|
|
)
|
|
result = await db.execute(
|
|
text("""
|
|
WITH expired AS (
|
|
SELECT issued.op_id, issued.incident_id, issued.input
|
|
FROM automation_operation_log issued
|
|
WHERE coalesce(
|
|
issued.input ->> 'semantic_operation_type',
|
|
issued.operation_type
|
|
) = 'ansible_executor_capability_issued'
|
|
AND issued.status = 'success'
|
|
AND NULLIF(issued.input ->> 'expires_at', '')::timestamptz
|
|
<= NOW()
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log terminal
|
|
WHERE terminal.parent_op_id = issued.op_id
|
|
AND coalesce(
|
|
terminal.input ->> 'semantic_operation_type',
|
|
terminal.operation_type
|
|
) IN (
|
|
'ansible_executor_capability_revoked',
|
|
'ansible_executor_capability_expired'
|
|
)
|
|
)
|
|
ORDER BY issued.created_at ASC
|
|
LIMIT :limit
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
INSERT INTO automation_operation_log (
|
|
operation_type, actor, status, incident_id,
|
|
input, output, parent_op_id, tags
|
|
)
|
|
SELECT
|
|
:ledger_operation_type,
|
|
'ansible_execution_broker',
|
|
'success',
|
|
expired.incident_id,
|
|
jsonb_build_object(
|
|
'automation_run_id', expired.input ->> 'automation_run_id',
|
|
'semantic_operation_type',
|
|
'ansible_executor_capability_expired',
|
|
'capability_op_id', expired.op_id::text,
|
|
'expired_at', NOW()
|
|
),
|
|
jsonb_build_object(
|
|
'expiry_status', 'expired',
|
|
'further_execution_allowed', false
|
|
),
|
|
expired.op_id,
|
|
ARRAY[
|
|
'ansible',
|
|
'execution_broker',
|
|
'capability_expired'
|
|
]::varchar[]
|
|
FROM expired
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"limit": max(1, limit),
|
|
"ledger_operation_type": ledger_operation_type,
|
|
},
|
|
)
|
|
return len(result.scalars().all())
|
|
|
|
|
|
def _runtime_stage_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
stage_id: str,
|
|
evidence_ref: str,
|
|
detail: dict[str, Any],
|
|
derived_from_durable_chain: bool = False,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION,
|
|
"stage_id": stage_id,
|
|
"automation_run_id": _automation_run_id_for_claim(claim),
|
|
"incident_id": claim.incident_id,
|
|
"evidence_ref": evidence_ref,
|
|
"detail": detail,
|
|
"durable_receipt": True,
|
|
"derived_from_durable_chain": derived_from_durable_chain,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
}
|
|
|
|
|
|
def build_ansible_pre_apply_runtime_stage_receipts(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
derived_from_durable_chain: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Build receipts proven before controlled apply from the claimed dry-run."""
|
|
|
|
return [
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="normalized_asset_identity",
|
|
evidence_ref=f"ansible_catalog:{claim.catalog_id}",
|
|
detail={
|
|
"asset_kind": "managed_host",
|
|
"canonical_asset_ids": list(claim.inventory_hosts),
|
|
"catalog_id": claim.catalog_id,
|
|
"playbook_path": claim.apply_playbook_path,
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
),
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="source_truth_diff",
|
|
evidence_ref=(
|
|
"automation_operation_log:"
|
|
f"{claim.op_id}:dry_run_result"
|
|
),
|
|
detail={
|
|
"check_mode_op_id": claim.op_id,
|
|
"check_mode": True,
|
|
"diff": True,
|
|
"check_mode_passed_before_apply": True,
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
),
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="risk_policy_decision",
|
|
evidence_ref=f"ansible_policy:{claim.catalog_id}",
|
|
detail={
|
|
"risk_level": str(claim.risk_level or "").lower(),
|
|
"controlled_apply_allowed": True,
|
|
"break_glass_required": False,
|
|
"policy": "global_product_governance_v2",
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
),
|
|
]
|
|
|
|
|
|
def build_ansible_context_runtime_stage_receipts(
|
|
claim: AnsibleCheckModeClaim,
|
|
evidence: dict[str, Any],
|
|
*,
|
|
derived_from_durable_chain: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Project public-safe pre-decision evidence into the current run."""
|
|
|
|
evidence_id = str(evidence.get("id") or "")
|
|
if not evidence_id:
|
|
return []
|
|
|
|
receipts: list[dict[str, Any]] = []
|
|
mcp_health = _json_loads(evidence.get("mcp_health"))
|
|
sensors_attempted = max(0, _int_from_value(evidence.get("sensors_attempted"), default=0))
|
|
sensors_succeeded = max(0, _int_from_value(evidence.get("sensors_succeeded"), default=0))
|
|
successful_tool_count = sum(
|
|
1 for ready in mcp_health.values() if ready is True
|
|
)
|
|
if sensors_succeeded > 0 and successful_tool_count > 0:
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="mcp_context",
|
|
evidence_ref=f"incident_evidence:{evidence_id}:mcp_health",
|
|
detail={
|
|
"evidence_id": evidence_id,
|
|
"tool_status_count": len(mcp_health),
|
|
"successful_tool_count": successful_tool_count,
|
|
"sensors_attempted": sensors_attempted,
|
|
"sensors_succeeded": sensors_succeeded,
|
|
"context_collected_before_candidate": True,
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
)
|
|
|
|
recent_logs = evidence.get("recent_logs")
|
|
if isinstance(recent_logs, str) and recent_logs.strip():
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="service_log_evidence",
|
|
evidence_ref=f"incident_evidence:{evidence_id}:recent_logs",
|
|
detail={
|
|
"evidence_id": evidence_id,
|
|
"sanitized_character_count": len(recent_logs),
|
|
"content_in_receipt": False,
|
|
"redaction_contract": "incident_evidence.recent_logs_sanitized",
|
|
"context_collected_before_candidate": True,
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
)
|
|
return receipts
|
|
|
|
|
|
def build_ansible_post_apply_runtime_stage_receipts(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
verifier_ready: bool,
|
|
derived_from_durable_chain: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
receipts = [
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="executor_log_projection",
|
|
evidence_ref=f"automation_operation_log:{apply_op_id}:output",
|
|
detail={
|
|
"apply_op_id": apply_op_id,
|
|
"returncode": result.returncode,
|
|
"timed_out": result.timed_out,
|
|
"duration_ms": result.duration_ms,
|
|
"projection_sanitized": True,
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
]
|
|
if result.returncode == 0 and verifier_ready:
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="retry_or_rollback",
|
|
evidence_ref=f"incident_evidence:{apply_op_id}:post_execution_state",
|
|
detail={
|
|
"apply_op_id": apply_op_id,
|
|
"terminal_type": "verified_success_no_retry_or_rollback_required",
|
|
"bounded_retry_performed": False,
|
|
"rollback_performed": False,
|
|
"post_apply_verifier_passed": True,
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
)
|
|
return receipts
|
|
|
|
|
|
def build_ansible_timeline_runtime_stage_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
timeline_event_id: str,
|
|
derived_from_durable_chain: bool = False,
|
|
) -> dict[str, Any]:
|
|
verified_success = bool(
|
|
result.returncode == 0 and result.post_verifier_passed is True
|
|
)
|
|
return _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="timeline_projection",
|
|
evidence_ref=f"timeline_events:{timeline_event_id}",
|
|
detail={
|
|
"timeline_event_id": timeline_event_id,
|
|
"apply_op_id": apply_op_id,
|
|
"returncode": result.returncode,
|
|
"post_verifier_passed": result.post_verifier_passed is True,
|
|
"projection_status": "success" if verified_success else "error",
|
|
},
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
|
|
|
|
async def _load_pre_decision_context_runtime_stage_receipts(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
project_id: str,
|
|
derived_from_durable_chain: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Load only evidence collected before the root candidate was created."""
|
|
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
evidence.id,
|
|
evidence.recent_logs,
|
|
evidence.mcp_health,
|
|
evidence.sensors_attempted,
|
|
evidence.sensors_succeeded,
|
|
evidence.collected_at
|
|
FROM incident_evidence evidence
|
|
JOIN automation_operation_log candidate
|
|
ON candidate.op_id = CAST(:candidate_op_id AS uuid)
|
|
WHERE evidence.incident_id = CAST(:incident_id AS varchar(30))
|
|
AND evidence.collected_at <= candidate.created_at
|
|
AND (
|
|
evidence.post_execution_state IS NULL
|
|
OR CAST(evidence.post_execution_state AS jsonb)
|
|
= 'null'::jsonb
|
|
)
|
|
AND (
|
|
NULLIF(evidence.recent_logs, '') IS NOT NULL
|
|
OR evidence.mcp_health IS NOT NULL
|
|
)
|
|
ORDER BY evidence.collected_at DESC
|
|
LIMIT 10
|
|
"""),
|
|
{
|
|
"candidate_op_id": claim.source_candidate_op_id,
|
|
"incident_id": claim.incident_id,
|
|
},
|
|
)
|
|
evidence_rows = [dict(row) for row in result.mappings().all()]
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_pre_decision_context_receipt_read_failed",
|
|
incident_id=claim.incident_id,
|
|
candidate_op_id=claim.source_candidate_op_id,
|
|
error=str(exc),
|
|
)
|
|
return []
|
|
|
|
by_stage: dict[str, dict[str, Any]] = {}
|
|
for evidence in evidence_rows:
|
|
for receipt in build_ansible_context_runtime_stage_receipts(
|
|
claim,
|
|
evidence,
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
):
|
|
by_stage.setdefault(str(receipt["stage_id"]), receipt)
|
|
return list(by_stage.values())
|
|
|
|
|
|
async def _record_timeline_projection_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
derived_from_durable_chain: bool = False,
|
|
) -> dict[str, Any] | None:
|
|
"""Persist one idempotent operator timeline event and return its receipt."""
|
|
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
verified_success = bool(
|
|
result.returncode == 0 and result.post_verifier_passed is True
|
|
)
|
|
description = (
|
|
f"automation_run_id={automation_run_id};apply_op_id={apply_op_id};"
|
|
f"catalog_id={claim.catalog_id};returncode={result.returncode};"
|
|
f"post_verifier_passed={str(result.post_verifier_passed is True).lower()}"
|
|
)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
row = await db.execute(
|
|
text("""
|
|
WITH existing AS (
|
|
SELECT id
|
|
FROM timeline_events
|
|
WHERE incident_id = :incident_id
|
|
AND actor = 'ansible_controlled_apply_worker'
|
|
AND description = :description
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
), inserted AS (
|
|
INSERT INTO timeline_events (
|
|
id, incident_id, event_type, status, title, description,
|
|
actor, actor_role, risk_level, created_at
|
|
)
|
|
SELECT
|
|
gen_random_uuid()::text,
|
|
:incident_id,
|
|
'exec',
|
|
:status,
|
|
:title,
|
|
:description,
|
|
'ansible_controlled_apply_worker',
|
|
'ai_agent',
|
|
:risk_level,
|
|
NOW()
|
|
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM inserted
|
|
UNION ALL
|
|
SELECT id FROM existing
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"status": "success" if verified_success else "error",
|
|
"title": f"AI controlled apply: {claim.catalog_id}"[:500],
|
|
"description": description,
|
|
"risk_level": str(claim.risk_level or "")[:20] or None,
|
|
},
|
|
)
|
|
timeline_event_id = str(row.scalar() or "")
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_timeline_projection_write_failed",
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
error=str(exc),
|
|
)
|
|
return None
|
|
|
|
if not timeline_event_id:
|
|
return None
|
|
return build_ansible_timeline_runtime_stage_receipt(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
timeline_event_id=timeline_event_id,
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
|
|
|
|
async def _record_runtime_stage_receipts(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
verifier_ready: bool,
|
|
project_id: str,
|
|
derived_from_durable_chain: bool = False,
|
|
extra_receipts: tuple[dict[str, Any], ...] = (),
|
|
) -> bool:
|
|
context_receipts = await _load_pre_decision_context_runtime_stage_receipts(
|
|
claim,
|
|
project_id=project_id,
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
)
|
|
receipts = [
|
|
*context_receipts,
|
|
*build_ansible_pre_apply_runtime_stage_receipts(
|
|
claim,
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
),
|
|
*build_ansible_post_apply_runtime_stage_receipts(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
verifier_ready=verifier_ready,
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
),
|
|
*extra_receipts,
|
|
]
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
updated = await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log AS apply
|
|
SET input = jsonb_set(
|
|
coalesce(apply.input, '{}'::jsonb),
|
|
'{runtime_stage_receipts}',
|
|
(
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
deduplicated.receipt
|
|
ORDER BY deduplicated.stage_id
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT DISTINCT ON (
|
|
merged.receipt ->> 'stage_id'
|
|
)
|
|
merged.receipt,
|
|
merged.receipt ->> 'stage_id' AS stage_id
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
) || CAST(:receipts AS jsonb)
|
|
) WITH ORDINALITY AS merged(receipt, position)
|
|
WHERE merged.receipt ->> 'stage_id' IS NOT NULL
|
|
ORDER BY
|
|
merged.receipt ->> 'stage_id',
|
|
merged.position DESC
|
|
) deduplicated
|
|
),
|
|
true
|
|
)
|
|
WHERE apply.op_id = CAST(:apply_op_id AS uuid)
|
|
AND apply.operation_type = 'ansible_apply_executed'
|
|
"""),
|
|
{
|
|
"receipts": json.dumps(receipts, ensure_ascii=False),
|
|
"apply_op_id": apply_op_id,
|
|
},
|
|
)
|
|
return bool(updated.rowcount)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_runtime_stage_receipt_write_failed",
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
error=str(exc),
|
|
)
|
|
return False
|
|
|
|
|
|
async def _append_runtime_stage_receipts_to_apply(
|
|
*,
|
|
apply_op_id: str,
|
|
receipts: tuple[dict[str, Any], ...],
|
|
project_id: str,
|
|
) -> bool:
|
|
return await _append_runtime_stage_receipts_to_operation(
|
|
operation_id=apply_op_id,
|
|
operation_type="ansible_apply_executed",
|
|
receipts=receipts,
|
|
project_id=project_id,
|
|
)
|
|
|
|
|
|
async def _append_runtime_stage_receipts_to_operation(
|
|
*,
|
|
operation_id: str,
|
|
operation_type: str,
|
|
receipts: tuple[dict[str, Any], ...],
|
|
project_id: str,
|
|
) -> bool:
|
|
if not receipts:
|
|
return False
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
updated = await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log apply
|
|
SET input = jsonb_set(
|
|
coalesce(apply.input, '{}'::jsonb),
|
|
'{runtime_stage_receipts}',
|
|
coalesce(
|
|
(
|
|
SELECT jsonb_agg(
|
|
deduplicated.receipt
|
|
ORDER BY deduplicated.stage_id
|
|
)
|
|
FROM (
|
|
SELECT DISTINCT ON (
|
|
merged.receipt ->> 'stage_id'
|
|
)
|
|
merged.receipt,
|
|
merged.receipt ->> 'stage_id' AS stage_id
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
) || CAST(:receipts AS jsonb)
|
|
) WITH ORDINALITY AS merged(receipt, position)
|
|
WHERE merged.receipt ->> 'stage_id' IS NOT NULL
|
|
ORDER BY
|
|
merged.receipt ->> 'stage_id',
|
|
merged.position DESC
|
|
) deduplicated
|
|
),
|
|
'[]'::jsonb
|
|
),
|
|
true
|
|
)
|
|
WHERE apply.op_id = CAST(:operation_id AS uuid)
|
|
AND apply.operation_type = :operation_type
|
|
"""),
|
|
{
|
|
"receipts": json.dumps(receipts, ensure_ascii=False),
|
|
"operation_id": operation_id,
|
|
"operation_type": operation_type,
|
|
},
|
|
)
|
|
return bool(updated.rowcount)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_runtime_terminal_receipt_append_failed",
|
|
apply_op_id=operation_id,
|
|
operation_type=operation_type,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return False
|
|
|
|
|
|
def _runtime_stage_ids(value: Any) -> set[str]:
|
|
payload = _json_loads(value)
|
|
receipts = payload.get("runtime_stage_receipts")
|
|
if not isinstance(receipts, list):
|
|
return set()
|
|
return {
|
|
str(receipt.get("stage_id") or "")
|
|
for receipt in receipts
|
|
if isinstance(receipt, Mapping) and receipt.get("durable_receipt") is True
|
|
}
|
|
|
|
|
|
async def _read_verified_apply_closure_prerequisites(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Read back every durable prerequisite before resolving an incident."""
|
|
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
approval_id = _approval_id_for_claim(claim)
|
|
canonical_playbook_id = canonical_ansible_playbook_id(claim.catalog_id)
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
apply.status = 'success' AS apply_success,
|
|
apply.input AS apply_input,
|
|
coalesce(
|
|
apply.output ->> 'independent_post_verifier_passed',
|
|
'false'
|
|
) = 'true' AS apply_verifier_terminal,
|
|
check_mode.status = 'success' AS check_mode_success,
|
|
candidate.op_id IS NOT NULL AS candidate_present,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM auto_repair_executions receipt
|
|
WHERE receipt.incident_id = :incident_id
|
|
AND receipt.triggered_by = 'ansible_controlled_apply'
|
|
AND receipt.success IS TRUE
|
|
AND receipt.executed_steps::text LIKE
|
|
'%' || :apply_op_id || '%'
|
|
) AS auto_repair_receipt,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM incident_evidence evidence
|
|
WHERE evidence.incident_id = :incident_id
|
|
AND evidence.post_execution_state ->> 'apply_op_id'
|
|
= :apply_op_id
|
|
AND evidence.verification_result = 'success'
|
|
) AS post_verifier_receipt,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM knowledge_entries km
|
|
WHERE km.related_incident_id = :incident_id
|
|
AND km.path_type = :km_path_type
|
|
AND km.related_playbook_id = :canonical_playbook_id
|
|
AND km.updated_at IS NOT NULL
|
|
) AS km_writeback_receipt,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log learning
|
|
WHERE learning.operation_type
|
|
= 'ansible_learning_writeback_recorded'
|
|
AND learning.parent_op_id = apply.op_id
|
|
AND learning.status = 'success'
|
|
AND learning.output ->> 'learning_recorded' = 'true'
|
|
AND learning.output ->> 'trust_updated' = 'true'
|
|
) AS playbook_trust_receipt,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM playbooks playbook
|
|
WHERE playbook.playbook_id = :canonical_playbook_id
|
|
AND playbook.success_count > 0
|
|
AND playbook.last_used_at IS NOT NULL
|
|
) AS playbook_readback,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM timeline_events timeline
|
|
WHERE timeline.incident_id = :incident_id
|
|
AND timeline.actor = 'ansible_controlled_apply_worker'
|
|
AND timeline.status = 'success'
|
|
AND timeline.description LIKE
|
|
'%apply_op_id=' || :apply_op_id || ';%'
|
|
) AS timeline_readback,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM awooop_outbound_message outbound
|
|
WHERE outbound.project_id = :project_id
|
|
AND outbound.channel_type = 'telegram'
|
|
AND outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id IS NOT NULL
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND outbound.source_envelope
|
|
#>> '{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND coalesce(
|
|
outbound.source_envelope
|
|
->> 'automation_run_id',
|
|
outbound.source_envelope
|
|
#>> '{callback_reply,automation_run_id}'
|
|
) = :automation_run_id
|
|
AND coalesce(
|
|
outbound.source_envelope
|
|
#>> '{callback_reply,incident_id}',
|
|
outbound.source_envelope
|
|
#>> '{source_refs,incident_ids,0}'
|
|
) = :incident_id
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,apply_op_id}' = :apply_op_id
|
|
) AS telegram_receipt,
|
|
(
|
|
:approval_id = ''
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM approval_records approval
|
|
WHERE approval.id = :approval_id
|
|
AND lower(approval.status::text)
|
|
= 'execution_success'
|
|
AND approval.extra_metadata ->> 'apply_op_id'
|
|
= :apply_op_id
|
|
AND approval.extra_metadata
|
|
->> 'post_verifier_passed' = 'true'
|
|
)
|
|
) AS approval_projection,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text
|
|
= 'EXECUTION_COMPLETED'
|
|
AND lifecycle.success IS TRUE
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= :apply_op_id
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
) AS execution_lifecycle,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text
|
|
= 'TELEGRAM_RESULT_SENT'
|
|
AND lifecycle.success IS TRUE
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= :apply_op_id
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
) AS telegram_lifecycle
|
|
FROM automation_operation_log apply
|
|
LEFT JOIN automation_operation_log check_mode
|
|
ON check_mode.op_id = apply.parent_op_id
|
|
AND check_mode.operation_type = 'ansible_check_mode_executed'
|
|
LEFT JOIN automation_operation_log candidate
|
|
ON candidate.op_id = check_mode.parent_op_id
|
|
AND candidate.operation_type = 'ansible_candidate_matched'
|
|
WHERE apply.op_id = CAST(:apply_op_id_uuid AS uuid)
|
|
AND apply.operation_type = 'ansible_apply_executed'
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"apply_op_id": apply_op_id,
|
|
"apply_op_id_uuid": apply_op_id,
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
"automation_run_id": automation_run_id,
|
|
"approval_id": approval_id,
|
|
"canonical_playbook_id": canonical_playbook_id,
|
|
"km_path_type": f"ansible_apply_receipt:{apply_op_id[:8]}",
|
|
},
|
|
)
|
|
row = result.mappings().one_or_none()
|
|
|
|
if row is None:
|
|
return {
|
|
"ready": False,
|
|
"missing": ["apply_operation_readback"],
|
|
"receipts": {},
|
|
"stage_ids": [],
|
|
}
|
|
|
|
stage_ids = _runtime_stage_ids(row.get("apply_input"))
|
|
receipts = {
|
|
"candidate": row.get("candidate_present") is True,
|
|
"check_mode": row.get("check_mode_success") is True,
|
|
"controlled_apply": row.get("apply_success") is True,
|
|
"post_apply_verifier": (
|
|
row.get("apply_verifier_terminal") is True
|
|
and row.get("post_verifier_receipt") is True
|
|
),
|
|
"auto_repair_execution_receipt": row.get("auto_repair_receipt") is True,
|
|
"km_playbook_writeback": row.get("km_writeback_receipt") is True,
|
|
"rag_writeback": "rag_writeback" in stage_ids,
|
|
"playbook_trust": (
|
|
row.get("playbook_trust_receipt") is True
|
|
and row.get("playbook_readback") is True
|
|
),
|
|
"timeline_projection": (
|
|
"timeline_projection" in stage_ids
|
|
and row.get("timeline_readback") is True
|
|
),
|
|
"telegram_receipt": row.get("telegram_receipt") is True,
|
|
"approval_projection": row.get("approval_projection") is True,
|
|
"execution_lifecycle": row.get("execution_lifecycle") is True,
|
|
"telegram_lifecycle": row.get("telegram_lifecycle") is True,
|
|
"required_runtime_stage_receipts": (
|
|
_VERIFIED_CLOSURE_REQUIRED_STAGE_IDS.issubset(stage_ids)
|
|
),
|
|
}
|
|
missing = sorted(name for name, ready in receipts.items() if not ready)
|
|
return {
|
|
"ready": not missing,
|
|
"missing": missing,
|
|
"receipts": receipts,
|
|
"stage_ids": sorted(stage_ids),
|
|
"required_stage_ids": sorted(_VERIFIED_CLOSURE_REQUIRED_STAGE_IDS),
|
|
"automation_run_id": automation_run_id,
|
|
"canonical_playbook_id": canonical_playbook_id,
|
|
}
|
|
|
|
|
|
async def _project_incident_terminal_to_working_memory(
|
|
*,
|
|
incident_id: str,
|
|
project_id: str,
|
|
incident_status: str,
|
|
updated_at: datetime,
|
|
resolved_at: datetime | None,
|
|
) -> str:
|
|
from src.models.incident import IncidentStatus
|
|
from src.services.incident_service import get_incident_service
|
|
|
|
normalized_status = str(incident_status or "").strip().lower()
|
|
if normalized_status not in {
|
|
IncidentStatus.RESOLVED.value,
|
|
IncidentStatus.CLOSED.value,
|
|
}:
|
|
return "failed"
|
|
return await get_incident_service().project_terminal_status_to_working_memory(
|
|
incident_id,
|
|
project_id=project_id,
|
|
status=IncidentStatus(normalized_status),
|
|
updated_at=updated_at,
|
|
resolved_at=resolved_at,
|
|
)
|
|
|
|
|
|
async def reconcile_verified_incident_working_memory_once(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
window_hours: int = _WORKING_MEMORY_PROJECTION_WINDOW_HOURS,
|
|
limit: int = 10,
|
|
) -> dict[str, Any]:
|
|
"""Project pending terminal DB rows without starving older Redis repairs."""
|
|
|
|
stats: dict[str, Any] = {
|
|
"scanned": 0,
|
|
"updated": 0,
|
|
"current": 0,
|
|
"missing": 0,
|
|
"failed": 0,
|
|
"error": None,
|
|
}
|
|
try:
|
|
cursor = _WORKING_MEMORY_PROJECTION_CURSOR.get(project_id)
|
|
statement = text("""
|
|
SELECT
|
|
incident.incident_id,
|
|
incident.status::text AS incident_status,
|
|
incident.updated_at,
|
|
incident.resolved_at
|
|
FROM incidents incident
|
|
WHERE incident.project_id = :project_id
|
|
AND upper(incident.status::text) IN ('RESOLVED', 'CLOSED')
|
|
AND incident.resolved_at IS NOT NULL
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,incident_resolved}' = 'true'
|
|
AND incident.updated_at >=
|
|
NOW() - (:window_hours * INTERVAL '1 hour')
|
|
AND (
|
|
coalesce(
|
|
cast(incident.outcome AS jsonb) #>>
|
|
'{working_memory_projection,ready}',
|
|
'false'
|
|
) <> 'true'
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{working_memory_projection,incident_status}'
|
|
IS DISTINCT FROM incident.status::text
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{working_memory_projection,source_updated_at_epoch_us}'
|
|
IS DISTINCT FROM CAST(
|
|
CAST(
|
|
round(
|
|
extract(epoch FROM incident.updated_at)
|
|
* 1000000
|
|
) AS bigint
|
|
) AS text
|
|
)
|
|
)
|
|
AND (
|
|
CAST(:cursor_updated_at AS timestamptz) IS NULL
|
|
OR incident.updated_at >
|
|
CAST(:cursor_updated_at AS timestamptz)
|
|
OR (
|
|
incident.updated_at =
|
|
CAST(:cursor_updated_at AS timestamptz)
|
|
AND incident.incident_id > :cursor_incident_id
|
|
)
|
|
)
|
|
ORDER BY incident.updated_at ASC, incident.incident_id ASC
|
|
LIMIT :limit
|
|
""")
|
|
|
|
def query_params(
|
|
active_cursor: tuple[datetime, str] | None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"project_id": project_id,
|
|
"window_hours": max(1, int(window_hours)),
|
|
"limit": max(1, int(limit)),
|
|
"cursor_updated_at": active_cursor[0] if active_cursor else None,
|
|
"cursor_incident_id": active_cursor[1] if active_cursor else "",
|
|
}
|
|
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(statement, query_params(cursor))
|
|
rows = [dict(row) for row in result.mappings().all()]
|
|
if not rows and cursor is not None:
|
|
cursor = None
|
|
result = await db.execute(statement, query_params(None))
|
|
rows = [dict(row) for row in result.mappings().all()]
|
|
|
|
if rows:
|
|
last = rows[-1]
|
|
_WORKING_MEMORY_PROJECTION_CURSOR[project_id] = (
|
|
last["updated_at"],
|
|
str(last.get("incident_id") or ""),
|
|
)
|
|
else:
|
|
_WORKING_MEMORY_PROJECTION_CURSOR.pop(project_id, None)
|
|
|
|
stats["scanned"] = len(rows)
|
|
for row in rows:
|
|
try:
|
|
projection_status = (
|
|
await _project_incident_terminal_to_working_memory(
|
|
incident_id=str(row.get("incident_id") or ""),
|
|
project_id=project_id,
|
|
incident_status=str(row.get("incident_status") or ""),
|
|
updated_at=row["updated_at"],
|
|
resolved_at=row.get("resolved_at"),
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
stats["failed"] += 1
|
|
logger.warning(
|
|
"ansible_incident_working_memory_projection_failed",
|
|
project_id=project_id,
|
|
incident_id=str(row.get("incident_id") or ""),
|
|
error_type=type(exc).__name__,
|
|
)
|
|
continue
|
|
if projection_status in stats:
|
|
stats[projection_status] += 1
|
|
else:
|
|
stats["failed"] += 1
|
|
except Exception as exc:
|
|
stats["error"] = f"{type(exc).__name__}: {exc}"[:500]
|
|
logger.warning(
|
|
"ansible_incident_working_memory_reconcile_failed",
|
|
project_id=project_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return stats
|
|
|
|
|
|
async def _read_incident_closure_readback(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
) -> dict[str, Any]:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
incident.status::text AS incident_status,
|
|
incident.resolved_at,
|
|
incident.outcome,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' = 'incident_closure'
|
|
AND receipt.value ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND receipt.value ->> 'durable_receipt' = 'true'
|
|
) AS closure_receipt,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text = 'RESOLVED'
|
|
AND lifecycle.success IS TRUE
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= :apply_op_id
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
) AS resolved_lifecycle
|
|
FROM incidents incident
|
|
JOIN automation_operation_log apply
|
|
ON apply.op_id = CAST(:apply_op_id_uuid AS uuid)
|
|
WHERE incident.incident_id = :incident_id
|
|
AND incident.project_id = :project_id
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
"apply_op_id": apply_op_id,
|
|
"apply_op_id_uuid": apply_op_id,
|
|
"automation_run_id": automation_run_id,
|
|
},
|
|
)
|
|
row = result.mappings().one_or_none()
|
|
|
|
if row is None:
|
|
return {"closed": False, "missing": ["incident_readback"]}
|
|
outcome = _json_loads(row.get("outcome"))
|
|
terminal = outcome.get("automation_terminal")
|
|
terminal_ready = bool(
|
|
isinstance(terminal, Mapping)
|
|
and terminal.get("automation_run_id") == automation_run_id
|
|
and terminal.get("apply_op_id") == apply_op_id
|
|
and terminal.get("incident_resolved") is True
|
|
)
|
|
checks = {
|
|
"incident_status": str(row.get("incident_status") or "").upper()
|
|
in {"RESOLVED", "CLOSED"},
|
|
"incident_resolved_at": row.get("resolved_at") is not None,
|
|
"incident_terminal_readback": terminal_ready,
|
|
"incident_closure_receipt": row.get("closure_receipt") is True,
|
|
"resolved_lifecycle": row.get("resolved_lifecycle") is True,
|
|
}
|
|
missing = sorted(name for name, ready in checks.items() if not ready)
|
|
return {
|
|
"closed": not missing,
|
|
"missing": missing,
|
|
"checks": checks,
|
|
"incident_status": str(row.get("incident_status") or ""),
|
|
"automation_run_id": automation_run_id,
|
|
"apply_op_id": apply_op_id,
|
|
}
|
|
|
|
|
|
def _incident_terminal_disposition_payload(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
terminal_type: str,
|
|
success_terminal: bool,
|
|
retry_op_id: str | None,
|
|
telegram_receipt_acknowledged: bool,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": "ansible_incident_terminal_disposition_v1",
|
|
"automation_run_id": _automation_run_id_for_claim(claim),
|
|
"apply_op_id": apply_op_id,
|
|
"retry_op_id": retry_op_id,
|
|
"terminal_type": terminal_type,
|
|
"incident_status": "RESOLVED" if success_terminal else "MITIGATING",
|
|
"incident_resolved": success_terminal,
|
|
"no_write_terminal": not success_terminal,
|
|
"safe_next_action": (
|
|
"keep_verified_repair_and_monitor_recurrence"
|
|
if success_terminal
|
|
else "queue_ai_playbook_or_transport_repair_candidate"
|
|
),
|
|
"telegram_receipt_acknowledged": telegram_receipt_acknowledged,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
}
|
|
|
|
|
|
async def _commit_verified_incident_closure(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
closure_prerequisite_count: int,
|
|
) -> dict[str, Any] | None:
|
|
"""Atomically persist the success terminal, receipt, and lifecycle row."""
|
|
|
|
terminal = {
|
|
**_incident_terminal_disposition_payload(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
terminal_type="controlled_apply_verified_closed",
|
|
success_terminal=True,
|
|
retry_op_id=None,
|
|
telegram_receipt_acknowledged=True,
|
|
),
|
|
"incident_id": claim.incident_id,
|
|
"proposal_executed": True,
|
|
"execution_success": True,
|
|
"repository_write_acknowledged": True,
|
|
"repository_readback_verified": True,
|
|
"durable_write_acknowledged": True,
|
|
"writer_source_sha": os.getenv(
|
|
"AWOOOI_BUILD_COMMIT_SHA",
|
|
"",
|
|
).strip().lower()
|
|
or None,
|
|
}
|
|
closure_receipt = _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="incident_closure",
|
|
evidence_ref=f"incidents:{claim.incident_id}:automation_terminal",
|
|
detail={
|
|
**terminal,
|
|
"closure_prerequisite_count": closure_prerequisite_count,
|
|
"closure_prerequisites_verified": True,
|
|
},
|
|
)
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
lifecycle_context = {
|
|
"automation_run_id": automation_run_id,
|
|
"catalog_id": claim.catalog_id,
|
|
"check_mode_op_id": claim.op_id,
|
|
"apply_op_id": apply_op_id,
|
|
"single_writer_executor": "awoooi-ansible-executor-broker",
|
|
"post_verifier_passed": True,
|
|
}
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
await db.execute(
|
|
text("""
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended(:closure_lock_key, 0)
|
|
)
|
|
"""),
|
|
{"closure_lock_key": f"ansible-closure:{apply_op_id}"},
|
|
)
|
|
committed = await db.execute(
|
|
text("""
|
|
WITH target_apply AS (
|
|
SELECT apply.op_id
|
|
FROM automation_operation_log apply
|
|
WHERE apply.op_id = CAST(:apply_op_id_uuid AS uuid)
|
|
AND apply.operation_type = 'ansible_apply_executed'
|
|
AND apply.status = 'success'
|
|
AND coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
) = :incident_id
|
|
AND apply.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND coalesce(
|
|
apply.output
|
|
->> 'independent_post_verifier_passed',
|
|
'false'
|
|
) = 'true'
|
|
FOR UPDATE
|
|
),
|
|
lifecycle_existing AS (
|
|
SELECT lifecycle.id
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text = 'RESOLVED'
|
|
AND lifecycle.success IS TRUE
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= :apply_op_id
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
),
|
|
lifecycle_inserted AS (
|
|
INSERT INTO alert_operation_log (
|
|
incident_id,
|
|
approval_id,
|
|
event_type,
|
|
actor,
|
|
action_detail,
|
|
success,
|
|
context
|
|
)
|
|
SELECT
|
|
CAST(:incident_id AS varchar(30)),
|
|
CAST(:approval_id AS varchar(36)),
|
|
'RESOLVED'::alert_event_type,
|
|
'awoooi-ansible-executor-broker',
|
|
'controlled_apply_verified_closed',
|
|
true,
|
|
CAST(:lifecycle_context AS jsonb)
|
|
FROM target_apply
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM lifecycle_existing
|
|
)
|
|
RETURNING id
|
|
),
|
|
lifecycle_ready AS (
|
|
SELECT id FROM lifecycle_existing
|
|
UNION ALL
|
|
SELECT id FROM lifecycle_inserted
|
|
),
|
|
apply_updated AS (
|
|
UPDATE automation_operation_log apply
|
|
SET input = jsonb_set(
|
|
coalesce(apply.input, '{}'::jsonb),
|
|
'{runtime_stage_receipts}',
|
|
coalesce(
|
|
(
|
|
SELECT jsonb_agg(
|
|
deduplicated.receipt
|
|
ORDER BY deduplicated.stage_id
|
|
)
|
|
FROM (
|
|
SELECT DISTINCT ON (
|
|
merged.receipt ->> 'stage_id'
|
|
)
|
|
merged.receipt,
|
|
merged.receipt ->> 'stage_id'
|
|
AS stage_id
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input
|
|
-> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
) || CAST(:receipts AS jsonb)
|
|
) WITH ORDINALITY
|
|
AS merged(receipt, position)
|
|
WHERE merged.receipt ->> 'stage_id'
|
|
IS NOT NULL
|
|
ORDER BY
|
|
merged.receipt ->> 'stage_id',
|
|
merged.position DESC
|
|
) deduplicated
|
|
),
|
|
'[]'::jsonb
|
|
),
|
|
true
|
|
)
|
|
FROM target_apply
|
|
WHERE apply.op_id = target_apply.op_id
|
|
AND EXISTS (SELECT 1 FROM lifecycle_ready)
|
|
RETURNING apply.op_id
|
|
),
|
|
incident_updated AS (
|
|
UPDATE incidents incident
|
|
SET status = CASE
|
|
WHEN upper(incident.status::text) = 'CLOSED'
|
|
THEN incident.status
|
|
ELSE 'RESOLVED'::incidentstatus
|
|
END,
|
|
outcome = CAST(
|
|
coalesce(
|
|
CASE
|
|
WHEN incident.outcome IS NULL
|
|
THEN '{}'::jsonb
|
|
WHEN jsonb_typeof(
|
|
CAST(incident.outcome AS jsonb)
|
|
) = 'object'
|
|
THEN CAST(incident.outcome AS jsonb)
|
|
ELSE jsonb_build_object(
|
|
'legacy_outcome',
|
|
CAST(incident.outcome AS jsonb)
|
|
)
|
|
END,
|
|
'{}'::jsonb
|
|
)
|
|
|| jsonb_build_object(
|
|
'automation_terminal',
|
|
CAST(:terminal_payload AS jsonb),
|
|
'proposal_executed',
|
|
true,
|
|
'execution_success',
|
|
true
|
|
)
|
|
AS json
|
|
),
|
|
resolved_at = coalesce(incident.resolved_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE incident.incident_id = :incident_id
|
|
AND incident.project_id = :project_id
|
|
AND EXISTS (SELECT 1 FROM apply_updated)
|
|
AND (
|
|
upper(incident.status::text)
|
|
NOT IN ('RESOLVED', 'CLOSED')
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,automation_run_id}'
|
|
= :automation_run_id
|
|
)
|
|
RETURNING
|
|
incident.status::text AS incident_status,
|
|
incident.updated_at,
|
|
incident.resolved_at,
|
|
incident.outcome
|
|
)
|
|
SELECT
|
|
incident_status,
|
|
updated_at,
|
|
resolved_at,
|
|
outcome,
|
|
EXISTS (SELECT 1 FROM apply_updated)
|
|
AS closure_receipt_written,
|
|
EXISTS (SELECT 1 FROM lifecycle_ready)
|
|
AS resolved_lifecycle_written
|
|
FROM incident_updated
|
|
"""),
|
|
{
|
|
"apply_op_id": apply_op_id,
|
|
"apply_op_id_uuid": apply_op_id,
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
"automation_run_id": automation_run_id,
|
|
"approval_id": _approval_id_for_claim(claim) or None,
|
|
"lifecycle_context": json.dumps(
|
|
lifecycle_context,
|
|
ensure_ascii=False,
|
|
),
|
|
"receipts": json.dumps(
|
|
[closure_receipt],
|
|
ensure_ascii=False,
|
|
),
|
|
"terminal_payload": json.dumps(
|
|
terminal,
|
|
ensure_ascii=False,
|
|
),
|
|
},
|
|
)
|
|
row = committed.mappings().one_or_none()
|
|
if row is None:
|
|
raise RuntimeError("verified_incident_closure_atomic_write_pending")
|
|
outcome = _json_loads(row.get("outcome"))
|
|
readback = outcome.get("automation_terminal")
|
|
if not (
|
|
isinstance(readback, Mapping)
|
|
and readback.get("automation_run_id") == automation_run_id
|
|
and readback.get("apply_op_id") == apply_op_id
|
|
and outcome.get("proposal_executed") is True
|
|
and outcome.get("execution_success") is True
|
|
and row.get("closure_receipt_written") is True
|
|
and row.get("resolved_lifecycle_written") is True
|
|
and row.get("resolved_at") is not None
|
|
):
|
|
raise RuntimeError("verified_incident_closure_atomic_readback_failed")
|
|
closure_result = {
|
|
**terminal,
|
|
"incident_status": str(row.get("incident_status") or ""),
|
|
"incident_row_version": (
|
|
row["updated_at"].isoformat()
|
|
if row.get("updated_at") is not None
|
|
else None
|
|
),
|
|
"closure_receipt_written": True,
|
|
"resolved_lifecycle_written": True,
|
|
}
|
|
try:
|
|
projection_status = await _project_incident_terminal_to_working_memory(
|
|
incident_id=claim.incident_id,
|
|
project_id=project_id,
|
|
incident_status=closure_result["incident_status"],
|
|
updated_at=row["updated_at"],
|
|
resolved_at=row.get("resolved_at"),
|
|
)
|
|
except Exception as projection_exc:
|
|
projection_status = "failed"
|
|
logger.warning(
|
|
"ansible_verified_incident_closure_projection_failed",
|
|
automation_run_id=automation_run_id,
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
error_type=type(projection_exc).__name__,
|
|
durable_closure_preserved=True,
|
|
)
|
|
closure_result["working_memory_projection_status"] = projection_status
|
|
closure_result["working_memory_projection_ready"] = (
|
|
projection_status in {"updated", "current"}
|
|
)
|
|
if closure_result["working_memory_projection_ready"] is not True:
|
|
logger.warning(
|
|
"ansible_verified_incident_closure_working_memory_pending",
|
|
automation_run_id=automation_run_id,
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
projection_status=projection_status,
|
|
)
|
|
return closure_result
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_verified_incident_closure_atomic_write_failed",
|
|
automation_run_id=automation_run_id,
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return None
|
|
|
|
|
|
async def _finalize_verified_apply_closure(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
) -> dict[str, Any]:
|
|
prerequisites = await _read_verified_apply_closure_prerequisites(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
if prerequisites.get("ready") is not True:
|
|
return {
|
|
"status": "closure_receipts_pending",
|
|
"closed": False,
|
|
"missing": list(prerequisites.get("missing") or []),
|
|
}
|
|
|
|
terminal = await _commit_verified_incident_closure(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
closure_prerequisite_count=len(prerequisites["receipts"]),
|
|
)
|
|
if terminal is None:
|
|
return {
|
|
"status": "atomic_closure_write_pending",
|
|
"closed": False,
|
|
"missing": [
|
|
"incident_terminal_readback",
|
|
"incident_closure_receipt",
|
|
"resolved_lifecycle",
|
|
],
|
|
}
|
|
readback = await _read_incident_closure_readback(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
projection_ready = terminal.get("working_memory_projection_ready") is True
|
|
readback_closed = readback.get("closed") is True
|
|
missing = list(readback.get("missing") or [])
|
|
if not projection_ready:
|
|
missing = sorted({*missing, "working_memory_terminal_projection"})
|
|
return {
|
|
**readback,
|
|
"status": (
|
|
(
|
|
"controlled_apply_closed"
|
|
if projection_ready
|
|
else "controlled_apply_closed_projection_degraded"
|
|
)
|
|
if readback_closed
|
|
else "closure_readback_pending"
|
|
),
|
|
"closed": readback_closed,
|
|
"missing": missing,
|
|
"working_memory_projection_ready": projection_ready,
|
|
"read_model_degraded": not projection_ready,
|
|
}
|
|
|
|
|
|
async def _record_incident_terminal_disposition(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
terminal_type: str,
|
|
success_terminal: bool,
|
|
retry_op_id: str | None = None,
|
|
telegram_receipt_acknowledged: bool,
|
|
) -> dict[str, Any] | None:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
incident_status = "RESOLVED" if success_terminal else "MITIGATING"
|
|
terminal_payload = _incident_terminal_disposition_payload(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
terminal_type=terminal_type,
|
|
success_terminal=success_terminal,
|
|
retry_op_id=retry_op_id,
|
|
telegram_receipt_acknowledged=telegram_receipt_acknowledged,
|
|
)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
execution = await db.execute(
|
|
text("""
|
|
SELECT coalesce(
|
|
(apply.dry_run_result ->> 'apply_executed')::boolean,
|
|
(apply.output
|
|
->> 'controlled_apply_executed')::boolean,
|
|
false
|
|
) AS proposal_executed
|
|
FROM automation_operation_log apply
|
|
WHERE apply.op_id = CAST(:apply_op_id AS uuid)
|
|
AND apply.operation_type = 'ansible_apply_executed'
|
|
LIMIT 1
|
|
"""),
|
|
{"apply_op_id": apply_op_id},
|
|
)
|
|
execution_row = execution.mappings().one_or_none()
|
|
if execution_row is None:
|
|
return None
|
|
proposal_executed = execution_row.get("proposal_executed") is True
|
|
execution_success: bool | None = (
|
|
success_terminal if proposal_executed else None
|
|
)
|
|
if success_terminal and not proposal_executed:
|
|
return None
|
|
updated = await db.execute(
|
|
text("""
|
|
UPDATE incidents
|
|
SET status = CAST(:incident_status AS incidentstatus),
|
|
outcome = CAST(
|
|
coalesce(
|
|
CASE
|
|
WHEN outcome IS NULL THEN '{}'::jsonb
|
|
WHEN jsonb_typeof(CAST(outcome AS jsonb))
|
|
= 'object'
|
|
THEN CAST(outcome AS jsonb)
|
|
ELSE jsonb_build_object(
|
|
'legacy_outcome',
|
|
CAST(outcome AS jsonb)
|
|
)
|
|
END,
|
|
'{}'::jsonb
|
|
)
|
|
|| jsonb_build_object(
|
|
'automation_terminal',
|
|
CAST(:terminal_payload AS jsonb),
|
|
'proposal_executed',
|
|
CAST(:proposal_executed AS boolean),
|
|
'execution_success',
|
|
CAST(:execution_success AS boolean)
|
|
)
|
|
AS json
|
|
),
|
|
resolved_at = CASE
|
|
WHEN :success_terminal THEN NOW()
|
|
ELSE resolved_at
|
|
END,
|
|
updated_at = NOW()
|
|
WHERE incident_id = :incident_id
|
|
AND project_id = :project_id
|
|
AND (
|
|
:success_terminal
|
|
OR upper(status::text) NOT IN ('RESOLVED', 'CLOSED')
|
|
)
|
|
RETURNING
|
|
status::text AS incident_status,
|
|
updated_at,
|
|
resolved_at,
|
|
outcome
|
|
"""),
|
|
{
|
|
"incident_status": incident_status,
|
|
"terminal_payload": json.dumps(
|
|
terminal_payload,
|
|
ensure_ascii=False,
|
|
),
|
|
"success_terminal": success_terminal,
|
|
"proposal_executed": proposal_executed,
|
|
"execution_success": execution_success,
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
},
|
|
)
|
|
row = updated.mappings().one_or_none()
|
|
if row is None:
|
|
return None
|
|
outcome = _json_loads(row.get("outcome"))
|
|
readback = outcome.get("automation_terminal")
|
|
if not isinstance(readback, Mapping):
|
|
return None
|
|
if (
|
|
outcome.get("proposal_executed") is not proposal_executed
|
|
or outcome.get("execution_success") is not execution_success
|
|
):
|
|
return None
|
|
if (
|
|
readback.get("automation_run_id") != automation_run_id
|
|
or readback.get("apply_op_id") != apply_op_id
|
|
or readback.get("terminal_type") != terminal_type
|
|
):
|
|
return None
|
|
return {
|
|
**terminal_payload,
|
|
"incident_id": claim.incident_id,
|
|
"incident_status": str(row.get("incident_status") or ""),
|
|
"proposal_executed": proposal_executed,
|
|
"execution_success": execution_success,
|
|
"incident_row_version": (
|
|
row["updated_at"].isoformat()
|
|
if row.get("updated_at") is not None
|
|
else None
|
|
),
|
|
"repository_write_acknowledged": True,
|
|
"repository_readback_verified": True,
|
|
"durable_write_acknowledged": True,
|
|
"writer_source_sha": os.getenv(
|
|
"AWOOOI_BUILD_COMMIT_SHA",
|
|
"",
|
|
).strip().lower()
|
|
or None,
|
|
}
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_incident_terminal_disposition_write_failed",
|
|
automation_run_id=automation_run_id,
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return None
|
|
|
|
|
|
def _build_retry_runtime_stage_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
replay_op_id: str,
|
|
derived_from_durable_chain: bool = False,
|
|
check_mode_replay_performed: bool = True,
|
|
) -> dict[str, Any]:
|
|
terminal_type = (
|
|
"no_write_replay_passed_waiting_repair_candidate"
|
|
if result.returncode == 0
|
|
else "no_write_replay_failed_waiting_playbook_or_transport_repair"
|
|
)
|
|
return _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="retry_or_rollback",
|
|
evidence_ref=f"automation_operation_log:{replay_op_id}:dry_run_result",
|
|
derived_from_durable_chain=derived_from_durable_chain,
|
|
detail={
|
|
"schema_version": "ansible_controlled_retry_terminal_v2",
|
|
"failed_apply_op_id": apply_op_id,
|
|
"retry_check_mode_op_id": replay_op_id,
|
|
"terminal_type": terminal_type,
|
|
"check_mode_replay_performed": check_mode_replay_performed,
|
|
"check_mode_replay_returncode": result.returncode,
|
|
"runtime_apply_executed": False,
|
|
"rollback_performed": False,
|
|
"verified_no_write_terminal": True,
|
|
"retry_operation_readback_verified": True,
|
|
"retry_attempt": 1,
|
|
"max_retry_attempts": _CONTROLLED_RETRY_MAX_ATTEMPTS,
|
|
"retry_idempotency_key": (
|
|
f"ansible-retry:{apply_op_id}:check-mode"
|
|
),
|
|
"retry_error_backoff_min_seconds": (
|
|
_CONTROLLED_RETRY_ERROR_BACKOFF_MIN_SECONDS
|
|
),
|
|
"retry_error_backoff_max_seconds": (
|
|
_CONTROLLED_RETRY_ERROR_BACKOFF_MAX_SECONDS
|
|
),
|
|
"bounded_retry": True,
|
|
"repair_candidate_required": True,
|
|
"safe_next_action": (
|
|
"queue_ai_playbook_or_transport_repair_candidate"
|
|
),
|
|
"repository_readback_verified": True,
|
|
"durable_write_acknowledged": True,
|
|
"writer_source_sha": os.getenv(
|
|
"AWOOOI_BUILD_COMMIT_SHA",
|
|
"",
|
|
).strip().lower()
|
|
or None,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
},
|
|
)
|
|
|
|
|
|
async def _retry_telegram_receipt_acknowledged(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
) -> bool:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM awooop_outbound_message outbound
|
|
WHERE outbound.project_id = :project_id
|
|
AND outbound.channel_type = 'telegram'
|
|
AND outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id IS NOT NULL
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND coalesce(
|
|
outbound.source_envelope
|
|
->> 'automation_run_id',
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,automation_run_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,automation_run_ids,0}'
|
|
) = :automation_run_id
|
|
AND coalesce(
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,incident_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,incident_ids,0}'
|
|
) = :incident_id
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,apply_op_id}' = :apply_op_id
|
|
)
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"automation_run_id": automation_run_id,
|
|
"incident_id": claim.incident_id,
|
|
"apply_op_id": apply_op_id,
|
|
},
|
|
)
|
|
return result.scalar() is True
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_retry_telegram_receipt_readback_failed",
|
|
automation_run_id=automation_run_id,
|
|
incident_id=claim.incident_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return False
|
|
|
|
|
|
async def _record_retry_runtime_stage_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
replay_op_id: str,
|
|
project_id: str,
|
|
check_mode_replay_performed: bool = True,
|
|
) -> bool:
|
|
"""Append the verified no-write retry terminal to the original apply run."""
|
|
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
terminal_type = (
|
|
"no_write_replay_passed_waiting_repair_candidate"
|
|
if result.returncode == 0
|
|
else "no_write_replay_failed_waiting_playbook_or_transport_repair"
|
|
)
|
|
replay_readback_verified = False
|
|
telegram_receipt_acknowledged = False
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
replay_readback = await db.execute(
|
|
text("""
|
|
SELECT status, input, output, dry_run_result
|
|
FROM automation_operation_log
|
|
WHERE op_id = CAST(:replay_op_id AS uuid)
|
|
AND operation_type = 'ansible_execution_skipped'
|
|
AND parent_op_id = CAST(:apply_op_id AS uuid)
|
|
"""),
|
|
{
|
|
"replay_op_id": replay_op_id,
|
|
"apply_op_id": apply_op_id,
|
|
},
|
|
)
|
|
replay_row = replay_readback.mappings().one_or_none()
|
|
replay_input = _json_loads(
|
|
replay_row.get("input") if replay_row else None
|
|
)
|
|
replay_output = _json_loads(
|
|
replay_row.get("output") if replay_row else None
|
|
)
|
|
replay_readback_verified = bool(
|
|
replay_row
|
|
and replay_row.get("status")
|
|
== ("success" if result.returncode == 0 else "failed")
|
|
and replay_input.get("automation_run_id")
|
|
== automation_run_id
|
|
and replay_input.get("retry_of_apply_op_id") == apply_op_id
|
|
and replay_output.get("runtime_apply_executed") is False
|
|
and replay_output.get("terminal_disposition") == terminal_type
|
|
)
|
|
telegram_readback = await db.execute(
|
|
text("""
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM awooop_outbound_message
|
|
WHERE project_id = :project_id
|
|
AND channel_type = 'telegram'
|
|
AND send_status = 'sent'
|
|
AND provider_message_id IS NOT NULL
|
|
AND source_envelope #>> '{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND COALESCE(
|
|
source_envelope ->> 'automation_run_id',
|
|
source_envelope #>>
|
|
'{callback_reply,automation_run_id}',
|
|
source_envelope #>>
|
|
'{source_refs,automation_run_ids,0}'
|
|
) = :automation_run_id
|
|
AND COALESCE(
|
|
source_envelope #>>
|
|
'{callback_reply,incident_id}',
|
|
source_envelope #>>
|
|
'{source_refs,incident_ids,0}'
|
|
) = :incident_id
|
|
AND source_envelope #>>
|
|
'{callback_reply,apply_op_id}' = :apply_op_id
|
|
)
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"automation_run_id": automation_run_id,
|
|
"incident_id": claim.incident_id,
|
|
"apply_op_id": apply_op_id,
|
|
},
|
|
)
|
|
telegram_receipt_acknowledged = telegram_readback.scalar() is True
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_retry_terminal_readback_failed",
|
|
automation_run_id=automation_run_id,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=replay_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
|
|
if not replay_readback_verified:
|
|
logger.warning(
|
|
"ansible_retry_terminal_receipt_not_written_unverified",
|
|
automation_run_id=automation_run_id,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=replay_op_id,
|
|
)
|
|
return False
|
|
|
|
retry_receipt = _build_retry_runtime_stage_receipt(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=replay_op_id,
|
|
check_mode_replay_performed=check_mode_replay_performed,
|
|
)
|
|
incident_detail = await _record_incident_terminal_disposition(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
terminal_type=terminal_type,
|
|
success_terminal=False,
|
|
retry_op_id=replay_op_id,
|
|
telegram_receipt_acknowledged=telegram_receipt_acknowledged,
|
|
)
|
|
receipts = [retry_receipt]
|
|
if incident_detail is not None:
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="incident_closure",
|
|
evidence_ref=(
|
|
f"incidents:{claim.incident_id}:automation_terminal"
|
|
),
|
|
detail=incident_detail,
|
|
)
|
|
)
|
|
return await _append_runtime_stage_receipts_to_apply(
|
|
apply_op_id=apply_op_id,
|
|
receipts=tuple(receipts),
|
|
project_id=project_id,
|
|
)
|
|
|
|
|
|
def _build_stdin_boundary_replay_stage_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
failed_check_mode_op_id: str,
|
|
terminal_op_id: str,
|
|
) -> dict[str, Any]:
|
|
terminal_type = (
|
|
"no_write_stdin_boundary_replay_passed_waiting_repair_candidate"
|
|
if result.returncode == 0
|
|
else "no_write_stdin_boundary_replay_failed_waiting_transport_repair"
|
|
)
|
|
return _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="retry_or_rollback",
|
|
evidence_ref=(
|
|
f"automation_operation_log:{terminal_op_id}:dry_run_result"
|
|
),
|
|
detail={
|
|
"schema_version": (
|
|
"ansible_failed_check_stdin_boundary_retry_terminal_v1"
|
|
),
|
|
"failed_check_mode_op_id": failed_check_mode_op_id,
|
|
"retry_check_mode_op_id": claim.op_id,
|
|
"retry_terminal_op_id": terminal_op_id,
|
|
"terminal_type": terminal_type,
|
|
"check_mode_replay_performed": True,
|
|
"check_mode_replay_returncode": result.returncode,
|
|
"runtime_apply_executed": False,
|
|
"rollback_performed": False,
|
|
"verified_no_write_terminal": True,
|
|
"retry_operation_readback_verified": True,
|
|
"retry_attempt": 1,
|
|
"max_retry_attempts": 1,
|
|
"retry_idempotency_key": (
|
|
f"ansible-check-retry:{failed_check_mode_op_id}:stdin-boundary"
|
|
),
|
|
"bounded_retry": True,
|
|
"repair_candidate_required": True,
|
|
"safe_next_action": (
|
|
"queue_new_controlled_apply_candidate_after_no_write_replay"
|
|
if result.returncode == 0
|
|
else "queue_ai_transport_repair_candidate"
|
|
),
|
|
"repository_readback_verified": True,
|
|
"durable_write_acknowledged": True,
|
|
"writer_source_sha": os.getenv(
|
|
"AWOOOI_BUILD_COMMIT_SHA",
|
|
"",
|
|
).strip().lower()
|
|
or None,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
},
|
|
)
|
|
|
|
|
|
async def _record_stdin_boundary_replay_terminal(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
project_id: str,
|
|
) -> bool:
|
|
"""Persist one idempotent, public-safe terminal for a no-write replay."""
|
|
|
|
failed_check_mode_op_id = str(
|
|
claim.input_payload.get("replay_of_check_mode_op_id") or ""
|
|
)
|
|
if not failed_check_mode_op_id:
|
|
return False
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
expected_status = "success" if result.returncode == 0 else "failed"
|
|
terminal_type = (
|
|
"no_write_stdin_boundary_replay_passed_waiting_repair_candidate"
|
|
if result.returncode == 0
|
|
else "no_write_stdin_boundary_replay_failed_waiting_transport_repair"
|
|
)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
await db.execute(
|
|
text("""
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended(:retry_terminal_lock_key, 0)
|
|
)
|
|
"""),
|
|
{
|
|
"retry_terminal_lock_key": (
|
|
"ansible-check-retry-terminal:"
|
|
f"{failed_check_mode_op_id}"
|
|
)
|
|
},
|
|
)
|
|
selected = await db.execute(
|
|
text("""
|
|
SELECT op_id::text AS op_id
|
|
FROM automation_operation_log
|
|
WHERE operation_type = 'ansible_execution_skipped'
|
|
AND input ->> 'execution_mode'
|
|
= 'stdin_boundary_check_mode_replay_terminal'
|
|
AND input ->> 'retry_of_check_mode_op_id'
|
|
= :failed_check_mode_op_id
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
"""),
|
|
{"failed_check_mode_op_id": failed_check_mode_op_id},
|
|
)
|
|
terminal_op_id = str(selected.scalar_one_or_none() or uuid4())
|
|
receipt = _build_stdin_boundary_replay_stage_receipt(
|
|
claim,
|
|
result,
|
|
failed_check_mode_op_id=failed_check_mode_op_id,
|
|
terminal_op_id=terminal_op_id,
|
|
)
|
|
terminal_input = {
|
|
"automation_run_id": automation_run_id,
|
|
"incident_id": claim.incident_id,
|
|
"catalog_id": claim.catalog_id,
|
|
"execution_mode": (
|
|
"stdin_boundary_check_mode_replay_terminal"
|
|
),
|
|
"retry_of_check_mode_op_id": failed_check_mode_op_id,
|
|
"retry_check_mode_op_id": claim.op_id,
|
|
"runtime_apply_executed": False,
|
|
"runtime_stage_receipts": [receipt],
|
|
}
|
|
terminal_output = {
|
|
"terminal_disposition": terminal_type,
|
|
"check_mode_replay_returncode": result.returncode,
|
|
"check_mode_replay_performed": True,
|
|
"runtime_apply_executed": False,
|
|
"verified_no_write_terminal": True,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
}
|
|
await db.execute(
|
|
text("""
|
|
INSERT INTO automation_operation_log (
|
|
op_id, operation_type, actor, status, incident_id,
|
|
input, output, dry_run_result, error, duration_ms,
|
|
parent_op_id, tags
|
|
)
|
|
SELECT
|
|
CAST(:op_id AS uuid),
|
|
'ansible_execution_skipped',
|
|
'ansible_stdin_boundary_replay_worker',
|
|
:status,
|
|
:incident_db_id,
|
|
CAST(:input AS jsonb),
|
|
CAST(:output AS jsonb),
|
|
CAST(:dry_run_result AS jsonb),
|
|
:error,
|
|
:duration_ms,
|
|
CAST(:parent_op_id AS uuid),
|
|
:tags
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log existing
|
|
WHERE existing.operation_type
|
|
= 'ansible_execution_skipped'
|
|
AND existing.input ->> 'execution_mode'
|
|
= 'stdin_boundary_check_mode_replay_terminal'
|
|
AND existing.input ->> 'retry_of_check_mode_op_id'
|
|
= :failed_check_mode_op_id
|
|
)
|
|
"""),
|
|
{
|
|
"op_id": terminal_op_id,
|
|
"status": expected_status,
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
claim.incident_id
|
|
),
|
|
"input": json.dumps(terminal_input, ensure_ascii=False),
|
|
"output": json.dumps(terminal_output, ensure_ascii=False),
|
|
"dry_run_result": json.dumps(
|
|
{
|
|
**terminal_output,
|
|
"check_mode": True,
|
|
"diff": True,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"error": (
|
|
None
|
|
if result.returncode == 0
|
|
else (
|
|
"stdin_boundary_replay_check_mode_failed_rc_"
|
|
f"{result.returncode}"
|
|
)
|
|
),
|
|
"duration_ms": result.duration_ms,
|
|
"parent_op_id": claim.op_id,
|
|
"tags": [
|
|
"ansible",
|
|
"stdin_boundary_replay",
|
|
"verified_no_write_terminal",
|
|
f"automation_run_id:{automation_run_id}",
|
|
],
|
|
"failed_check_mode_op_id": failed_check_mode_op_id,
|
|
},
|
|
)
|
|
readback = await db.execute(
|
|
text("""
|
|
SELECT status, input, output
|
|
FROM automation_operation_log
|
|
WHERE op_id = CAST(:op_id AS uuid)
|
|
AND operation_type = 'ansible_execution_skipped'
|
|
LIMIT 1
|
|
"""),
|
|
{"op_id": terminal_op_id},
|
|
)
|
|
row = readback.mappings().one_or_none()
|
|
input_readback = _json_loads(row.get("input") if row else None)
|
|
output_readback = _json_loads(row.get("output") if row else None)
|
|
receipts = input_readback.get("runtime_stage_receipts")
|
|
return bool(
|
|
row
|
|
and row.get("status") == expected_status
|
|
and input_readback.get("automation_run_id")
|
|
== automation_run_id
|
|
and input_readback.get("retry_of_check_mode_op_id")
|
|
== failed_check_mode_op_id
|
|
and output_readback.get("runtime_apply_executed") is False
|
|
and output_readback.get("terminal_disposition")
|
|
== terminal_type
|
|
and isinstance(receipts, list)
|
|
and any(
|
|
isinstance(item, Mapping)
|
|
and item.get("stage_id") == "retry_or_rollback"
|
|
and item.get("durable_receipt") is True
|
|
for item in receipts
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_stdin_boundary_replay_terminal_write_failed",
|
|
automation_run_id=automation_run_id,
|
|
failed_check_mode_op_id=failed_check_mode_op_id,
|
|
retry_check_mode_op_id=claim.op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return False
|
|
|
|
|
|
def _claim_from_stdin_boundary_terminal_row(
|
|
row: dict[str, Any],
|
|
) -> AnsibleCheckModeClaim | None:
|
|
replay_input = _json_loads(row.get("replay_input"))
|
|
base = _claim_from_stale_check_mode_row(
|
|
{
|
|
"op_id": row.get("replay_op_id"),
|
|
"parent_op_id": row.get("source_candidate_op_id"),
|
|
"incident_id": row.get("incident_id"),
|
|
"input": replay_input,
|
|
}
|
|
)
|
|
if base is None:
|
|
return None
|
|
terminal_input = _json_loads(row.get("terminal_input"))
|
|
automation_run_id = str(
|
|
terminal_input.get("automation_run_id")
|
|
or replay_input.get("automation_run_id")
|
|
or base.source_candidate_op_id
|
|
)
|
|
return replace(
|
|
base,
|
|
input_payload={
|
|
**base.input_payload,
|
|
**replay_input,
|
|
"automation_run_id": automation_run_id,
|
|
"historical_stdin_boundary_replay": True,
|
|
"controlled_apply_allowed": False,
|
|
"apply_enabled": False,
|
|
},
|
|
)
|
|
|
|
|
|
async def _record_no_write_replay_timeline_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
terminal_op_id: str,
|
|
project_id: str,
|
|
) -> dict[str, Any] | None:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
description = (
|
|
f"automation_run_id={automation_run_id};"
|
|
f"terminal_op_id={terminal_op_id};catalog_id={claim.catalog_id};"
|
|
f"returncode={result.returncode};runtime_apply_executed=false;"
|
|
"terminal=no_write_stdin_boundary_replay"
|
|
)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
selected = await db.execute(
|
|
text("""
|
|
WITH existing AS (
|
|
SELECT id
|
|
FROM timeline_events
|
|
WHERE incident_id = :incident_id
|
|
AND actor = 'ansible_stdin_boundary_replay_worker'
|
|
AND description = :description
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
), inserted AS (
|
|
INSERT INTO timeline_events (
|
|
id, incident_id, event_type, status, title,
|
|
description, actor, actor_role, risk_level,
|
|
created_at
|
|
)
|
|
SELECT
|
|
gen_random_uuid()::text,
|
|
:incident_id,
|
|
'exec',
|
|
'error',
|
|
:title,
|
|
:description,
|
|
'ansible_stdin_boundary_replay_worker',
|
|
'ai_agent',
|
|
:risk_level,
|
|
NOW()
|
|
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
|
RETURNING id
|
|
)
|
|
SELECT id FROM inserted
|
|
UNION ALL
|
|
SELECT id FROM existing
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"title": (
|
|
"AI no-write replay terminal: "
|
|
f"{claim.catalog_id}"
|
|
)[:500],
|
|
"description": description,
|
|
"risk_level": str(claim.risk_level or "")[:20] or None,
|
|
},
|
|
)
|
|
timeline_event_id = str(selected.scalar() or "")
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_stdin_boundary_timeline_write_failed",
|
|
automation_run_id=automation_run_id,
|
|
terminal_op_id=terminal_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return None
|
|
if not timeline_event_id:
|
|
return None
|
|
return _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="timeline_projection",
|
|
evidence_ref=f"timeline_events:{timeline_event_id}",
|
|
detail={
|
|
"timeline_event_id": timeline_event_id,
|
|
"terminal_op_id": terminal_op_id,
|
|
"returncode": result.returncode,
|
|
"runtime_apply_executed": False,
|
|
"projection_status": "repair_required",
|
|
"repository_readback_verified": True,
|
|
},
|
|
derived_from_durable_chain=True,
|
|
)
|
|
|
|
|
|
async def _record_no_write_replay_learning_receipts(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
terminal_op_id: str,
|
|
project_id: str,
|
|
) -> dict[str, Any]:
|
|
from src.models.knowledge import (
|
|
EntrySource,
|
|
EntryStatus,
|
|
EntryType,
|
|
KnowledgeEntryCreate,
|
|
)
|
|
from src.repositories.knowledge_repository import KnowledgeDBRepository
|
|
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
canonical_playbook_id = canonical_ansible_playbook_id(claim.catalog_id)
|
|
path_type = f"ansible_no_write_replay:{terminal_op_id[:8]}"
|
|
receipts = await _load_pre_decision_context_runtime_stage_receipts(
|
|
claim,
|
|
project_id=project_id,
|
|
derived_from_durable_chain=True,
|
|
)
|
|
failed_check_mode_op_id = str(
|
|
claim.input_payload.get("replay_of_check_mode_op_id") or ""
|
|
)
|
|
if failed_check_mode_op_id:
|
|
receipts.append(
|
|
_build_stdin_boundary_replay_stage_receipt(
|
|
claim,
|
|
result,
|
|
failed_check_mode_op_id=failed_check_mode_op_id,
|
|
terminal_op_id=terminal_op_id,
|
|
)
|
|
)
|
|
receipts.extend(
|
|
[
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="normalized_asset_identity",
|
|
evidence_ref=f"ansible_catalog:{claim.catalog_id}",
|
|
detail={
|
|
"asset_kind": "managed_host",
|
|
"canonical_asset_ids": list(claim.inventory_hosts),
|
|
"catalog_id": claim.catalog_id,
|
|
"playbook_path": claim.playbook_path,
|
|
},
|
|
derived_from_durable_chain=True,
|
|
),
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="source_truth_diff",
|
|
evidence_ref=(
|
|
f"automation_operation_log:{claim.op_id}:dry_run_result"
|
|
),
|
|
detail={
|
|
"check_mode_op_id": claim.op_id,
|
|
"check_mode_replay_performed": True,
|
|
"check_mode_replay_returncode": result.returncode,
|
|
"diff": True,
|
|
"runtime_apply_executed": False,
|
|
},
|
|
derived_from_durable_chain=True,
|
|
),
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="risk_policy_decision",
|
|
evidence_ref=f"ansible_policy:{claim.catalog_id}",
|
|
detail={
|
|
"risk_level": str(claim.risk_level or "").lower(),
|
|
"controlled_apply_allowed": (
|
|
str(claim.risk_level or "").lower()
|
|
in {"low", "medium", "high"}
|
|
),
|
|
"controlled_apply_executed": False,
|
|
"execution_scope": "historical_no_write_replay",
|
|
"apply_deferred_reason": (
|
|
"stdin_boundary_transport_or_playbook_repair_required"
|
|
),
|
|
"decision": (
|
|
"verified_no_write_terminal_pending_ai_repair"
|
|
),
|
|
"policy": "global_product_governance_v2",
|
|
},
|
|
derived_from_durable_chain=True,
|
|
),
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="executor_log_projection",
|
|
evidence_ref=(
|
|
f"automation_operation_log:{terminal_op_id}:output"
|
|
),
|
|
detail={
|
|
"terminal_op_id": terminal_op_id,
|
|
"returncode": result.returncode,
|
|
"duration_ms": result.duration_ms,
|
|
"runtime_apply_executed": False,
|
|
"projection_sanitized": True,
|
|
},
|
|
derived_from_durable_chain=True,
|
|
),
|
|
]
|
|
)
|
|
|
|
km_writeback: dict[str, Any] | None = None
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
existing = await db.execute(
|
|
text("""
|
|
SELECT id
|
|
FROM knowledge_entries
|
|
WHERE related_incident_id = :incident_id
|
|
AND path_type = :path_type
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"path_type": path_type,
|
|
},
|
|
)
|
|
if existing.scalar() is None:
|
|
repo = KnowledgeDBRepository(db)
|
|
await repo.create(
|
|
KnowledgeEntryCreate(
|
|
title=f"AI 無寫入重試反模式:{claim.incident_id}",
|
|
content=(
|
|
"AI Agent 已完成歷史 Ansible stdin boundary "
|
|
"check-mode 重試,未執行 runtime apply。\n\n"
|
|
f"- Automation run: {automation_run_id}\n"
|
|
f"- Incident: {claim.incident_id}\n"
|
|
f"- Catalog: {claim.catalog_id}\n"
|
|
f"- PlayBook: {claim.playbook_path}\n"
|
|
f"- Terminal operation: {terminal_op_id}\n"
|
|
f"- Check-mode return code: {result.returncode}\n"
|
|
"- Runtime apply executed: false\n"
|
|
"- Next action: AI transport or PlayBook repair\n"
|
|
),
|
|
entry_type=EntryType.ANTI_PATTERN,
|
|
category="AI自動化/Ansible反模式",
|
|
tags=[
|
|
"ai_auto_repair",
|
|
"ansible_no_write_replay",
|
|
"stdin_boundary",
|
|
str(claim.catalog_id or ""),
|
|
f"automation_run_id:{automation_run_id}",
|
|
],
|
|
source=EntrySource.AI_EXTRACTED,
|
|
status=EntryStatus.PUBLISHED,
|
|
related_incident_id=claim.incident_id,
|
|
related_playbook_id=canonical_playbook_id,
|
|
path_type=path_type,
|
|
created_by="ai_agent_ansible_replay_worker",
|
|
)
|
|
)
|
|
readback = await db.execute(
|
|
text("""
|
|
SELECT id, related_playbook_id, path_type, status, updated_at
|
|
FROM knowledge_entries
|
|
WHERE related_incident_id = :incident_id
|
|
AND path_type = :path_type
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"path_type": path_type,
|
|
},
|
|
)
|
|
km_row = readback.mappings().first()
|
|
if (
|
|
km_row
|
|
and str(km_row.get("related_playbook_id") or "")
|
|
== canonical_playbook_id
|
|
and str(km_row.get("status") or "").lower() == "published"
|
|
and km_row.get("updated_at") is not None
|
|
):
|
|
km_writeback = {
|
|
"schema_version": "ansible_no_write_km_writeback_v1",
|
|
"knowledge_entry_id": str(km_row["id"]),
|
|
"canonical_playbook_id": canonical_playbook_id,
|
|
"path_type": path_type,
|
|
"status": str(km_row.get("status") or ""),
|
|
"repository_write_acknowledged": True,
|
|
"repository_readback_verified": True,
|
|
"durable_write_acknowledged": True,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
}
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_stdin_boundary_km_writeback_failed",
|
|
automation_run_id=automation_run_id,
|
|
terminal_op_id=terminal_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
if km_writeback is not None:
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="km_playbook_writeback",
|
|
evidence_ref=(
|
|
"knowledge_entries:"
|
|
f"{km_writeback['knowledge_entry_id']}:row_version"
|
|
),
|
|
detail=km_writeback,
|
|
derived_from_durable_chain=True,
|
|
)
|
|
)
|
|
|
|
trust_writeback = await record_ansible_playbook_trust_writeback(
|
|
project_id=project_id,
|
|
automation_run_id=automation_run_id,
|
|
incident_id=claim.incident_id,
|
|
incident_db_id=_automation_operation_log_incident_id(
|
|
claim.incident_id
|
|
),
|
|
catalog_id=claim.catalog_id,
|
|
playbook_path=claim.playbook_path,
|
|
apply_op_id=terminal_op_id,
|
|
verification_result="not_applicable_no_runtime_apply",
|
|
trust_mutation_performed=False,
|
|
observation_kind="no_write_replay",
|
|
)
|
|
if trust_writeback is not None:
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="playbook_trust",
|
|
evidence_ref=(
|
|
"playbooks:"
|
|
f"{trust_writeback['canonical_playbook_id']}:trust"
|
|
),
|
|
detail=trust_writeback,
|
|
derived_from_durable_chain=True,
|
|
)
|
|
)
|
|
|
|
rag_writeback = (
|
|
await ensure_ansible_rag_writeback(
|
|
project_id=project_id,
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=terminal_op_id,
|
|
path_type=path_type,
|
|
)
|
|
if km_writeback is not None
|
|
else None
|
|
)
|
|
if rag_writeback is not None:
|
|
rag_evidence_ref = (
|
|
"knowledge_entries:"
|
|
f"{rag_writeback['knowledge_entry_id']}:embedding"
|
|
if rag_writeback.get("embedding_persisted") is True
|
|
else (
|
|
"rag_chunks:"
|
|
f"{rag_writeback.get('source_id', '')}:"
|
|
f"count={rag_writeback.get('rag_chunk_count', 0)}"
|
|
)
|
|
)
|
|
receipts.append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="rag_writeback",
|
|
evidence_ref=rag_evidence_ref,
|
|
detail=rag_writeback,
|
|
derived_from_durable_chain=True,
|
|
)
|
|
)
|
|
|
|
timeline_receipt = await _record_no_write_replay_timeline_receipt(
|
|
claim,
|
|
result,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
)
|
|
if timeline_receipt is not None:
|
|
receipts.append(timeline_receipt)
|
|
|
|
by_stage = {
|
|
str(receipt.get("stage_id") or ""): receipt
|
|
for receipt in receipts
|
|
if isinstance(receipt, Mapping)
|
|
}
|
|
stage_ids = set(by_stage)
|
|
missing_stage_ids = sorted(
|
|
_NO_WRITE_REPLAY_REQUIRED_STAGE_IDS - stage_ids
|
|
)
|
|
return {
|
|
"ready": not missing_stage_ids,
|
|
"receipts": tuple(by_stage.values()),
|
|
"stage_ids": sorted(stage_ids),
|
|
"missing_stage_ids": missing_stage_ids,
|
|
"km_writeback": km_writeback is not None,
|
|
"rag_writeback": rag_writeback is not None,
|
|
"playbook_trust": trust_writeback is not None,
|
|
"mcp_context": "mcp_context" in stage_ids,
|
|
"runtime_apply_executed": False,
|
|
}
|
|
|
|
|
|
async def _load_missing_stdin_boundary_terminal_projection_rows(
|
|
*,
|
|
project_id: str,
|
|
window_hours: int,
|
|
limit: int,
|
|
) -> list[dict[str, Any]]:
|
|
async with get_db_context(project_id) as db:
|
|
await db.execute(text("SET LOCAL statement_timeout = '5000ms'"))
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
terminal.op_id::text AS terminal_op_id,
|
|
terminal.status AS terminal_status,
|
|
terminal.input AS terminal_input,
|
|
terminal.output AS terminal_output,
|
|
terminal.error AS terminal_error,
|
|
terminal.duration_ms AS terminal_duration_ms,
|
|
replay.op_id::text AS replay_op_id,
|
|
candidate.op_id::text AS source_candidate_op_id,
|
|
replay.input AS replay_input,
|
|
coalesce(
|
|
terminal.incident_id::text,
|
|
terminal.input ->> 'incident_id',
|
|
replay.incident_id::text,
|
|
replay.input ->> 'incident_id'
|
|
) AS incident_id,
|
|
CASE
|
|
WHEN lifecycle_projection.run_auto_repair_triggered
|
|
IS TRUE
|
|
AND (
|
|
lifecycle_projection.run_execution_started
|
|
IS NOT TRUE
|
|
OR lifecycle_projection.run_execution_completed
|
|
IS NOT TRUE
|
|
OR lifecycle_projection.run_telegram_result_acknowledged
|
|
IS NOT TRUE
|
|
)
|
|
THEN 0
|
|
ELSE 1
|
|
END AS lifecycle_open_priority
|
|
FROM automation_operation_log terminal
|
|
JOIN automation_operation_log replay
|
|
ON replay.op_id = terminal.parent_op_id
|
|
AND replay.operation_type = 'ansible_check_mode_executed'
|
|
AND replay.input ->> 'execution_mode'
|
|
= 'stdin_boundary_check_mode_replay'
|
|
AND replay.input ->> 'automation_run_id'
|
|
= terminal.input ->> 'automation_run_id'
|
|
JOIN automation_operation_log failed_check
|
|
ON failed_check.op_id = CAST(
|
|
NULLIF(
|
|
replay.input ->> 'replay_of_check_mode_op_id',
|
|
''
|
|
) AS uuid
|
|
)
|
|
AND failed_check.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND failed_check.status = 'failed'
|
|
AND failed_check.parent_op_id = replay.parent_op_id
|
|
AND failed_check.input ->> 'automation_run_id'
|
|
= terminal.input ->> 'automation_run_id'
|
|
JOIN automation_operation_log candidate
|
|
ON candidate.op_id = replay.parent_op_id
|
|
AND candidate.operation_type = 'ansible_candidate_matched'
|
|
AND coalesce(
|
|
nullif(candidate.input ->> 'automation_run_id', ''),
|
|
candidate.op_id::text
|
|
) = terminal.input ->> 'automation_run_id'
|
|
JOIN incidents incident
|
|
ON incident.incident_id = coalesce(
|
|
terminal.incident_id::text,
|
|
terminal.input ->> 'incident_id',
|
|
replay.incident_id::text,
|
|
replay.input ->> 'incident_id'
|
|
)
|
|
AND incident.project_id = :project_id
|
|
JOIN LATERAL (
|
|
SELECT
|
|
coalesce(
|
|
bool_or(
|
|
lifecycle.event_type::text
|
|
= 'AUTO_REPAIR_TRIGGERED'
|
|
),
|
|
false
|
|
) AS run_auto_repair_triggered,
|
|
coalesce(
|
|
bool_or(
|
|
lifecycle.event_type::text
|
|
= 'EXECUTION_STARTED'
|
|
),
|
|
false
|
|
) AS run_execution_started,
|
|
coalesce(
|
|
bool_or(
|
|
lifecycle.event_type::text
|
|
= 'EXECUTION_COMPLETED'
|
|
),
|
|
false
|
|
) AS run_execution_completed,
|
|
coalesce(
|
|
bool_or(
|
|
lifecycle.event_type::text
|
|
= 'TELEGRAM_RESULT_SENT'
|
|
OR (
|
|
lifecycle.event_type::text
|
|
= 'NOTIFICATION_CLASSIFIED'
|
|
AND lifecycle.action_detail
|
|
= 'stdin_boundary_no_write_result_receipt_suppressed'
|
|
)
|
|
),
|
|
false
|
|
) AS run_telegram_result_acknowledged,
|
|
coalesce(
|
|
bool_or(
|
|
lifecycle.event_type::text
|
|
= 'AUTO_REPAIR_TRIGGERED'
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= terminal.op_id::text
|
|
),
|
|
false
|
|
) AS terminal_auto_repair_triggered,
|
|
coalesce(
|
|
bool_or(
|
|
lifecycle.event_type::text
|
|
= 'EXECUTION_STARTED'
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= terminal.op_id::text
|
|
),
|
|
false
|
|
) AS terminal_execution_started
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = incident.incident_id
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= terminal.input ->> 'automation_run_id'
|
|
AND lifecycle.event_type::text IN (
|
|
'AUTO_REPAIR_TRIGGERED',
|
|
'EXECUTION_STARTED',
|
|
'EXECUTION_COMPLETED',
|
|
'TELEGRAM_RESULT_SENT',
|
|
'NOTIFICATION_CLASSIFIED'
|
|
)
|
|
) lifecycle_projection ON TRUE
|
|
WHERE terminal.operation_type = 'ansible_execution_skipped'
|
|
AND terminal.status IN ('success', 'failed')
|
|
AND terminal.input ->> 'execution_mode'
|
|
= 'stdin_boundary_check_mode_replay_terminal'
|
|
AND terminal.input ->> 'retry_of_check_mode_op_id'
|
|
= failed_check.op_id::text
|
|
AND terminal.output ->> 'runtime_apply_executed' = 'false'
|
|
AND terminal.created_at >= NOW() - (
|
|
:window_hours * INTERVAL '1 hour'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
terminal.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) receipt(value)
|
|
WHERE receipt.value ->> 'stage_id'
|
|
= 'retry_or_rollback'
|
|
AND receipt.value ->> 'automation_run_id'
|
|
= terminal.input ->> 'automation_run_id'
|
|
AND receipt.value #>>
|
|
'{detail,verified_no_write_terminal}' = 'true'
|
|
AND receipt.value #>>
|
|
'{detail,runtime_apply_executed}' = 'false'
|
|
AND receipt.value #>>
|
|
'{detail,repository_readback_verified}' = 'true'
|
|
AND receipt.value ->> 'durable_receipt' = 'true'
|
|
)
|
|
AND (
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
terminal.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) receipt(value)
|
|
WHERE receipt.value ->> 'stage_id'
|
|
= 'incident_closure'
|
|
AND receipt.value ->> 'automation_run_id'
|
|
= terminal.input ->> 'automation_run_id'
|
|
AND receipt.value #>>
|
|
'{detail,terminal_op_id}' = terminal.op_id::text
|
|
AND receipt.value #>>
|
|
'{detail,projection_readback_verified}' = 'true'
|
|
AND receipt.value ->> 'durable_receipt' = 'true'
|
|
)
|
|
OR lifecycle_projection.terminal_auto_repair_triggered
|
|
IS NOT TRUE
|
|
OR lifecycle_projection.terminal_execution_started
|
|
IS NOT TRUE
|
|
)
|
|
ORDER BY
|
|
lifecycle_open_priority ASC,
|
|
terminal.created_at ASC,
|
|
terminal.op_id ASC
|
|
LIMIT :limit
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"window_hours": max(1, min(window_hours, 7 * 24)),
|
|
"limit": max(1, min(limit, 5)),
|
|
},
|
|
)
|
|
return [dict(row) for row in result.mappings().all()]
|
|
|
|
|
|
async def _stdin_boundary_replay_telegram_notification_disposition(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
terminal_op_id: str,
|
|
project_id: str,
|
|
) -> str:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT CASE
|
|
WHEN outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id IS NOT NULL
|
|
THEN 'sent'
|
|
WHEN outbound.send_status = 'shadow'
|
|
AND outbound.provider_message_id IS NULL
|
|
AND outbound.source_envelope #>>
|
|
'{notification_policy,disposition}'
|
|
= 'suppressed'
|
|
THEN 'suppressed'
|
|
ELSE 'missing'
|
|
END AS disposition
|
|
FROM awooop_outbound_message outbound
|
|
WHERE outbound.project_id = :project_id
|
|
AND outbound.channel_type = 'telegram'
|
|
AND (
|
|
(
|
|
outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id IS NOT NULL
|
|
)
|
|
OR (
|
|
outbound.send_status = 'shadow'
|
|
AND outbound.provider_message_id IS NULL
|
|
AND outbound.source_envelope #>>
|
|
'{notification_policy,disposition}'
|
|
= 'suppressed'
|
|
)
|
|
)
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND coalesce(
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,execution_kind}',
|
|
outbound.source_envelope ->> 'execution_kind'
|
|
) = 'no_write_replay'
|
|
AND coalesce(
|
|
outbound.source_envelope
|
|
->> 'automation_run_id',
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,automation_run_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,automation_run_ids,0}'
|
|
) = :automation_run_id
|
|
AND coalesce(
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,incident_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,incident_ids,0}'
|
|
) = :incident_id
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,apply_op_id}'
|
|
= :terminal_op_id
|
|
ORDER BY (outbound.send_status = 'sent') DESC
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"automation_run_id": automation_run_id,
|
|
"incident_id": claim.incident_id,
|
|
"terminal_op_id": terminal_op_id,
|
|
},
|
|
)
|
|
return str(result.scalar_one_or_none() or "missing")
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_stdin_boundary_telegram_readback_failed",
|
|
automation_run_id=automation_run_id,
|
|
terminal_op_id=terminal_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return "missing"
|
|
|
|
|
|
async def _stdin_boundary_replay_telegram_receipt_acknowledged(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
terminal_op_id: str,
|
|
project_id: str,
|
|
) -> bool:
|
|
disposition = (
|
|
await _stdin_boundary_replay_telegram_notification_disposition(
|
|
claim,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
return disposition in {"sent", "suppressed"}
|
|
|
|
|
|
async def _record_stdin_boundary_incident_terminal_disposition(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
terminal_op_id: str,
|
|
learning_stage_ids: list[str],
|
|
project_id: str,
|
|
) -> dict[str, Any] | None:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
terminal_payload = {
|
|
**_incident_terminal_disposition_payload(
|
|
claim,
|
|
apply_op_id=terminal_op_id,
|
|
terminal_type="no_write_stdin_boundary_replay_terminal",
|
|
success_terminal=False,
|
|
retry_op_id=claim.op_id,
|
|
telegram_receipt_acknowledged=True,
|
|
),
|
|
"schema_version": (
|
|
"ansible_stdin_boundary_incident_terminal_v1"
|
|
),
|
|
"terminal_op_id": terminal_op_id,
|
|
"proposal_executed": False,
|
|
"execution_success": None,
|
|
"learning_stage_ids": learning_stage_ids,
|
|
"learning_writeback_acknowledged": True,
|
|
"repository_readback_verified": True,
|
|
"runtime_apply_executed": False,
|
|
}
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
selected = await db.execute(
|
|
text("""
|
|
SELECT status::text AS incident_status, outcome, updated_at
|
|
FROM incidents
|
|
WHERE incident_id = :incident_id
|
|
AND project_id = :project_id
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
},
|
|
)
|
|
current = selected.mappings().one_or_none()
|
|
if current is None:
|
|
return None
|
|
current_status = str(current.get("incident_status") or "")
|
|
projection_superseded = current_status.upper() in {
|
|
"RESOLVED",
|
|
"CLOSED",
|
|
}
|
|
incident_write_performed = False
|
|
readback = current
|
|
if not projection_superseded:
|
|
updated = await db.execute(
|
|
text("""
|
|
UPDATE incidents
|
|
SET status = 'MITIGATING'::incidentstatus,
|
|
outcome = CAST(
|
|
coalesce(
|
|
CASE
|
|
WHEN outcome IS NULL THEN '{}'::jsonb
|
|
WHEN jsonb_typeof(CAST(outcome AS jsonb))
|
|
= 'object'
|
|
THEN CAST(outcome AS jsonb)
|
|
ELSE jsonb_build_object(
|
|
'legacy_outcome',
|
|
CAST(outcome AS jsonb)
|
|
)
|
|
END,
|
|
'{}'::jsonb
|
|
) || jsonb_build_object(
|
|
'automation_terminal',
|
|
CAST(:terminal_payload AS jsonb),
|
|
'proposal_executed',
|
|
false,
|
|
'execution_success',
|
|
NULL
|
|
) AS json
|
|
),
|
|
updated_at = NOW()
|
|
WHERE incident_id = :incident_id
|
|
AND project_id = :project_id
|
|
AND upper(status::text)
|
|
NOT IN ('RESOLVED', 'CLOSED')
|
|
RETURNING
|
|
status::text AS incident_status,
|
|
outcome,
|
|
updated_at
|
|
"""),
|
|
{
|
|
"terminal_payload": json.dumps(
|
|
terminal_payload,
|
|
ensure_ascii=False,
|
|
),
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
},
|
|
)
|
|
readback = updated.mappings().one_or_none()
|
|
incident_write_performed = readback is not None
|
|
if readback is None:
|
|
return None
|
|
outcome = _json_loads(readback.get("outcome"))
|
|
terminal_readback = outcome.get("automation_terminal")
|
|
if not (
|
|
isinstance(terminal_readback, Mapping)
|
|
and terminal_readback.get("automation_run_id")
|
|
== automation_run_id
|
|
and terminal_readback.get("terminal_op_id")
|
|
== terminal_op_id
|
|
and terminal_readback.get("runtime_apply_executed")
|
|
is False
|
|
):
|
|
return None
|
|
return {
|
|
**terminal_payload,
|
|
"incident_status": str(
|
|
readback.get("incident_status") or current_status
|
|
),
|
|
"incident_projection_write_performed": (
|
|
incident_write_performed
|
|
),
|
|
"incident_projection_superseded": projection_superseded,
|
|
"incident_projection_readback_verified": True,
|
|
"incident_row_version": (
|
|
readback["updated_at"].isoformat()
|
|
if readback.get("updated_at") is not None
|
|
else None
|
|
),
|
|
}
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_stdin_boundary_incident_projection_failed",
|
|
automation_run_id=automation_run_id,
|
|
terminal_op_id=terminal_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return None
|
|
|
|
|
|
async def _verify_stdin_boundary_terminal_projection_readback(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
terminal_op_id: str,
|
|
project_id: str,
|
|
require_projection_flag: bool,
|
|
) -> bool:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
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 EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log terminal
|
|
JOIN automation_operation_log replay
|
|
ON replay.op_id = terminal.parent_op_id
|
|
AND replay.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND replay.input ->> 'execution_mode'
|
|
= 'stdin_boundary_check_mode_replay'
|
|
JOIN automation_operation_log failed_check
|
|
ON failed_check.op_id = CAST(
|
|
NULLIF(
|
|
replay.input
|
|
->> 'replay_of_check_mode_op_id',
|
|
''
|
|
) AS uuid
|
|
)
|
|
AND failed_check.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND failed_check.status = 'failed'
|
|
AND failed_check.parent_op_id = replay.parent_op_id
|
|
AND failed_check.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
JOIN automation_operation_log candidate
|
|
ON candidate.op_id = replay.parent_op_id
|
|
AND candidate.operation_type
|
|
= 'ansible_candidate_matched'
|
|
AND coalesce(
|
|
nullif(
|
|
candidate.input
|
|
->> 'automation_run_id',
|
|
''
|
|
),
|
|
candidate.op_id::text
|
|
) = :automation_run_id
|
|
WHERE terminal.op_id
|
|
= CAST(:terminal_op_id AS uuid)
|
|
AND terminal.operation_type
|
|
= 'ansible_execution_skipped'
|
|
AND terminal.status IN ('success', 'failed')
|
|
AND terminal.input ->> 'execution_mode'
|
|
= 'stdin_boundary_check_mode_replay_terminal'
|
|
AND terminal.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND terminal.input
|
|
->> 'retry_of_check_mode_op_id'
|
|
= failed_check.op_id::text
|
|
AND replay.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND coalesce(
|
|
terminal.incident_id::text,
|
|
terminal.input ->> 'incident_id'
|
|
) = :incident_id
|
|
AND terminal.output ->> 'runtime_apply_executed'
|
|
= 'false'
|
|
AND (
|
|
SELECT COUNT(DISTINCT receipt.value
|
|
->> 'stage_id')
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
terminal.input
|
|
-> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' IN (
|
|
'mcp_context',
|
|
'service_log_evidence',
|
|
'normalized_asset_identity',
|
|
'source_truth_diff',
|
|
'risk_policy_decision',
|
|
'executor_log_projection',
|
|
'retry_or_rollback',
|
|
'km_playbook_writeback',
|
|
'rag_writeback',
|
|
'playbook_trust',
|
|
'timeline_projection'
|
|
)
|
|
AND receipt.value ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND receipt.value ->> 'durable_receipt'
|
|
= 'true'
|
|
) = 11
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
terminal.input
|
|
-> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) retry_receipt(value)
|
|
WHERE retry_receipt.value ->> 'stage_id'
|
|
= 'retry_or_rollback'
|
|
AND retry_receipt.value #>>
|
|
'{detail,verified_no_write_terminal}'
|
|
= 'true'
|
|
AND retry_receipt.value #>>
|
|
'{detail,runtime_apply_executed}'
|
|
= 'false'
|
|
AND retry_receipt.value #>>
|
|
'{detail,repository_readback_verified}'
|
|
= 'true'
|
|
AND retry_receipt.value
|
|
->> 'durable_receipt' = 'true'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
terminal.input
|
|
-> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) closure_receipt(value)
|
|
WHERE closure_receipt.value ->> 'stage_id'
|
|
= 'incident_closure'
|
|
AND closure_receipt.value
|
|
->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND closure_receipt.value #>>
|
|
'{detail,terminal_op_id}'
|
|
= CAST(:terminal_op_id AS text)
|
|
AND closure_receipt.value #>>
|
|
'{detail,no_write_terminal}' = 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,telegram_receipt_acknowledged}'
|
|
= 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,learning_writeback_acknowledged}'
|
|
= 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,incident_projection_readback_verified}'
|
|
= 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,repository_readback_verified}'
|
|
= 'true'
|
|
AND (
|
|
:require_projection_flag IS FALSE
|
|
OR closure_receipt.value #>>
|
|
'{detail,projection_readback_verified}'
|
|
= 'true'
|
|
)
|
|
AND closure_receipt.value
|
|
->> 'durable_receipt' = 'true'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM awooop_outbound_message outbound
|
|
WHERE outbound.project_id = :project_id
|
|
AND outbound.channel_type = 'telegram'
|
|
AND (
|
|
(
|
|
outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id
|
|
IS NOT NULL
|
|
)
|
|
OR (
|
|
outbound.send_status = 'shadow'
|
|
AND outbound.provider_message_id
|
|
IS NULL
|
|
AND outbound.source_envelope #>>
|
|
'{notification_policy,disposition}'
|
|
= 'suppressed'
|
|
)
|
|
)
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,execution_kind}'
|
|
= 'no_write_replay'
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,automation_run_id}'
|
|
= :automation_run_id
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,incident_id}'
|
|
= :incident_id
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,apply_op_id}'
|
|
= CAST(:terminal_op_id AS text)
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text
|
|
= 'AUTO_REPAIR_TRIGGERED'
|
|
AND lifecycle.context
|
|
->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= CAST(:terminal_op_id AS text)
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text
|
|
= 'EXECUTION_STARTED'
|
|
AND lifecycle.context
|
|
->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= CAST(:terminal_op_id AS text)
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND lifecycle.event_type::text
|
|
= 'EXECUTION_COMPLETED'
|
|
AND lifecycle.success IS FALSE
|
|
AND lifecycle.context
|
|
->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= CAST(:terminal_op_id AS text)
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = :incident_id
|
|
AND (
|
|
lifecycle.event_type::text
|
|
= 'TELEGRAM_RESULT_SENT'
|
|
OR (
|
|
lifecycle.event_type::text
|
|
= 'NOTIFICATION_CLASSIFIED'
|
|
AND lifecycle.action_detail
|
|
= 'stdin_boundary_no_write_result_receipt_suppressed'
|
|
)
|
|
)
|
|
AND lifecycle.context
|
|
->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= CAST(:terminal_op_id AS text)
|
|
)
|
|
)
|
|
"""),
|
|
{
|
|
"terminal_op_id": terminal_op_id,
|
|
"automation_run_id": automation_run_id,
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
"require_projection_flag": require_projection_flag,
|
|
},
|
|
)
|
|
return result.scalar() is True
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_stdin_boundary_projection_readback_failed",
|
|
automation_run_id=automation_run_id,
|
|
terminal_op_id=terminal_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return False
|
|
|
|
|
|
async def backfill_missing_stdin_boundary_terminal_projections_once(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
window_hours: int = _STDIN_BOUNDARY_REPLAY_WINDOW_HOURS,
|
|
limit: int = 1,
|
|
) -> dict[str, Any]:
|
|
"""Close no-write replay attempts without re-executing their apply."""
|
|
|
|
stats: dict[str, Any] = {
|
|
"scanned": 0,
|
|
"written": 0,
|
|
"learning_written": 0,
|
|
"telegram_receipt_acknowledged": 0,
|
|
"telegram_notification_suppressed": 0,
|
|
"incident_receipt_written": 0,
|
|
"lifecycle_written": 0,
|
|
"verified": 0,
|
|
"runtime_apply_executed": False,
|
|
"blockers": [],
|
|
"error": None,
|
|
}
|
|
try:
|
|
rows = await _load_missing_stdin_boundary_terminal_projection_rows(
|
|
project_id=project_id,
|
|
window_hours=window_hours,
|
|
limit=limit,
|
|
)
|
|
stats["scanned"] = len(rows)
|
|
for row in rows:
|
|
claim = _claim_from_stdin_boundary_terminal_row(row)
|
|
terminal_op_id = str(row.get("terminal_op_id") or "")
|
|
terminal_output = _json_loads(row.get("terminal_output"))
|
|
if claim is None or not terminal_op_id:
|
|
stats["blockers"].append("terminal_claim_reconstruction_failed")
|
|
continue
|
|
returncode = _int_from_value(
|
|
terminal_output.get("check_mode_replay_returncode"),
|
|
default=(
|
|
0
|
|
if str(row.get("terminal_status") or "") == "success"
|
|
else 1
|
|
),
|
|
)
|
|
replay_result = AnsibleRunResult(
|
|
returncode=returncode,
|
|
stdout="",
|
|
stderr=str(row.get("terminal_error") or ""),
|
|
duration_ms=_int_from_value(
|
|
row.get("terminal_duration_ms"),
|
|
default=0,
|
|
),
|
|
post_verifier_passed=False,
|
|
)
|
|
learning = await _record_no_write_replay_learning_receipts(
|
|
claim,
|
|
replay_result,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
)
|
|
if learning.get("ready") is not True:
|
|
missing = ",".join(learning.get("missing_stage_ids") or [])
|
|
stats["blockers"].append(
|
|
f"learning_receipts_missing:{missing}"[:300]
|
|
)
|
|
continue
|
|
learning_written = (
|
|
await _append_runtime_stage_receipts_to_operation(
|
|
operation_id=terminal_op_id,
|
|
operation_type="ansible_execution_skipped",
|
|
receipts=tuple(learning.get("receipts") or ()),
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
stats["learning_written"] += int(learning_written)
|
|
if not learning_written:
|
|
stats["blockers"].append("learning_receipt_append_failed")
|
|
continue
|
|
|
|
telegram_disposition = (
|
|
await _stdin_boundary_replay_telegram_notification_disposition(
|
|
claim,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
if telegram_disposition == "missing":
|
|
receipt_sent = await _send_controlled_apply_telegram_receipt(
|
|
claim,
|
|
replay_result,
|
|
apply_op_id=terminal_op_id,
|
|
writeback={
|
|
"verification_result": "failed",
|
|
"verification": True,
|
|
"learning": True,
|
|
},
|
|
project_id=project_id,
|
|
execution_kind="no_write_replay",
|
|
provider_delivery="shadow_only",
|
|
)
|
|
if receipt_sent:
|
|
telegram_disposition = (
|
|
await _stdin_boundary_replay_telegram_notification_disposition(
|
|
claim,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
if telegram_disposition not in {"sent", "suppressed"}:
|
|
stats["blockers"].append("telegram_receipt_pending")
|
|
continue
|
|
stats["telegram_receipt_acknowledged"] += 1
|
|
stats["telegram_notification_suppressed"] += int(
|
|
telegram_disposition == "suppressed"
|
|
)
|
|
|
|
trigger_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"AUTO_REPAIR_TRIGGERED",
|
|
apply_op_id=terminal_op_id,
|
|
success=True,
|
|
action_detail=(
|
|
"stdin_boundary_no_write_replay_triggered"
|
|
),
|
|
project_id=project_id,
|
|
post_verifier_passed=None,
|
|
)
|
|
started_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"EXECUTION_STARTED",
|
|
apply_op_id=terminal_op_id,
|
|
success=True,
|
|
action_detail=(
|
|
"stdin_boundary_no_write_replay_started"
|
|
),
|
|
project_id=project_id,
|
|
post_verifier_passed=None,
|
|
)
|
|
execution_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"EXECUTION_COMPLETED",
|
|
apply_op_id=terminal_op_id,
|
|
success=False,
|
|
action_detail=(
|
|
"stdin_boundary_replay_verified_no_write_terminal"
|
|
),
|
|
project_id=project_id,
|
|
error_message="ai_transport_or_playbook_repair_required",
|
|
post_verifier_passed=False,
|
|
)
|
|
telegram_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
(
|
|
"NOTIFICATION_CLASSIFIED"
|
|
if telegram_disposition == "suppressed"
|
|
else "TELEGRAM_RESULT_SENT"
|
|
),
|
|
apply_op_id=terminal_op_id,
|
|
success=False,
|
|
action_detail=(
|
|
"stdin_boundary_no_write_result_receipt_suppressed"
|
|
if telegram_disposition == "suppressed"
|
|
else "stdin_boundary_no_write_result_receipt_sent"
|
|
),
|
|
project_id=project_id,
|
|
post_verifier_passed=False,
|
|
)
|
|
if not all((
|
|
trigger_lifecycle,
|
|
started_lifecycle,
|
|
execution_lifecycle,
|
|
telegram_lifecycle,
|
|
)):
|
|
stats["blockers"].append("lifecycle_projection_pending")
|
|
continue
|
|
stats["lifecycle_written"] += 1
|
|
|
|
incident_detail = (
|
|
await _record_stdin_boundary_incident_terminal_disposition(
|
|
claim,
|
|
terminal_op_id=terminal_op_id,
|
|
learning_stage_ids=list(learning["stage_ids"]),
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
if incident_detail is None:
|
|
stats["blockers"].append("incident_projection_pending")
|
|
continue
|
|
closure_detail = {
|
|
**incident_detail,
|
|
"terminal_op_id": terminal_op_id,
|
|
"retry_check_mode_op_id": claim.op_id,
|
|
"check_mode_replay_returncode": replay_result.returncode,
|
|
"telegram_notification_disposition": telegram_disposition,
|
|
"runtime_apply_executed": False,
|
|
"verified_no_write_terminal": True,
|
|
"telegram_receipt_acknowledged": True,
|
|
"learning_writeback_acknowledged": True,
|
|
"required_learning_stage_ids": sorted(
|
|
_NO_WRITE_REPLAY_REQUIRED_STAGE_IDS
|
|
),
|
|
"projection_readback_verified": False,
|
|
"repository_readback_verified": True,
|
|
"safe_next_action": (
|
|
"queue_ai_transport_or_playbook_repair_candidate"
|
|
),
|
|
}
|
|
closure_receipt = _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="incident_closure",
|
|
evidence_ref=(
|
|
f"automation_operation_log:{terminal_op_id}:"
|
|
"no_write_terminal"
|
|
),
|
|
detail=closure_detail,
|
|
derived_from_durable_chain=True,
|
|
)
|
|
incident_receipt_written = (
|
|
await _append_runtime_stage_receipts_to_operation(
|
|
operation_id=terminal_op_id,
|
|
operation_type="ansible_execution_skipped",
|
|
receipts=(closure_receipt,),
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
stats["incident_receipt_written"] += int(
|
|
incident_receipt_written
|
|
)
|
|
if not incident_receipt_written:
|
|
stats["blockers"].append("incident_receipt_append_failed")
|
|
continue
|
|
verified = await _verify_stdin_boundary_terminal_projection_readback(
|
|
claim,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
require_projection_flag=False,
|
|
)
|
|
if not verified:
|
|
stats["blockers"].append("projection_readback_pending")
|
|
continue
|
|
final_receipt = _runtime_stage_receipt(
|
|
claim,
|
|
stage_id="incident_closure",
|
|
evidence_ref=(
|
|
f"automation_operation_log:{terminal_op_id}:"
|
|
"no_write_terminal"
|
|
),
|
|
detail={
|
|
**closure_detail,
|
|
"projection_readback_verified": True,
|
|
},
|
|
derived_from_durable_chain=True,
|
|
)
|
|
finalized = await _append_runtime_stage_receipts_to_operation(
|
|
operation_id=terminal_op_id,
|
|
operation_type="ansible_execution_skipped",
|
|
receipts=(final_receipt,),
|
|
project_id=project_id,
|
|
)
|
|
if not finalized:
|
|
stats["blockers"].append("projection_finalization_pending")
|
|
continue
|
|
final_verified = (
|
|
await _verify_stdin_boundary_terminal_projection_readback(
|
|
claim,
|
|
terminal_op_id=terminal_op_id,
|
|
project_id=project_id,
|
|
require_projection_flag=True,
|
|
)
|
|
)
|
|
stats["verified"] += int(final_verified)
|
|
stats["written"] += int(final_verified)
|
|
if not final_verified:
|
|
stats["blockers"].append("final_projection_readback_pending")
|
|
except Exception as exc:
|
|
stats["error"] = f"{type(exc).__name__}: {exc}"[:500]
|
|
logger.warning(
|
|
"ansible_stdin_boundary_projection_backfill_failed",
|
|
project_id=project_id,
|
|
**stats,
|
|
)
|
|
stats["blockers"] = sorted(set(stats["blockers"]))
|
|
return stats
|
|
|
|
|
|
async def _record_learning_writeback_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
verification_result: str,
|
|
action_label: str,
|
|
project_id: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Persist canonical PlayBook trust and its idempotent learning receipt."""
|
|
|
|
del result, action_label
|
|
return await record_ansible_playbook_trust_writeback(
|
|
project_id=project_id,
|
|
automation_run_id=_automation_run_id_for_claim(claim),
|
|
incident_id=claim.incident_id,
|
|
incident_db_id=_automation_operation_log_incident_id(claim.incident_id),
|
|
catalog_id=claim.catalog_id,
|
|
playbook_path=claim.apply_playbook_path,
|
|
apply_op_id=apply_op_id,
|
|
verification_result=verification_result,
|
|
)
|
|
|
|
|
|
async def _record_apply_post_verifier_terminal(
|
|
*,
|
|
apply_op_id: str,
|
|
verification_passed: bool,
|
|
verification_result: str,
|
|
verifier_receipt: Mapping[str, Any],
|
|
project_id: str,
|
|
) -> bool:
|
|
public_receipt = {
|
|
"schema_version": verifier_receipt.get("schema_version"),
|
|
"verifier": verifier_receipt.get("verifier"),
|
|
"independent_source": verifier_receipt.get("independent_source"),
|
|
"verifier_source_sha": verifier_receipt.get("verifier_source_sha"),
|
|
"verification_result": verification_result,
|
|
"all_postconditions_passed": (
|
|
verifier_receipt.get("all_postconditions_passed") is True
|
|
),
|
|
"required_postcondition_count": int(
|
|
verifier_receipt.get("required_postcondition_count") or 0
|
|
),
|
|
"passed_postcondition_count": int(
|
|
verifier_receipt.get("passed_postcondition_count") or 0
|
|
),
|
|
"executor_returncode_trusted": False,
|
|
"raw_output_stored": False,
|
|
}
|
|
try:
|
|
async with get_db_context(project_id) as db:
|
|
updated = await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log
|
|
SET status = CASE
|
|
WHEN :verification_passed THEN status
|
|
ELSE 'failed'
|
|
END,
|
|
output = coalesce(output, '{}'::jsonb) || CAST(
|
|
:verifier_terminal AS jsonb
|
|
),
|
|
error = CASE
|
|
WHEN :verification_passed THEN error
|
|
ELSE coalesce(error, 'independent_post_verifier_failed')
|
|
END
|
|
WHERE op_id = CAST(:apply_op_id AS uuid)
|
|
AND operation_type = 'ansible_apply_executed'
|
|
"""),
|
|
{
|
|
"verification_passed": verification_passed,
|
|
"verifier_terminal": json.dumps({
|
|
"independent_post_verifier_passed": verification_passed,
|
|
"independent_post_verifier": public_receipt,
|
|
}, ensure_ascii=False),
|
|
"apply_op_id": apply_op_id,
|
|
},
|
|
)
|
|
return bool(updated.rowcount)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_apply_post_verifier_terminal_write_failed",
|
|
apply_op_id=apply_op_id,
|
|
verification_result=verification_result,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return False
|
|
|
|
|
|
async def _record_post_apply_verifier_and_learning(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
project_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Persist post-apply verifier evidence and KM learning for Ansible apply."""
|
|
|
|
action_label = _post_apply_action_label(claim, apply_op_id=apply_op_id)
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
try:
|
|
verifier_receipt = await run_ansible_asset_post_verifier(
|
|
catalog_id=claim.catalog_id,
|
|
automation_run_id=automation_run_id,
|
|
apply_op_id=apply_op_id,
|
|
inventory_hosts=claim.inventory_hosts,
|
|
executor_returncode=result.returncode,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"ansible_independent_post_verifier_failed",
|
|
incident_id=claim.incident_id,
|
|
catalog_id=claim.catalog_id,
|
|
apply_op_id=apply_op_id,
|
|
error_type=type(exc).__name__,
|
|
)
|
|
verifier_receipt = {
|
|
"schema_version": "awoooi_ansible_asset_post_verifier_v1",
|
|
"verifier": "asset_specific_read_only_host_postconditions",
|
|
"independent_source": "broker_ssh_host_runtime_readback",
|
|
"automation_run_id": automation_run_id,
|
|
"apply_op_id": apply_op_id,
|
|
"catalog_id": claim.catalog_id,
|
|
"verification_result": "failed",
|
|
"all_postconditions_passed": False,
|
|
"required_postcondition_count": 0,
|
|
"passed_postcondition_count": 0,
|
|
"postconditions": [],
|
|
"active_blockers": [
|
|
f"independent_post_verifier_error:{type(exc).__name__}"
|
|
],
|
|
"executor_returncode": result.returncode,
|
|
"executor_returncode_trusted": False,
|
|
"raw_output_stored": False,
|
|
"writes_on_verify": False,
|
|
}
|
|
verification_result = _post_apply_verification_result(
|
|
result,
|
|
verifier_receipt,
|
|
)
|
|
post_state = {
|
|
**verifier_receipt,
|
|
"playbook_path": claim.apply_playbook_path,
|
|
"timed_out": result.timed_out,
|
|
"action_taken": action_label,
|
|
"next_required_step": (
|
|
"km_playbook_trust_writeback"
|
|
if verification_result == "success"
|
|
else "automatic_retry_or_rollback"
|
|
),
|
|
}
|
|
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}"
|
|
)
|
|
canonical_playbook_id = canonical_ansible_playbook_id(claim.catalog_id)
|
|
status: dict[str, Any] = {
|
|
"verification": False,
|
|
"verification_passed": False,
|
|
"verification_result": verification_result,
|
|
"independent_verifier": verifier_receipt,
|
|
"learning": False,
|
|
"km_writeback": None,
|
|
"trust_learning": False,
|
|
"rag_writeback": False,
|
|
"runtime_stage_receipts": [],
|
|
}
|
|
|
|
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,
|
|
:sensors_attempted,
|
|
: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": canonical_playbook_id,
|
|
"mcp_health": json.dumps({
|
|
"asset_specific_read_only_host_postconditions": (
|
|
verification_result == "success"
|
|
)
|
|
}, ensure_ascii=False),
|
|
"collection_duration_ms": sum(
|
|
max(0, int(item.get("duration_ms") or 0))
|
|
for item in verifier_receipt.get("postconditions") or []
|
|
if isinstance(item, Mapping)
|
|
),
|
|
"sensors_attempted": int(
|
|
verifier_receipt.get("required_postcondition_count") or 0
|
|
),
|
|
"sensors_succeeded": int(
|
|
verifier_receipt.get("passed_postcondition_count") or 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,
|
|
},
|
|
)
|
|
verifier_evidence_id = inserted.scalar()
|
|
if verifier_evidence_id is None:
|
|
existing = await db.execute(
|
|
text("""
|
|
SELECT id
|
|
FROM incident_evidence
|
|
WHERE incident_id = CAST(:incident_id AS varchar(30))
|
|
AND post_execution_state ->> 'apply_op_id' = CAST(
|
|
:apply_op_id AS text
|
|
)
|
|
AND post_execution_state ->> 'schema_version'
|
|
= 'awoooi_ansible_asset_post_verifier_v1'
|
|
AND verification_result = :verification_result
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"apply_op_id": apply_op_id,
|
|
"verification_result": verification_result,
|
|
},
|
|
)
|
|
verifier_evidence_id = existing.scalar()
|
|
status["verification"] = verifier_evidence_id 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),
|
|
)
|
|
|
|
verification_passed = bool(
|
|
verification_result == "success"
|
|
and status["verification"] is True
|
|
)
|
|
terminal_written = await _record_apply_post_verifier_terminal(
|
|
apply_op_id=apply_op_id,
|
|
verification_passed=verification_passed,
|
|
verification_result=verification_result,
|
|
verifier_receipt=verifier_receipt,
|
|
project_id=project_id,
|
|
)
|
|
verification_passed = bool(verification_passed and terminal_written)
|
|
if not verification_passed:
|
|
verification_result = (
|
|
"timeout" if verification_result == "timeout" else "failed"
|
|
)
|
|
status["verification_passed"] = verification_passed
|
|
status["verification_result"] = verification_result
|
|
status["apply_terminal_written"] = terminal_written
|
|
|
|
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:
|
|
path_type = _post_apply_km_path_type(apply_op_id)
|
|
existing = await db.execute(
|
|
text("""
|
|
SELECT id
|
|
FROM knowledge_entries
|
|
WHERE related_incident_id = :incident_id
|
|
AND path_type = :path_type
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"path_type": path_type,
|
|
},
|
|
)
|
|
if existing.scalar() is None:
|
|
repo = KnowledgeDBRepository(db)
|
|
await repo.create(
|
|
KnowledgeEntryCreate(
|
|
title=f"AI 自動修復沉澱:{claim.incident_id}",
|
|
content=(
|
|
"AI Agent 已執行 Ansible controlled apply 並寫入驗證摘要。\n\n"
|
|
"- Automation run: "
|
|
f"{claim.input_payload.get('automation_run_id') or claim.source_candidate_op_id}\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 ""),
|
|
(
|
|
"automation_run_id:"
|
|
f"{claim.input_payload.get('automation_run_id') or claim.source_candidate_op_id}"
|
|
),
|
|
],
|
|
source=EntrySource.AI_EXTRACTED,
|
|
status=EntryStatus.REVIEW,
|
|
related_incident_id=claim.incident_id,
|
|
related_playbook_id=canonical_playbook_id,
|
|
path_type=path_type,
|
|
created_by="ai_agent_ansible_worker",
|
|
)
|
|
)
|
|
verified_km = await db.execute(
|
|
text("""
|
|
SELECT
|
|
id,
|
|
related_playbook_id,
|
|
path_type,
|
|
status,
|
|
updated_at
|
|
FROM knowledge_entries
|
|
WHERE related_incident_id = :incident_id
|
|
AND path_type = :path_type
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"incident_id": claim.incident_id,
|
|
"path_type": path_type,
|
|
},
|
|
)
|
|
km_row = verified_km.mappings().first()
|
|
if (
|
|
km_row
|
|
and str(km_row.get("id") or "")
|
|
and str(km_row.get("related_playbook_id") or "")
|
|
== canonical_playbook_id
|
|
and str(km_row.get("path_type") or "") == path_type
|
|
and km_row.get("updated_at") is not None
|
|
):
|
|
status["learning"] = True
|
|
status["km_writeback"] = {
|
|
"schema_version": "ansible_km_writeback_v1",
|
|
"knowledge_entry_id": str(km_row["id"]),
|
|
"canonical_playbook_id": canonical_playbook_id,
|
|
"catalog_id": claim.catalog_id,
|
|
"path_type": path_type,
|
|
"status": str(km_row.get("status") or ""),
|
|
"km_row_version": km_row["updated_at"].isoformat(),
|
|
"repository_write_acknowledged": True,
|
|
"repository_readback_verified": True,
|
|
"durable_write_acknowledged": True,
|
|
"writer_source_sha": os.getenv(
|
|
"AWOOOI_BUILD_COMMIT_SHA",
|
|
"",
|
|
).strip().lower()
|
|
or None,
|
|
"raw_log_payload_stored": False,
|
|
"secret_value_stored": False,
|
|
}
|
|
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),
|
|
)
|
|
km_writeback = status.get("km_writeback")
|
|
if isinstance(km_writeback, Mapping):
|
|
status["runtime_stage_receipts"].append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="km_playbook_writeback",
|
|
evidence_ref=(
|
|
"knowledge_entries:"
|
|
f"{km_writeback['knowledge_entry_id']}:row_version"
|
|
),
|
|
detail=dict(km_writeback),
|
|
)
|
|
)
|
|
trust_writeback = await _record_learning_writeback_receipt(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
verification_result=verification_result,
|
|
action_label=action_label,
|
|
project_id=project_id,
|
|
)
|
|
status["trust_learning"] = trust_writeback is not None
|
|
if trust_writeback is not None:
|
|
status["runtime_stage_receipts"].append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="playbook_trust",
|
|
evidence_ref=(
|
|
"playbooks:"
|
|
f"{trust_writeback['canonical_playbook_id']}:trust"
|
|
),
|
|
detail=trust_writeback,
|
|
)
|
|
)
|
|
rag_writeback = (
|
|
await ensure_ansible_rag_writeback(
|
|
project_id=project_id,
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
)
|
|
if status["learning"]
|
|
else None
|
|
)
|
|
status["rag_writeback"] = rag_writeback is not None
|
|
if rag_writeback is not None:
|
|
if rag_writeback.get("embedding_persisted") is True:
|
|
rag_evidence_ref = (
|
|
"knowledge_entries:"
|
|
f"{rag_writeback['knowledge_entry_id']}:embedding"
|
|
)
|
|
else:
|
|
rag_evidence_ref = (
|
|
"rag_chunks:"
|
|
f"{rag_writeback.get('source_id', '')}:"
|
|
f"count={rag_writeback.get('rag_chunk_count', 0)}"
|
|
)
|
|
status["runtime_stage_receipts"].append(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="rag_writeback",
|
|
evidence_ref=rag_evidence_ref,
|
|
detail=rag_writeback,
|
|
)
|
|
)
|
|
return status
|
|
|
|
|
|
async def _send_controlled_apply_telegram_receipt(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
writeback: dict[str, Any],
|
|
project_id: str,
|
|
execution_kind: str = "controlled_apply",
|
|
provider_delivery: str = "digest",
|
|
) -> bool:
|
|
try:
|
|
from src.services.telegram_gateway import get_telegram_gateway
|
|
|
|
effective_provider_delivery = str(
|
|
claim.input_payload.get("telegram_provider_delivery")
|
|
or provider_delivery
|
|
)
|
|
response = await get_telegram_gateway().send_controlled_apply_result_receipt(
|
|
automation_run_id=str(
|
|
claim.input_payload.get("automation_run_id")
|
|
or claim.source_candidate_op_id
|
|
),
|
|
incident_id=claim.incident_id,
|
|
catalog_id=claim.catalog_id,
|
|
apply_op_id=apply_op_id,
|
|
playbook_path=claim.apply_playbook_path,
|
|
verification_result=str(
|
|
writeback.get("verification_result") or "failed"
|
|
),
|
|
returncode=result.returncode,
|
|
duration_ms=result.duration_ms,
|
|
verifier_written=bool(writeback.get("verification")),
|
|
learning_written=bool(writeback.get("learning")),
|
|
execution_kind=execution_kind,
|
|
provider_delivery=effective_provider_delivery,
|
|
project_id=project_id,
|
|
)
|
|
return bool(
|
|
isinstance(response, Mapping)
|
|
and response.get("ok") is True
|
|
and response.get("_awooop_outbound_mirror_acknowledged") is True
|
|
)
|
|
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 _reconcile_verified_apply_closure_projections(
|
|
claim: AnsibleCheckModeClaim,
|
|
result: AnsibleRunResult,
|
|
*,
|
|
apply_op_id: str,
|
|
writeback: dict[str, Any],
|
|
project_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Repair terminal projections without ever executing the apply again."""
|
|
|
|
verified_success = bool(
|
|
result.returncode == 0
|
|
and result.post_verifier_passed is True
|
|
and writeback.get("verification_passed") is True
|
|
)
|
|
if not verified_success:
|
|
telegram_receipt = await _retry_telegram_receipt_acknowledged(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
if not telegram_receipt:
|
|
receipt_finalized = await _send_controlled_apply_telegram_receipt(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
writeback=writeback,
|
|
project_id=project_id,
|
|
)
|
|
if receipt_finalized:
|
|
telegram_receipt = await _retry_telegram_receipt_acknowledged(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
telegram_lifecycle = False
|
|
if telegram_receipt:
|
|
telegram_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"TELEGRAM_RESULT_SENT",
|
|
apply_op_id=apply_op_id,
|
|
success=False,
|
|
action_detail="controlled_apply_result_receipt_sent",
|
|
project_id=project_id,
|
|
post_verifier_passed=result.post_verifier_passed,
|
|
)
|
|
return {
|
|
"status": "not_verified_success",
|
|
"closed": False,
|
|
"telegram_receipt_acknowledged": telegram_receipt,
|
|
"telegram_lifecycle_written": telegram_lifecycle,
|
|
"runtime_apply_executed": False,
|
|
}
|
|
|
|
approval_projection = await _finalize_controlled_approval_projection(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
execution_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"EXECUTION_COMPLETED",
|
|
apply_op_id=apply_op_id,
|
|
success=True,
|
|
action_detail=f"controlled_apply_completed:{claim.catalog_id}",
|
|
project_id=project_id,
|
|
post_verifier_passed=True,
|
|
)
|
|
|
|
readback = await _read_verified_apply_closure_prerequisites(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
telegram_receipt = bool(
|
|
(readback.get("receipts") or {}).get("telegram_receipt")
|
|
)
|
|
if not telegram_receipt:
|
|
await _send_controlled_apply_telegram_receipt(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
writeback=writeback,
|
|
project_id=project_id,
|
|
)
|
|
readback = await _read_verified_apply_closure_prerequisites(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
telegram_receipt = bool(
|
|
(readback.get("receipts") or {}).get("telegram_receipt")
|
|
)
|
|
|
|
telegram_lifecycle = False
|
|
if telegram_receipt:
|
|
telegram_lifecycle = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"TELEGRAM_RESULT_SENT",
|
|
apply_op_id=apply_op_id,
|
|
success=True,
|
|
action_detail="controlled_apply_result_receipt_sent",
|
|
project_id=project_id,
|
|
post_verifier_passed=True,
|
|
)
|
|
|
|
closure = await _finalize_verified_apply_closure(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
return {
|
|
**closure,
|
|
"approval_projection_written": (
|
|
approval_projection or not _approval_id_for_claim(claim)
|
|
),
|
|
"execution_lifecycle_written": execution_lifecycle,
|
|
"telegram_receipt_acknowledged": telegram_receipt,
|
|
"telegram_lifecycle_written": telegram_lifecycle,
|
|
"runtime_apply_executed": 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,
|
|
"trust_learning_written": 0,
|
|
"rag_writeback_written": 0,
|
|
"playbook_trust_written": 0,
|
|
"timeline_projection_written": 0,
|
|
"runtime_stage_receipts_written": 0,
|
|
"incident_closure_written": 0,
|
|
"telegram_receipt_acknowledged": 0,
|
|
"retry_terminal_projection_scanned": 0,
|
|
"retry_terminal_projection_written": 0,
|
|
"retry_terminal_projection_retry_receipt_written": 0,
|
|
"retry_terminal_projection_telegram_receipt_acknowledged": 0,
|
|
"retry_terminal_projection_incident_receipt_written": 0,
|
|
"retry_terminal_projection_lifecycle_written": 0,
|
|
"retry_terminal_projection_verified": 0,
|
|
"retry_terminal_projection_runtime_apply_executed": False,
|
|
"skipped": 0,
|
|
"error": None,
|
|
}
|
|
try:
|
|
projection = await backfill_missing_retry_terminal_projections_once(
|
|
project_id=project_id,
|
|
window_hours=window_hours,
|
|
limit=max(1, min(limit, 5)),
|
|
)
|
|
stats["retry_terminal_projection_scanned"] = int(
|
|
projection.get("scanned") or 0
|
|
)
|
|
stats["retry_terminal_projection_written"] = int(
|
|
projection.get("written") or 0
|
|
)
|
|
stats["retry_terminal_projection_retry_receipt_written"] = int(
|
|
projection.get("retry_receipt_written") or 0
|
|
)
|
|
stats[
|
|
"retry_terminal_projection_telegram_receipt_acknowledged"
|
|
] = int(projection.get("telegram_receipt_acknowledged") or 0)
|
|
stats["retry_terminal_projection_incident_receipt_written"] = int(
|
|
projection.get("incident_receipt_written") or 0
|
|
)
|
|
stats["retry_terminal_projection_lifecycle_written"] = int(
|
|
projection.get("lifecycle_written") or 0
|
|
)
|
|
stats["retry_terminal_projection_verified"] = int(
|
|
projection.get("verified") or 0
|
|
)
|
|
projection_error = str(projection.get("error") or "")[:500]
|
|
stats["scanned"] = stats["retry_terminal_projection_scanned"]
|
|
stats["incident_closure_written"] = stats[
|
|
"retry_terminal_projection_written"
|
|
]
|
|
stats["error"] = projection_error or None
|
|
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,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM incident_evidence verifier
|
|
WHERE verifier.incident_id = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND verifier.post_execution_state ->> 'apply_op_id' = apply.op_id::text
|
|
AND verifier.verification_result = 'success'
|
|
) AS verifier_ready,
|
|
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)
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log learning
|
|
WHERE learning.operation_type = 'ansible_learning_writeback_recorded'
|
|
AND learning.parent_op_id = apply.op_id
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) AS receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' = 'timeline_projection'
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) AS receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' = 'playbook_trust'
|
|
)
|
|
OR (
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) AS receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' = 'rag_writeback'
|
|
)
|
|
AND (
|
|
apply.input -> 'rag_writeback_attempt' IS NULL
|
|
OR NULLIF(
|
|
apply.input -> 'rag_writeback_attempt' ->> 'attempted_at',
|
|
''
|
|
)::timestamptz <= NOW() - INTERVAL '5 minutes'
|
|
)
|
|
)
|
|
OR (
|
|
apply.status = 'success'
|
|
AND (
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input
|
|
-> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) AS receipt(value)
|
|
WHERE receipt.value ->> 'stage_id'
|
|
= 'incident_closure'
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM incidents incident
|
|
WHERE incident.incident_id = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND incident.project_id = :project_id
|
|
AND (
|
|
incident.resolved_at IS NULL
|
|
OR upper(incident.status::text)
|
|
NOT IN ('RESOLVED', 'CLOSED')
|
|
)
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND lifecycle.event_type::text
|
|
= 'RESOLVED'
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= apply.op_id::text
|
|
)
|
|
)
|
|
)
|
|
)
|
|
ORDER BY apply.created_at DESC
|
|
LIMIT :limit
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"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
|
|
writeback = await _record_post_apply_verifier_and_learning(
|
|
claim,
|
|
result,
|
|
apply_op_id=str(row.get("op_id") or ""),
|
|
project_id=project_id,
|
|
)
|
|
verified_result = replace(
|
|
result,
|
|
post_verifier_passed=(
|
|
writeback.get("verification_passed") is True
|
|
),
|
|
)
|
|
if await _record_auto_repair_execution_receipt(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=str(row.get("op_id") or ""),
|
|
project_id=project_id,
|
|
):
|
|
stats["written"] += 1
|
|
else:
|
|
stats["skipped"] += 1
|
|
if writeback.get("verification"):
|
|
stats["verification_written"] += 1
|
|
if writeback.get("learning"):
|
|
stats["learning_written"] += 1
|
|
if writeback.get("trust_learning"):
|
|
stats["trust_learning_written"] += 1
|
|
stats["playbook_trust_written"] += 1
|
|
if writeback.get("rag_writeback"):
|
|
stats["rag_writeback_written"] += 1
|
|
timeline_receipt = await _record_timeline_projection_receipt(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=str(row.get("op_id") or ""),
|
|
project_id=project_id,
|
|
derived_from_durable_chain=True,
|
|
)
|
|
if timeline_receipt is not None:
|
|
stats["timeline_projection_written"] += 1
|
|
learning_receipts = tuple(
|
|
receipt
|
|
for receipt in writeback.get("runtime_stage_receipts", [])
|
|
if isinstance(receipt, dict)
|
|
)
|
|
if await _record_runtime_stage_receipts(
|
|
claim,
|
|
result,
|
|
apply_op_id=str(row.get("op_id") or ""),
|
|
verifier_ready=bool(writeback.get("verification_passed")),
|
|
project_id=project_id,
|
|
derived_from_durable_chain=True,
|
|
extra_receipts=(
|
|
*((timeline_receipt,) if timeline_receipt else ()),
|
|
*learning_receipts,
|
|
),
|
|
):
|
|
stats["runtime_stage_receipts_written"] += 1
|
|
closure = await _reconcile_verified_apply_closure_projections(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=str(row.get("op_id") or ""),
|
|
writeback=writeback,
|
|
project_id=project_id,
|
|
)
|
|
if closure.get("telegram_receipt_acknowledged") is True:
|
|
stats["telegram_receipt_acknowledged"] += 1
|
|
if closure.get("closed") is True:
|
|
stats["incident_closure_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 _load_open_failed_apply_retry_row(
|
|
*,
|
|
project_id: str,
|
|
window_hours: int,
|
|
stale_after_seconds: int | None = None,
|
|
retry_backoff_seconds: int = _CONTROLLED_RETRY_ERROR_BACKOFF_MIN_SECONDS,
|
|
) -> dict[str, Any] | None:
|
|
"""Read one durable failed-apply retry candidate before transport checks."""
|
|
|
|
async with get_db_context(project_id) as db:
|
|
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,
|
|
replay.op_id::text AS stale_replay_op_id,
|
|
replay.input AS stale_replay_input,
|
|
replay.created_at AS stale_replay_created_at,
|
|
CASE
|
|
WHEN replay.input ->> 'retry_recovery_count'
|
|
~ '^[0-9]+$'
|
|
THEN (replay.input
|
|
->> 'retry_recovery_count')::integer
|
|
ELSE 0
|
|
END AS stale_replay_recovery_count
|
|
FROM automation_operation_log apply
|
|
LEFT JOIN LATERAL (
|
|
SELECT pending.op_id, pending.input, pending.status,
|
|
pending.created_at
|
|
FROM automation_operation_log pending
|
|
WHERE pending.operation_type
|
|
= 'ansible_execution_skipped'
|
|
AND pending.parent_op_id = apply.op_id
|
|
AND pending.input ->> 'execution_mode'
|
|
= 'controlled_retry_check_mode_replay'
|
|
ORDER BY pending.created_at DESC, pending.op_id DESC
|
|
LIMIT 1
|
|
) replay ON TRUE
|
|
WHERE apply.operation_type = 'ansible_apply_executed'
|
|
AND apply.status = 'failed'
|
|
AND apply.created_at >= NOW() - (
|
|
:window_hours * INTERVAL '1 hour'
|
|
)
|
|
AND apply.created_at <= NOW() - (
|
|
:retry_backoff_seconds * INTERVAL '1 second'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM incident_evidence verifier
|
|
WHERE verifier.incident_id = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND verifier.post_execution_state ->> 'apply_op_id'
|
|
= apply.op_id::text
|
|
AND verifier.verification_result IN ('failed', 'timeout')
|
|
)
|
|
AND (
|
|
replay.op_id IS NULL
|
|
OR (
|
|
replay.status = 'pending'
|
|
AND coalesce(
|
|
nullif(
|
|
replay.input ->> 'retry_claimed_at',
|
|
''
|
|
)::timestamptz,
|
|
replay.created_at
|
|
) <= NOW() - (
|
|
:stale_after_seconds * INTERVAL '1 second'
|
|
)
|
|
)
|
|
)
|
|
ORDER BY apply.created_at DESC
|
|
LIMIT 1
|
|
FOR UPDATE OF apply SKIP LOCKED
|
|
"""),
|
|
{
|
|
"window_hours": max(1, window_hours),
|
|
"retry_backoff_seconds": max(
|
|
_CONTROLLED_RETRY_ERROR_BACKOFF_MIN_SECONDS,
|
|
min(
|
|
_CONTROLLED_RETRY_ERROR_BACKOFF_MAX_SECONDS,
|
|
retry_backoff_seconds,
|
|
),
|
|
),
|
|
"stale_after_seconds": max(
|
|
300,
|
|
stale_after_seconds
|
|
or (
|
|
settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS
|
|
+ 120
|
|
),
|
|
),
|
|
},
|
|
)
|
|
row = result.mappings().one_or_none()
|
|
return dict(row) if row is not None else None
|
|
|
|
|
|
async def preflight_failed_apply_retry_queue_once(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
window_hours: int = 24,
|
|
**_kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
"""Query retry backlog without using executor transport or writing state."""
|
|
|
|
try:
|
|
row = await _load_open_failed_apply_retry_row(
|
|
project_id=project_id,
|
|
window_hours=window_hours,
|
|
)
|
|
return {
|
|
"scanned": 1 if row is not None else 0,
|
|
"replayed": 0,
|
|
"check_mode_passed": 0,
|
|
"check_mode_failed": 0,
|
|
"runtime_stage_receipt_written": 0,
|
|
"blockers": [],
|
|
"error": None,
|
|
"query_only": True,
|
|
"runtime_apply_executed": False,
|
|
"execution_owner": "awoooi-ansible-executor-broker",
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"scanned": 0,
|
|
"replayed": 0,
|
|
"check_mode_passed": 0,
|
|
"check_mode_failed": 0,
|
|
"runtime_stage_receipt_written": 0,
|
|
"blockers": [],
|
|
"error": type(exc).__name__,
|
|
"query_only": True,
|
|
"runtime_apply_executed": False,
|
|
"execution_owner": "awoooi-ansible-executor-broker",
|
|
}
|
|
|
|
|
|
async def _load_missing_retry_terminal_projection_rows(
|
|
*,
|
|
project_id: str,
|
|
window_hours: int,
|
|
limit: int,
|
|
) -> list[dict[str, Any]]:
|
|
"""Load verified no-write retries whose incident projection is incomplete."""
|
|
|
|
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,
|
|
replay.op_id::text AS replay_op_id,
|
|
replay.status AS replay_status,
|
|
replay.output AS replay_output,
|
|
replay.dry_run_result AS replay_dry_run_result,
|
|
replay.error AS replay_error,
|
|
replay.duration_ms AS replay_duration_ms,
|
|
retry_receipt.receipt IS NOT NULL
|
|
AS retry_receipt_present,
|
|
coalesce(
|
|
retry_receipt.receipt #>> '{detail,terminal_type}',
|
|
replay.output ->> 'terminal_disposition'
|
|
)
|
|
AS terminal_type,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM awooop_outbound_message outbound
|
|
WHERE outbound.project_id = :project_id
|
|
AND outbound.channel_type = 'telegram'
|
|
AND outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id IS NOT NULL
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND coalesce(
|
|
outbound.source_envelope
|
|
->> 'automation_run_id',
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,automation_run_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,automation_run_ids,0}'
|
|
) = apply.input ->> 'automation_run_id'
|
|
AND coalesce(
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,incident_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,incident_ids,0}'
|
|
) = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,apply_op_id}' = apply.op_id::text
|
|
) AS telegram_receipt_acknowledged
|
|
FROM automation_operation_log apply
|
|
JOIN automation_operation_log replay
|
|
ON replay.parent_op_id = apply.op_id
|
|
AND replay.operation_type = 'ansible_execution_skipped'
|
|
AND replay.input ->> 'execution_mode'
|
|
= 'controlled_retry_check_mode_replay'
|
|
AND replay.status IN ('success', 'failed')
|
|
AND replay.output ->> 'runtime_apply_executed' = 'false'
|
|
AND replay.input ->> 'automation_run_id'
|
|
= apply.input ->> 'automation_run_id'
|
|
AND coalesce(
|
|
replay.incident_id::text,
|
|
replay.input ->> 'incident_id'
|
|
) = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND replay.output ->> 'terminal_disposition' = CASE
|
|
WHEN replay.status = 'success'
|
|
THEN 'no_write_replay_passed_waiting_repair_candidate'
|
|
ELSE 'no_write_replay_failed_waiting_playbook_or_transport_repair'
|
|
END
|
|
JOIN incidents incident
|
|
ON incident.incident_id = coalesce(
|
|
apply.incident_id::text,
|
|
apply.input ->> 'incident_id'
|
|
)
|
|
AND incident.project_id = :project_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT receipt.value AS receipt
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' = 'retry_or_rollback'
|
|
AND receipt.value ->> 'automation_run_id'
|
|
= apply.input ->> 'automation_run_id'
|
|
AND receipt.value #>>
|
|
'{detail,retry_check_mode_op_id}' = replay.op_id::text
|
|
AND receipt.value #>>
|
|
'{detail,failed_apply_op_id}' = apply.op_id::text
|
|
AND receipt.value #>>
|
|
'{detail,verified_no_write_terminal}' = 'true'
|
|
AND receipt.value #>>
|
|
'{detail,runtime_apply_executed}' = 'false'
|
|
AND receipt.value #>>
|
|
'{detail,repository_readback_verified}' = 'true'
|
|
AND receipt.value #>> '{detail,terminal_type}'
|
|
= replay.output ->> 'terminal_disposition'
|
|
AND receipt.value ->> 'durable_receipt' = 'true'
|
|
LIMIT 1
|
|
) retry_receipt ON TRUE
|
|
WHERE apply.operation_type = 'ansible_apply_executed'
|
|
AND apply.status = 'failed'
|
|
AND apply.created_at >= NOW() - (
|
|
:window_hours * INTERVAL '1 hour'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM incident_evidence verifier
|
|
WHERE verifier.incident_id = incident.incident_id
|
|
AND verifier.post_execution_state ->> 'apply_op_id'
|
|
= apply.op_id::text
|
|
AND verifier.verification_result IN ('failed', 'timeout')
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log source_check
|
|
JOIN automation_operation_log source_candidate
|
|
ON source_candidate.op_id = source_check.parent_op_id
|
|
AND source_candidate.operation_type
|
|
= 'ansible_candidate_matched'
|
|
WHERE source_check.op_id = apply.parent_op_id
|
|
AND source_check.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log source_check
|
|
JOIN automation_operation_log source_candidate
|
|
ON source_candidate.op_id = source_check.parent_op_id
|
|
AND source_candidate.operation_type
|
|
= 'ansible_candidate_matched'
|
|
JOIN automation_operation_log newer_candidate
|
|
ON newer_candidate.operation_type
|
|
= 'ansible_candidate_matched'
|
|
AND newer_candidate.input ->> 'executor' = 'ansible'
|
|
AND newer_candidate.input ->> 'decision_path'
|
|
= 'repair_candidate_controlled_queue'
|
|
AND coalesce(
|
|
newer_candidate.incident_id::text,
|
|
newer_candidate.input ->> 'incident_id'
|
|
) = incident.incident_id
|
|
AND (
|
|
newer_candidate.created_at,
|
|
newer_candidate.op_id
|
|
) > (
|
|
source_candidate.created_at,
|
|
source_candidate.op_id
|
|
)
|
|
WHERE source_check.op_id = apply.parent_op_id
|
|
AND source_check.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
)
|
|
AND (
|
|
retry_receipt.receipt IS NULL
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM awooop_outbound_message outbound
|
|
WHERE outbound.project_id = :project_id
|
|
AND outbound.channel_type = 'telegram'
|
|
AND outbound.send_status = 'sent'
|
|
AND outbound.provider_message_id IS NOT NULL
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,action}'
|
|
= 'controlled_apply_result'
|
|
AND coalesce(
|
|
outbound.source_envelope
|
|
->> 'automation_run_id',
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,automation_run_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,automation_run_ids,0}'
|
|
) = apply.input ->> 'automation_run_id'
|
|
AND coalesce(
|
|
outbound.source_envelope #>>
|
|
'{callback_reply,incident_id}',
|
|
outbound.source_envelope #>>
|
|
'{source_refs,incident_ids,0}'
|
|
) = incident.incident_id
|
|
AND outbound.source_envelope #>>
|
|
'{callback_reply,apply_op_id}'
|
|
= apply.op_id::text
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) closure_receipt(value)
|
|
WHERE closure_receipt.value ->> 'stage_id'
|
|
= 'incident_closure'
|
|
AND closure_receipt.value ->> 'automation_run_id'
|
|
= apply.input ->> 'automation_run_id'
|
|
AND closure_receipt.value #>>
|
|
'{detail,apply_op_id}' = apply.op_id::text
|
|
AND closure_receipt.value #>>
|
|
'{detail,retry_op_id}' = replay.op_id::text
|
|
AND closure_receipt.value #>>
|
|
'{detail,terminal_type}'
|
|
= replay.output ->> 'terminal_disposition'
|
|
AND closure_receipt.value #>>
|
|
'{detail,no_write_terminal}' = 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,proposal_executed}' = 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,execution_success}' = 'false'
|
|
AND closure_receipt.value #>>
|
|
'{detail,telegram_receipt_acknowledged}'
|
|
= 'true'
|
|
AND closure_receipt.value #>>
|
|
'{detail,repository_readback_verified}'
|
|
= 'true'
|
|
AND closure_receipt.value ->> 'durable_receipt'
|
|
= 'true'
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = incident.incident_id
|
|
AND lifecycle.event_type::text
|
|
= 'EXECUTION_COMPLETED'
|
|
AND lifecycle.success IS FALSE
|
|
AND lifecycle.context ->> 'apply_op_id'
|
|
= apply.op_id::text
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= apply.input ->> 'automation_run_id'
|
|
)
|
|
OR (
|
|
upper(incident.status::text)
|
|
IS DISTINCT FROM 'MITIGATING'
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,schema_version}'
|
|
IS DISTINCT FROM
|
|
'ansible_incident_terminal_disposition_v1'
|
|
OR cast(incident.outcome AS jsonb) ->>
|
|
'proposal_executed'
|
|
IS DISTINCT FROM 'true'
|
|
OR cast(incident.outcome AS jsonb) ->>
|
|
'execution_success'
|
|
IS DISTINCT FROM 'false'
|
|
OR
|
|
cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,automation_run_id}'
|
|
IS DISTINCT FROM apply.input
|
|
->> 'automation_run_id'
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,apply_op_id}'
|
|
IS DISTINCT FROM apply.op_id::text
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,retry_op_id}'
|
|
IS DISTINCT FROM replay.op_id::text
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,terminal_type}'
|
|
IS DISTINCT FROM replay.output
|
|
->> 'terminal_disposition'
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,no_write_terminal}'
|
|
IS DISTINCT FROM 'true'
|
|
OR cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,telegram_receipt_acknowledged}'
|
|
IS DISTINCT FROM 'true'
|
|
)
|
|
)
|
|
ORDER BY replay.created_at DESC
|
|
LIMIT :limit
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"window_hours": max(1, window_hours),
|
|
"limit": max(1, min(limit, 5)),
|
|
},
|
|
)
|
|
return [dict(row) for row in result.mappings().all()]
|
|
|
|
|
|
async def _verify_retry_terminal_projection_readback(
|
|
claim: AnsibleCheckModeClaim,
|
|
*,
|
|
apply_op_id: str,
|
|
replay_op_id: str,
|
|
terminal_type: str,
|
|
project_id: str,
|
|
) -> bool:
|
|
automation_run_id = _automation_run_id_for_claim(claim)
|
|
async with get_db_context(project_id) as db:
|
|
await db.execute(text("SET LOCAL statement_timeout = '5000ms'"))
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
upper(incident.status::text) = 'MITIGATING'
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,schema_version}'
|
|
= 'ansible_incident_terminal_disposition_v1'
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,automation_run_id}'
|
|
= :automation_run_id
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,apply_op_id}' = :apply_op_id
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,retry_op_id}' = :replay_op_id
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,terminal_type}' = :terminal_type
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,no_write_terminal}' = 'true'
|
|
AND cast(incident.outcome AS jsonb) #>>
|
|
'{automation_terminal,telegram_receipt_acknowledged}'
|
|
= 'true'
|
|
AND cast(incident.outcome AS jsonb) ->>
|
|
'proposal_executed' = 'true'
|
|
AND cast(incident.outcome AS jsonb) ->>
|
|
'execution_success' = 'false'
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) retry_receipt(value)
|
|
WHERE retry_receipt.value ->> 'stage_id'
|
|
= 'retry_or_rollback'
|
|
AND retry_receipt.value ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND retry_receipt.value #>>
|
|
'{detail,failed_apply_op_id}' = :apply_op_id
|
|
AND retry_receipt.value #>>
|
|
'{detail,retry_check_mode_op_id}' = :replay_op_id
|
|
AND retry_receipt.value #>>
|
|
'{detail,terminal_type}' = :terminal_type
|
|
AND retry_receipt.value #>>
|
|
'{detail,verified_no_write_terminal}' = 'true'
|
|
AND retry_receipt.value #>>
|
|
'{detail,runtime_apply_executed}' = 'false'
|
|
AND retry_receipt.value #>>
|
|
'{detail,repository_readback_verified}' = 'true'
|
|
AND retry_receipt.value ->> 'durable_receipt' = 'true'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM jsonb_array_elements(
|
|
coalesce(
|
|
apply.input -> 'runtime_stage_receipts',
|
|
'[]'::jsonb
|
|
)
|
|
) receipt(value)
|
|
WHERE receipt.value ->> 'stage_id' = 'incident_closure'
|
|
AND receipt.value ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND receipt.value #>> '{detail,apply_op_id}'
|
|
= :apply_op_id
|
|
AND receipt.value #>> '{detail,retry_op_id}'
|
|
= :replay_op_id
|
|
AND receipt.value #>>
|
|
'{detail,telegram_receipt_acknowledged}' = 'true'
|
|
AND receipt.value #>>
|
|
'{detail,proposal_executed}' = 'true'
|
|
AND receipt.value #>>
|
|
'{detail,execution_success}' = 'false'
|
|
AND receipt.value #>>
|
|
'{detail,repository_readback_verified}' = 'true'
|
|
AND receipt.value ->> 'durable_receipt' = 'true'
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM alert_operation_log lifecycle
|
|
WHERE lifecycle.incident_id = incident.incident_id
|
|
AND lifecycle.event_type::text = 'EXECUTION_COMPLETED'
|
|
AND lifecycle.success IS FALSE
|
|
AND lifecycle.context ->> 'apply_op_id' = :apply_op_id
|
|
AND lifecycle.context ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
) AS projection_verified
|
|
FROM incidents incident
|
|
JOIN automation_operation_log apply
|
|
ON apply.op_id = CAST(:apply_op_id_uuid AS uuid)
|
|
AND apply.operation_type = 'ansible_apply_executed'
|
|
AND apply.status = 'failed'
|
|
AND apply.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
JOIN automation_operation_log replay
|
|
ON replay.op_id = CAST(:replay_op_id_uuid AS uuid)
|
|
AND replay.parent_op_id = apply.op_id
|
|
AND replay.operation_type = 'ansible_execution_skipped'
|
|
AND replay.status IN ('success', 'failed')
|
|
AND replay.input ->> 'execution_mode'
|
|
= 'controlled_retry_check_mode_replay'
|
|
AND replay.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND coalesce(
|
|
replay.incident_id::text,
|
|
replay.input ->> 'incident_id'
|
|
) = incident.incident_id
|
|
AND replay.output ->> 'runtime_apply_executed' = 'false'
|
|
AND replay.output ->> 'terminal_disposition' = :terminal_type
|
|
WHERE incident.incident_id = :incident_id
|
|
AND incident.project_id = :project_id
|
|
LIMIT 1
|
|
"""),
|
|
{
|
|
"automation_run_id": automation_run_id,
|
|
"apply_op_id": apply_op_id,
|
|
"apply_op_id_uuid": apply_op_id,
|
|
"replay_op_id": replay_op_id,
|
|
"replay_op_id_uuid": replay_op_id,
|
|
"terminal_type": terminal_type,
|
|
"incident_id": claim.incident_id,
|
|
"project_id": project_id,
|
|
},
|
|
)
|
|
return result.scalar() is True
|
|
|
|
|
|
async def backfill_missing_retry_terminal_projections_once(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
window_hours: int = 24,
|
|
limit: int = 1,
|
|
) -> dict[str, Any]:
|
|
"""Repair missing incident terminals from durable no-write retry receipts."""
|
|
|
|
stats: dict[str, Any] = {
|
|
"scanned": 0,
|
|
"written": 0,
|
|
"retry_receipt_written": 0,
|
|
"telegram_receipt_acknowledged": 0,
|
|
"incident_receipt_written": 0,
|
|
"lifecycle_written": 0,
|
|
"verified": 0,
|
|
"runtime_apply_executed": False,
|
|
"error": None,
|
|
}
|
|
try:
|
|
rows = await _load_missing_retry_terminal_projection_rows(
|
|
project_id=project_id,
|
|
window_hours=window_hours,
|
|
limit=limit,
|
|
)
|
|
stats["scanned"] = len(rows)
|
|
for row in rows:
|
|
reconstructed = _claim_from_apply_operation_row(row)
|
|
if reconstructed is None:
|
|
continue
|
|
claim, failed_apply_result = reconstructed
|
|
apply_op_id = str(row.get("op_id") or "")
|
|
replay_op_id = str(row.get("replay_op_id") or "")
|
|
terminal_type = str(row.get("terminal_type") or "")
|
|
replay_status = str(row.get("replay_status") or "")
|
|
expected_terminal_type = (
|
|
"no_write_replay_passed_waiting_repair_candidate"
|
|
if replay_status == "success"
|
|
else (
|
|
"no_write_replay_failed_waiting_playbook_or_transport_repair"
|
|
if replay_status == "failed"
|
|
else ""
|
|
)
|
|
)
|
|
if not (
|
|
apply_op_id
|
|
and replay_op_id
|
|
and terminal_type == expected_terminal_type
|
|
):
|
|
continue
|
|
telegram_acknowledged = (
|
|
row.get("telegram_receipt_acknowledged") is True
|
|
)
|
|
if not telegram_acknowledged:
|
|
apply_output = _json_loads(row.get("output"))
|
|
receipt_sent = await _send_controlled_apply_telegram_receipt(
|
|
claim,
|
|
failed_apply_result,
|
|
apply_op_id=apply_op_id,
|
|
writeback={
|
|
"verification_result": str(
|
|
apply_output.get(
|
|
"independent_post_verifier_result"
|
|
)
|
|
or "failed"
|
|
),
|
|
"verification": True,
|
|
"learning": False,
|
|
},
|
|
project_id=project_id,
|
|
)
|
|
if receipt_sent:
|
|
telegram_acknowledged = (
|
|
await _retry_telegram_receipt_acknowledged(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
if not telegram_acknowledged:
|
|
continue
|
|
stats["telegram_receipt_acknowledged"] += 1
|
|
if row.get("retry_receipt_present") is not True:
|
|
replay_output = _json_loads(row.get("replay_output"))
|
|
replay_dry_run = _json_loads(
|
|
row.get("replay_dry_run_result")
|
|
)
|
|
raw_returncode = replay_output.get("returncode")
|
|
try:
|
|
replay_returncode = int(raw_returncode)
|
|
except (TypeError, ValueError):
|
|
replay_returncode = 0 if replay_status == "success" else 1
|
|
replay_result = AnsibleRunResult(
|
|
returncode=replay_returncode,
|
|
stdout="",
|
|
stderr=str(row.get("replay_error") or ""),
|
|
duration_ms=int(row.get("replay_duration_ms") or 0),
|
|
timed_out=replay_dry_run.get("timed_out") is True,
|
|
post_verifier_passed=False,
|
|
)
|
|
retry_receipt_written = (
|
|
await _append_runtime_stage_receipts_to_apply(
|
|
apply_op_id=apply_op_id,
|
|
receipts=(
|
|
_build_retry_runtime_stage_receipt(
|
|
claim,
|
|
replay_result,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=replay_op_id,
|
|
derived_from_durable_chain=True,
|
|
check_mode_replay_performed=(
|
|
replay_output.get(
|
|
"check_mode_replay_performed"
|
|
)
|
|
is not False
|
|
),
|
|
),
|
|
),
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
stats["retry_receipt_written"] += int(
|
|
retry_receipt_written
|
|
)
|
|
if not retry_receipt_written:
|
|
continue
|
|
terminal = await _record_incident_terminal_disposition(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
terminal_type=terminal_type,
|
|
success_terminal=False,
|
|
retry_op_id=replay_op_id,
|
|
telegram_receipt_acknowledged=telegram_acknowledged,
|
|
)
|
|
if terminal is None:
|
|
continue
|
|
incident_receipt_written = (
|
|
await _append_runtime_stage_receipts_to_apply(
|
|
apply_op_id=apply_op_id,
|
|
receipts=(
|
|
_runtime_stage_receipt(
|
|
claim,
|
|
stage_id="incident_closure",
|
|
evidence_ref=(
|
|
f"incidents:{claim.incident_id}:automation_terminal"
|
|
),
|
|
detail=terminal,
|
|
derived_from_durable_chain=True,
|
|
),
|
|
),
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
lifecycle_written = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"EXECUTION_COMPLETED",
|
|
apply_op_id=apply_op_id,
|
|
success=False,
|
|
action_detail="controlled_retry_no_write_terminal_backfilled",
|
|
project_id=project_id,
|
|
error_message="failed_apply_retry_terminal",
|
|
post_verifier_passed=False,
|
|
)
|
|
stats["incident_receipt_written"] += int(
|
|
incident_receipt_written
|
|
)
|
|
stats["lifecycle_written"] += int(lifecycle_written)
|
|
if not incident_receipt_written or not lifecycle_written:
|
|
continue
|
|
verified = await _verify_retry_terminal_projection_readback(
|
|
claim,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=replay_op_id,
|
|
terminal_type=terminal_type,
|
|
project_id=project_id,
|
|
)
|
|
stats["verified"] += int(verified)
|
|
stats["written"] += int(verified)
|
|
except Exception as exc:
|
|
stats["error"] = f"{type(exc).__name__}: {exc}"[:500]
|
|
logger.warning(
|
|
"ansible_retry_terminal_projection_backfill_failed",
|
|
project_id=project_id,
|
|
**stats,
|
|
)
|
|
return stats
|
|
|
|
|
|
async def run_failed_apply_check_mode_replay_once(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
window_hours: int = 24,
|
|
timeout_seconds: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Replay one failed apply in no-write check mode and persist its terminal receipt."""
|
|
|
|
stats: dict[str, Any] = {
|
|
"scanned": 0,
|
|
"replayed": 0,
|
|
"check_mode_passed": 0,
|
|
"check_mode_failed": 0,
|
|
"runtime_apply_executed": 0,
|
|
"runtime_stage_receipt_written": 0,
|
|
"stale_retry_reclaimed": 0,
|
|
"stale_retry_terminalized": 0,
|
|
"blockers": [],
|
|
"error": None,
|
|
}
|
|
try:
|
|
effective_timeout = (
|
|
timeout_seconds or settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS
|
|
)
|
|
stale_after_seconds = max(300, effective_timeout + 120)
|
|
row = await _load_open_failed_apply_retry_row(
|
|
project_id=project_id,
|
|
window_hours=window_hours,
|
|
stale_after_seconds=stale_after_seconds,
|
|
)
|
|
if row is None:
|
|
return stats
|
|
stats["scanned"] = 1
|
|
reconstructed = _claim_from_apply_operation_row(row)
|
|
if reconstructed is None:
|
|
stats["error"] = "failed_apply_receipt_could_not_be_reconstructed"
|
|
return stats
|
|
claim, _failed_apply_result = reconstructed
|
|
apply_op_id = str(row.get("op_id") or "")
|
|
automation_run_id = str(
|
|
claim.input_payload.get("automation_run_id")
|
|
or claim.source_candidate_op_id
|
|
)
|
|
stale_replay_op_id = str(row.get("stale_replay_op_id") or "")
|
|
stale_recovery_count = int(
|
|
row.get("stale_replay_recovery_count") or 0
|
|
)
|
|
stale_retry_terminalized = bool(
|
|
stale_replay_op_id
|
|
and stale_recovery_count >= _CONTROLLED_RETRY_MAX_ATTEMPTS
|
|
)
|
|
stats["stale_retry_reclaimed"] = int(bool(stale_replay_op_id))
|
|
stats["stale_retry_terminalized"] = int(stale_retry_terminalized)
|
|
|
|
if not stale_retry_terminalized:
|
|
blockers = _runtime_blockers()
|
|
if blockers:
|
|
stats["blockers"] = blockers
|
|
return stats
|
|
transport_blockers = await recent_ansible_transport_blockers(
|
|
project_id=project_id
|
|
)
|
|
if transport_blockers:
|
|
stats["blockers"] = transport_blockers
|
|
return stats
|
|
|
|
retry_claim_id = str(uuid4())
|
|
retry_claimed_at = datetime.now(UTC).isoformat()
|
|
input_payload = {
|
|
**claim.input_payload,
|
|
"automation_run_id": automation_run_id,
|
|
"execution_mode": "controlled_retry_check_mode_replay",
|
|
"retry_of_apply_op_id": apply_op_id,
|
|
"check_mode": True,
|
|
"diff": True,
|
|
"apply_enabled": False,
|
|
"approval_required_before_apply": False,
|
|
"owner_review_required": False,
|
|
"controlled_apply_allowed": True,
|
|
"controlled_apply_blocker": None,
|
|
"bounded_execution_scope": "retry_check_mode_only",
|
|
"retry_claim_id": retry_claim_id,
|
|
"retry_claimed_at": retry_claimed_at,
|
|
"retry_recovery_count": 0,
|
|
"next_controlled_action": (
|
|
"queue_ai_playbook_or_transport_repair_candidate"
|
|
),
|
|
}
|
|
async with get_db_context(project_id) as db:
|
|
await db.execute(
|
|
text("""
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended(:retry_claim_lock_key, 0)
|
|
)
|
|
"""),
|
|
{
|
|
"retry_claim_lock_key": (
|
|
f"ansible-retry-claim:{apply_op_id}"
|
|
)
|
|
},
|
|
)
|
|
if stale_replay_op_id:
|
|
claimed = await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log replay
|
|
SET input = coalesce(replay.input, '{}'::jsonb)
|
|
|| jsonb_build_object(
|
|
'retry_claim_id',
|
|
CAST(:retry_claim_id AS text),
|
|
'retry_claimed_at',
|
|
CAST(:retry_claimed_at AS text),
|
|
'retry_claim_recovered',
|
|
true,
|
|
'retry_recovery_count',
|
|
CASE
|
|
WHEN replay.input
|
|
->> 'retry_recovery_count'
|
|
~ '^[0-9]+$'
|
|
THEN (replay.input
|
|
->> 'retry_recovery_count')::integer
|
|
+ 1
|
|
ELSE 1
|
|
END
|
|
),
|
|
dry_run_result = coalesce(
|
|
replay.dry_run_result,
|
|
'{}'::jsonb
|
|
) || jsonb_build_object(
|
|
'claim_state',
|
|
'reclaimed_after_stale_pending'
|
|
)
|
|
WHERE replay.op_id = CAST(:replay_op_id AS uuid)
|
|
AND replay.operation_type
|
|
= 'ansible_execution_skipped'
|
|
AND replay.status = 'pending'
|
|
AND replay.parent_op_id
|
|
= CAST(:parent_op_id AS uuid)
|
|
AND replay.input ->> 'automation_run_id'
|
|
= :automation_run_id
|
|
AND replay.input ->> 'execution_mode'
|
|
= 'controlled_retry_check_mode_replay'
|
|
AND coalesce(
|
|
nullif(
|
|
replay.input ->> 'retry_claimed_at',
|
|
''
|
|
)::timestamptz,
|
|
replay.created_at
|
|
) <= NOW() - (
|
|
:stale_after_seconds * INTERVAL '1 second'
|
|
)
|
|
RETURNING replay.op_id
|
|
"""),
|
|
{
|
|
"replay_op_id": stale_replay_op_id,
|
|
"retry_claim_id": retry_claim_id,
|
|
"retry_claimed_at": retry_claimed_at,
|
|
"stale_after_seconds": stale_after_seconds,
|
|
"parent_op_id": apply_op_id,
|
|
"automation_run_id": automation_run_id,
|
|
},
|
|
)
|
|
else:
|
|
claimed = await db.execute(
|
|
text("""
|
|
INSERT INTO automation_operation_log (
|
|
operation_type, actor, status, incident_id,
|
|
input, output, dry_run_result,
|
|
parent_op_id, tags
|
|
)
|
|
SELECT
|
|
'ansible_execution_skipped',
|
|
'ansible_controlled_retry_worker',
|
|
'pending',
|
|
:incident_db_id,
|
|
CAST(:input AS jsonb),
|
|
'{}'::jsonb,
|
|
CAST(:dry_run_result AS jsonb),
|
|
CAST(:parent_op_id AS uuid),
|
|
:tags
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log replay
|
|
WHERE replay.operation_type = 'ansible_execution_skipped'
|
|
AND replay.parent_op_id = CAST(:parent_op_id AS uuid)
|
|
AND replay.input ->> 'execution_mode'
|
|
= 'controlled_retry_check_mode_replay'
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
claim.incident_id
|
|
),
|
|
"input": json.dumps(input_payload, ensure_ascii=False),
|
|
"dry_run_result": json.dumps(
|
|
{
|
|
"execution_mode": (
|
|
"controlled_retry_check_mode_replay"
|
|
),
|
|
"retry_of_apply_op_id": apply_op_id,
|
|
"check_mode_executed": False,
|
|
"runtime_apply_executed": False,
|
|
"claim_state": "claimed",
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": apply_op_id,
|
|
"tags": [
|
|
"ansible",
|
|
"controlled_retry",
|
|
"check_mode_replay",
|
|
"no_runtime_apply",
|
|
f"automation_run_id:{automation_run_id}",
|
|
],
|
|
},
|
|
)
|
|
replay_op_id_value = claimed.scalar_one_or_none()
|
|
if replay_op_id_value is None:
|
|
stats["blockers"] = ["failed_apply_retry_claim_raced"]
|
|
return stats
|
|
replay_op_id = str(replay_op_id_value)
|
|
if stale_retry_terminalized:
|
|
replay_result = AnsibleRunResult(
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="stale_pending_retry_recovery_budget_exhausted",
|
|
duration_ms=0,
|
|
)
|
|
else:
|
|
try:
|
|
spec = build_ansible_check_mode_command(
|
|
playbook_path=claim.playbook_path,
|
|
inventory_hosts=claim.inventory_hosts,
|
|
)
|
|
replay_result = await _run_ansible_command(
|
|
spec,
|
|
timeout_seconds=effective_timeout,
|
|
)
|
|
except Exception as exc:
|
|
replay_result = AnsibleRunResult(
|
|
returncode=1,
|
|
stdout="",
|
|
stderr=f"ansible_retry_check_mode_runtime_error: {exc}",
|
|
duration_ms=0,
|
|
)
|
|
status, output, dry_run_result, error = _build_result_payload(
|
|
replay_result,
|
|
controlled_apply_allowed=True,
|
|
controlled_apply_blocker=None,
|
|
)
|
|
output.update({
|
|
"execution_mode": "controlled_retry_check_mode_replay",
|
|
"retry_of_apply_op_id": apply_op_id,
|
|
"apply_enabled": False,
|
|
"approval_required_before_apply": False,
|
|
"owner_review_required": False,
|
|
"runtime_apply_executed": False,
|
|
"check_mode_executed": not stale_retry_terminalized,
|
|
"check_mode_replay_performed": not stale_retry_terminalized,
|
|
"stale_pending_retry_terminalized": stale_retry_terminalized,
|
|
"bounded_execution_scope": "retry_check_mode_only",
|
|
"next_required_step": (
|
|
"queue_ai_playbook_or_transport_repair_candidate"
|
|
),
|
|
"terminal_disposition": (
|
|
"no_write_replay_passed_waiting_repair_candidate"
|
|
if replay_result.returncode == 0
|
|
else "no_write_replay_failed_waiting_playbook_or_transport_repair"
|
|
),
|
|
})
|
|
dry_run_result.update({
|
|
"execution_mode": "controlled_retry_check_mode_replay",
|
|
"retry_of_apply_op_id": apply_op_id,
|
|
"apply_enabled": False,
|
|
"approval_required_before_apply": False,
|
|
"owner_review_required": False,
|
|
"runtime_apply_executed": False,
|
|
"check_mode_executed": not stale_retry_terminalized,
|
|
"check_mode_replay_performed": not stale_retry_terminalized,
|
|
"stale_pending_retry_terminalized": stale_retry_terminalized,
|
|
"bounded_execution_scope": "retry_check_mode_only",
|
|
"next_controlled_action": (
|
|
"queue_ai_playbook_or_transport_repair_candidate"
|
|
),
|
|
})
|
|
async with get_db_context(project_id) as db:
|
|
updated = 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)
|
|
AND status = 'pending'
|
|
AND input ->> 'retry_claim_id' = :retry_claim_id
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"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": replay_result.duration_ms,
|
|
"stderr": _tail(replay_result.stderr, _STDERR_LIMIT),
|
|
"op_id": replay_op_id,
|
|
"retry_claim_id": retry_claim_id,
|
|
},
|
|
)
|
|
if updated.scalar_one_or_none() is None:
|
|
stats["error"] = "failed_apply_retry_terminal_update_lost_lease"
|
|
return stats
|
|
stats["replayed"] = 0 if stale_retry_terminalized else 1
|
|
stats[
|
|
"check_mode_passed" if replay_result.returncode == 0 else "check_mode_failed"
|
|
] = 1
|
|
if await _record_retry_runtime_stage_receipt(
|
|
claim,
|
|
replay_result,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=replay_op_id,
|
|
project_id=project_id,
|
|
check_mode_replay_performed=not stale_retry_terminalized,
|
|
):
|
|
stats["runtime_stage_receipt_written"] = 1
|
|
logger.info(
|
|
"ansible_failed_apply_check_mode_replay_completed",
|
|
automation_run_id=automation_run_id,
|
|
apply_op_id=apply_op_id,
|
|
replay_op_id=str(replay_op_id),
|
|
returncode=replay_result.returncode,
|
|
runtime_apply_executed=False,
|
|
runtime_stage_receipt_written=bool(
|
|
stats["runtime_stage_receipt_written"]
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
stats["error"] = f"{type(exc).__name__}: {exc}"[:500]
|
|
logger.warning("ansible_failed_apply_check_mode_replay_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
|
|
CASE
|
|
WHEN candidate.input ->> 'work_item_id' LIKE 'P0-%'
|
|
THEN 0
|
|
WHEN candidate.input ->> 'proposal_source'
|
|
= 'alert_webhook_controlled_router'
|
|
THEN LEAST(
|
|
CASE
|
|
WHEN candidate.input ->> 'execution_priority'
|
|
~ '^[0-9]{1,3}$'
|
|
THEN (candidate.input ->> 'execution_priority')::int
|
|
ELSE 30
|
|
END,
|
|
30
|
|
)
|
|
WHEN candidate.input ->> 'execution_priority'
|
|
~ '^[0-9]{1,3}$'
|
|
THEN LEAST(
|
|
(candidate.input ->> 'execution_priority')::int,
|
|
100
|
|
)
|
|
ELSE 100
|
|
END ASC,
|
|
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"])
|
|
candidate_input, trust_gate = await _guard_candidate_input_by_playbook_trust(
|
|
db,
|
|
candidate_input,
|
|
)
|
|
if trust_gate.get("status") == "circuit_open":
|
|
await _insert_skipped_candidate(
|
|
db,
|
|
source_candidate_op_id=source_op_id,
|
|
candidate_input=candidate_input,
|
|
reason="playbook_trust_circuit_open",
|
|
)
|
|
continue
|
|
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 claim_stale_pending_check_modes(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
limit: int = 1,
|
|
stale_after_seconds: int,
|
|
) -> list[AnsibleCheckModeClaim]:
|
|
"""Lease and revalidate stale pending rows before replaying check-mode."""
|
|
|
|
claims: list[AnsibleCheckModeClaim] = []
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
check_mode.op_id,
|
|
check_mode.parent_op_id,
|
|
check_mode.tags,
|
|
coalesce(
|
|
check_mode.incident_id::text,
|
|
check_mode.input ->> 'incident_id'
|
|
) AS incident_id,
|
|
check_mode.input
|
|
FROM automation_operation_log check_mode
|
|
WHERE check_mode.operation_type = 'ansible_check_mode_executed'
|
|
AND check_mode.status = 'pending'
|
|
AND coalesce(
|
|
nullif(
|
|
check_mode.dry_run_result ->> 'reclaimed_at',
|
|
''
|
|
)::timestamptz,
|
|
check_mode.created_at
|
|
) <= NOW() - (:stale_after_seconds * INTERVAL '1 second')
|
|
ORDER BY check_mode.created_at DESC
|
|
LIMIT :limit
|
|
FOR UPDATE SKIP LOCKED
|
|
"""),
|
|
{
|
|
"limit": max(1, limit),
|
|
"stale_after_seconds": max(300, stale_after_seconds),
|
|
},
|
|
)
|
|
for row_value in result.mappings().all():
|
|
row = dict(row_value)
|
|
claim = _claim_from_stale_check_mode_row(row)
|
|
if claim is None:
|
|
await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log
|
|
SET status = 'failed',
|
|
error = 'stale_pending_reclaim_rejected_by_current_policy',
|
|
dry_run_result = coalesce(
|
|
dry_run_result,
|
|
'{}'::jsonb
|
|
) || jsonb_build_object(
|
|
'claim_state', 'stale_reclaim_rejected',
|
|
'reclaimed_at', NOW(),
|
|
'check_mode_executed', false
|
|
)
|
|
WHERE op_id = CAST(:op_id AS uuid)
|
|
AND status = 'pending'
|
|
"""),
|
|
{"op_id": str(row.get("op_id") or "")},
|
|
)
|
|
continue
|
|
await db.execute(
|
|
text("""
|
|
UPDATE automation_operation_log
|
|
SET input = CAST(:input AS jsonb),
|
|
error = NULL,
|
|
dry_run_result = coalesce(
|
|
dry_run_result,
|
|
'{}'::jsonb
|
|
) || jsonb_build_object(
|
|
'claim_state', 'stale_reclaimed',
|
|
'reclaimed_at', NOW(),
|
|
'check_mode_executed', false,
|
|
'apply_executed', false
|
|
)
|
|
WHERE op_id = CAST(:op_id AS uuid)
|
|
AND status = 'pending'
|
|
"""),
|
|
{
|
|
"input": json.dumps(claim.input_payload, ensure_ascii=False),
|
|
"op_id": claim.op_id,
|
|
},
|
|
)
|
|
claims.append(claim)
|
|
return claims
|
|
|
|
|
|
async def claim_stdin_boundary_failed_check_modes(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
limit: int = 1,
|
|
window_hours: int = _STDIN_BOUNDARY_REPLAY_WINDOW_HOURS,
|
|
) -> list[AnsibleCheckModeClaim]:
|
|
"""Replay historical nonblocking-stdin failures once, without apply."""
|
|
|
|
claims: list[AnsibleCheckModeClaim] = []
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
check_mode.op_id,
|
|
check_mode.parent_op_id,
|
|
coalesce(
|
|
check_mode.input ->> 'incident_id',
|
|
check_mode.incident_id::text
|
|
) AS incident_id,
|
|
check_mode.input
|
|
FROM automation_operation_log check_mode
|
|
WHERE check_mode.operation_type = 'ansible_check_mode_executed'
|
|
AND check_mode.status = 'failed'
|
|
AND check_mode.created_at >= NOW() - (
|
|
:window_hours * INTERVAL '1 hour'
|
|
)
|
|
AND (
|
|
coalesce(check_mode.error, '')
|
|
LIKE '%Ansible requires blocking IO%'
|
|
OR coalesce(check_mode.error, '')
|
|
LIKE '%Non-blocking file handles detected%'
|
|
OR coalesce(check_mode.stderr_feed_back, '')
|
|
LIKE '%Ansible requires blocking IO%'
|
|
OR coalesce(check_mode.stderr_feed_back, '')
|
|
LIKE '%Non-blocking file handles detected%'
|
|
OR coalesce(check_mode.output::text, '')
|
|
LIKE '%Ansible requires blocking IO%'
|
|
OR coalesce(check_mode.output::text, '')
|
|
LIKE '%Non-blocking file handles detected%'
|
|
OR coalesce(check_mode.dry_run_result::text, '')
|
|
LIKE '%Ansible requires blocking IO%'
|
|
OR coalesce(check_mode.dry_run_result::text, '')
|
|
LIKE '%Non-blocking file handles detected%'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log replay
|
|
WHERE replay.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND replay.input ->> 'replay_of_check_mode_op_id'
|
|
= check_mode.op_id::text
|
|
)
|
|
ORDER BY check_mode.created_at ASC
|
|
LIMIT :scan_limit
|
|
FOR UPDATE SKIP LOCKED
|
|
"""),
|
|
{
|
|
"window_hours": max(1, min(window_hours, 7 * 24)),
|
|
"scan_limit": min(100, max(10, limit * 10)),
|
|
},
|
|
)
|
|
for row_value in result.mappings().all():
|
|
row = dict(row_value)
|
|
original = _claim_from_stale_check_mode_row(row)
|
|
if original is None:
|
|
continue
|
|
failed_check_mode_op_id = str(row.get("op_id") or "")
|
|
replay_input = {
|
|
**original.input_payload,
|
|
"execution_mode": "stdin_boundary_check_mode_replay",
|
|
"historical_stdin_boundary_replay": True,
|
|
"replay_of_check_mode_op_id": failed_check_mode_op_id,
|
|
"check_mode": True,
|
|
"diff": True,
|
|
"apply_enabled": False,
|
|
"controlled_apply_allowed": False,
|
|
"controlled_apply_blocker": (
|
|
"historical_stdin_boundary_replay_no_write"
|
|
),
|
|
"approval_required_before_apply": False,
|
|
"owner_review_required": False,
|
|
"bounded_execution_scope": "check_mode_replay_only",
|
|
"safe_next_action": (
|
|
"queue_new_controlled_apply_candidate_after_no_write_replay"
|
|
),
|
|
}
|
|
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
|
|
)
|
|
SELECT
|
|
'ansible_check_mode_executed',
|
|
'ansible_stdin_boundary_replay_worker',
|
|
'pending',
|
|
:incident_db_id,
|
|
CAST(:input AS jsonb),
|
|
'{}'::jsonb,
|
|
CAST(:dry_run_result AS jsonb),
|
|
CAST(:parent_op_id AS uuid),
|
|
:tags
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log replay
|
|
WHERE replay.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND replay.input ->> 'replay_of_check_mode_op_id'
|
|
= :failed_check_mode_op_id
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
original.incident_id
|
|
),
|
|
"input": json.dumps(replay_input, ensure_ascii=False),
|
|
"dry_run_result": json.dumps(
|
|
{
|
|
"execution_mode": (
|
|
"stdin_boundary_check_mode_replay"
|
|
),
|
|
"replay_of_check_mode_op_id": (
|
|
failed_check_mode_op_id
|
|
),
|
|
"check_mode_executed": False,
|
|
"runtime_apply_executed": False,
|
|
"claim_state": "claimed",
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": original.source_candidate_op_id,
|
|
"tags": [
|
|
"ansible",
|
|
"check_mode",
|
|
"stdin_boundary_replay",
|
|
"no_runtime_apply",
|
|
],
|
|
"failed_check_mode_op_id": failed_check_mode_op_id,
|
|
},
|
|
)
|
|
op_id_value = inserted.scalar_one_or_none()
|
|
if op_id_value is None:
|
|
continue
|
|
claims.append(
|
|
AnsibleCheckModeClaim(
|
|
op_id=str(op_id_value),
|
|
source_candidate_op_id=original.source_candidate_op_id,
|
|
incident_id=original.incident_id,
|
|
catalog_id=original.catalog_id,
|
|
playbook_path=original.playbook_path,
|
|
apply_playbook_path=original.apply_playbook_path,
|
|
inventory_hosts=original.inventory_hosts,
|
|
risk_level=original.risk_level,
|
|
input_payload=replay_input,
|
|
)
|
|
)
|
|
if len(claims) >= max(1, limit):
|
|
break
|
|
return claims
|
|
|
|
|
|
async def claim_catalog_drift_failed_check_modes(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
limit: int = 1,
|
|
candidate_max_age_hours: int | None = None,
|
|
) -> list[AnsibleCheckModeClaim]:
|
|
"""Replay a failed check once when its canonical check playbook changed."""
|
|
|
|
claims: list[AnsibleCheckModeClaim] = []
|
|
max_age_hours = max(
|
|
candidate_max_age_hours
|
|
or settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS,
|
|
_CATALOG_REPAIR_REPLAY_WINDOW_HOURS,
|
|
)
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
check_mode.op_id,
|
|
check_mode.parent_op_id,
|
|
coalesce(
|
|
check_mode.input ->> 'incident_id',
|
|
check_mode.incident_id::text
|
|
) AS incident_id,
|
|
check_mode.input,
|
|
incident.alertname AS incident_alertname,
|
|
incident.alert_category AS incident_alert_category,
|
|
incident.affected_services AS incident_affected_services
|
|
FROM automation_operation_log check_mode
|
|
JOIN incidents incident
|
|
ON incident.incident_id = coalesce(
|
|
check_mode.input ->> 'incident_id',
|
|
check_mode.incident_id::text
|
|
)
|
|
AND incident.project_id = :project_id
|
|
WHERE check_mode.operation_type = 'ansible_check_mode_executed'
|
|
AND check_mode.status = 'failed'
|
|
AND upper(incident.status::text) NOT IN ('RESOLVED', 'CLOSED')
|
|
AND check_mode.created_at >= NOW() - (
|
|
:candidate_max_age_hours * INTERVAL '1 hour'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log replay
|
|
WHERE replay.input ->> 'replay_of_check_mode_op_id'
|
|
= check_mode.op_id::text
|
|
)
|
|
ORDER BY check_mode.created_at DESC
|
|
LIMIT :scan_limit
|
|
FOR UPDATE SKIP LOCKED
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"candidate_max_age_hours": max(1, max_age_hours),
|
|
"scan_limit": 100,
|
|
},
|
|
)
|
|
ranked_rows: list[
|
|
tuple[
|
|
int,
|
|
dict[str, Any],
|
|
AnsibleCheckModeClaim,
|
|
str,
|
|
str,
|
|
]
|
|
] = []
|
|
for row_value in result.mappings().all():
|
|
row = dict(row_value)
|
|
previous_input = _json_loads(row.get("input"))
|
|
previous_check_path = str(
|
|
previous_input.get("check_mode_playbook_path")
|
|
or previous_input.get("playbook_path")
|
|
or ""
|
|
)
|
|
previous_catalog_id = str(
|
|
previous_input.get("catalog_id") or ""
|
|
)
|
|
previous_catalog_revision = str(
|
|
previous_input.get("catalog_revision") or ""
|
|
)
|
|
canonical_claim = _claim_from_stale_check_mode_row(row)
|
|
current_catalog_revision = str(
|
|
(canonical_claim.input_payload if canonical_claim else {}).get(
|
|
"catalog_revision"
|
|
)
|
|
or ""
|
|
)
|
|
path_changed = bool(
|
|
canonical_claim
|
|
and canonical_claim.playbook_path != previous_check_path
|
|
)
|
|
revision_changed = bool(
|
|
current_catalog_revision
|
|
and current_catalog_revision != previous_catalog_revision
|
|
)
|
|
if (
|
|
canonical_claim is None
|
|
or not previous_check_path
|
|
or not (path_changed or revision_changed)
|
|
):
|
|
continue
|
|
catalog_route_changed = bool(
|
|
previous_catalog_id
|
|
and canonical_claim.catalog_id != previous_catalog_id
|
|
)
|
|
replay_priority = (
|
|
0 if catalog_route_changed else 1 if path_changed else 2
|
|
)
|
|
ranked_rows.append(
|
|
(
|
|
replay_priority,
|
|
row,
|
|
canonical_claim,
|
|
previous_check_path,
|
|
previous_catalog_revision,
|
|
)
|
|
)
|
|
|
|
for (
|
|
_,
|
|
row,
|
|
canonical_claim,
|
|
previous_check_path,
|
|
previous_catalog_revision,
|
|
) in sorted(ranked_rows, key=lambda item: item[0]):
|
|
current_catalog_revision = str(
|
|
canonical_claim.input_payload.get("catalog_revision") or ""
|
|
)
|
|
catalog_replay_revision = (
|
|
current_catalog_revision
|
|
or f"path:{canonical_claim.playbook_path}"
|
|
)
|
|
replay_input = {
|
|
**canonical_claim.input_payload,
|
|
"catalog_drift_replay": True,
|
|
"catalog_replay_revision": catalog_replay_revision,
|
|
"replay_of_check_mode_op_id": str(row.get("op_id") or ""),
|
|
"previous_check_mode_playbook_path": previous_check_path,
|
|
"previous_catalog_revision": previous_catalog_revision or None,
|
|
"historical_projection_backfill": True,
|
|
"telegram_provider_delivery": "shadow_only",
|
|
}
|
|
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
|
|
) SELECT
|
|
'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
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log existing_replay
|
|
WHERE existing_replay.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND existing_replay.input ->> 'catalog_id'
|
|
= :catalog_id
|
|
AND existing_replay.input ->> 'catalog_replay_revision'
|
|
= :catalog_replay_revision
|
|
AND existing_replay.input ->> 'catalog_drift_replay'
|
|
= 'true'
|
|
AND existing_replay.input
|
|
->> 'replay_of_check_mode_op_id'
|
|
= :failed_check_mode_op_id
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
canonical_claim.incident_id
|
|
),
|
|
"input": json.dumps(replay_input, ensure_ascii=False),
|
|
"dry_run_result": json.dumps(
|
|
{
|
|
"check_mode_executed": False,
|
|
"apply_executed": False,
|
|
"claim_state": "catalog_drift_replay",
|
|
"controlled_apply_allowed": bool(
|
|
replay_input.get("controlled_apply_allowed")
|
|
),
|
|
"controlled_apply_blocker": replay_input.get(
|
|
"controlled_apply_blocker"
|
|
),
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": canonical_claim.source_candidate_op_id,
|
|
"catalog_id": canonical_claim.catalog_id,
|
|
"catalog_replay_revision": catalog_replay_revision,
|
|
"failed_check_mode_op_id": str(row.get("op_id") or ""),
|
|
"tags": [
|
|
"ansible",
|
|
"check_mode",
|
|
"pending",
|
|
"catalog_drift_replay",
|
|
],
|
|
},
|
|
)
|
|
op_id_value = inserted.scalar_one_or_none()
|
|
if op_id_value is None:
|
|
continue
|
|
op_id = str(op_id_value)
|
|
claims.append(
|
|
AnsibleCheckModeClaim(
|
|
op_id=op_id,
|
|
source_candidate_op_id=canonical_claim.source_candidate_op_id,
|
|
incident_id=canonical_claim.incident_id,
|
|
catalog_id=canonical_claim.catalog_id,
|
|
playbook_path=canonical_claim.playbook_path,
|
|
apply_playbook_path=canonical_claim.apply_playbook_path,
|
|
inventory_hosts=canonical_claim.inventory_hosts,
|
|
risk_level=canonical_claim.risk_level,
|
|
input_payload=replay_input,
|
|
)
|
|
)
|
|
if len(claims) >= max(1, limit):
|
|
break
|
|
return claims
|
|
|
|
|
|
async def claim_catalog_drift_failed_applies(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
limit: int = 1,
|
|
candidate_max_age_hours: int | None = None,
|
|
) -> list[AnsibleCheckModeClaim]:
|
|
"""Re-enter controlled execution once for a repaired failed apply."""
|
|
|
|
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
|
|
apply.op_id,
|
|
apply.parent_op_id,
|
|
coalesce(
|
|
apply.input ->> 'incident_id',
|
|
apply.incident_id::text
|
|
) AS incident_id,
|
|
apply.input
|
|
FROM automation_operation_log apply
|
|
WHERE apply.operation_type = 'ansible_apply_executed'
|
|
AND apply.status = 'failed'
|
|
AND apply.created_at >= NOW() - (
|
|
:candidate_max_age_hours * INTERVAL '1 hour'
|
|
)
|
|
ORDER BY apply.created_at DESC
|
|
LIMIT :scan_limit
|
|
FOR UPDATE OF apply SKIP LOCKED
|
|
"""),
|
|
{
|
|
"candidate_max_age_hours": max(1, max_age_hours),
|
|
"scan_limit": min(100, max(10, limit * 10)),
|
|
},
|
|
)
|
|
for row_value in result.mappings().all():
|
|
canonical_claim = _claim_from_failed_apply_catalog_repair_row(
|
|
dict(row_value)
|
|
)
|
|
if canonical_claim is None:
|
|
continue
|
|
replay_input = canonical_claim.input_payload
|
|
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
|
|
) SELECT
|
|
'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
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log existing_replay
|
|
WHERE existing_replay.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND existing_replay.input
|
|
->> 'catalog_repair_of_apply_op_id'
|
|
= :failed_apply_op_id
|
|
AND existing_replay.input
|
|
->> 'catalog_replay_revision'
|
|
= :catalog_replay_revision
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
canonical_claim.incident_id
|
|
),
|
|
"input": json.dumps(replay_input, ensure_ascii=False),
|
|
"dry_run_result": json.dumps(
|
|
{
|
|
"check_mode_executed": False,
|
|
"apply_executed": False,
|
|
"claim_state": "failed_apply_catalog_repair",
|
|
"controlled_apply_allowed": bool(
|
|
replay_input.get("controlled_apply_allowed")
|
|
),
|
|
"controlled_apply_blocker": replay_input.get(
|
|
"controlled_apply_blocker"
|
|
),
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": (
|
|
canonical_claim.source_candidate_op_id
|
|
),
|
|
"failed_apply_op_id": replay_input[
|
|
"catalog_repair_of_apply_op_id"
|
|
],
|
|
"catalog_replay_revision": replay_input[
|
|
"catalog_replay_revision"
|
|
],
|
|
"tags": [
|
|
"ansible",
|
|
"check_mode",
|
|
"pending",
|
|
"failed_apply_catalog_repair",
|
|
],
|
|
},
|
|
)
|
|
op_id_value = inserted.scalar_one_or_none()
|
|
if op_id_value is None:
|
|
continue
|
|
claims.append(
|
|
replace(canonical_claim, op_id=str(op_id_value))
|
|
)
|
|
if len(claims) >= max(1, limit):
|
|
break
|
|
return claims
|
|
|
|
|
|
async def claim_semantically_misrouted_verified_applies(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
limit: int = 1,
|
|
window_hours: int = _CATALOG_REPAIR_REPLAY_WINDOW_HOURS,
|
|
) -> list[AnsibleCheckModeClaim]:
|
|
"""Re-verify terminal applies whose catalog no longer matches the alert.
|
|
|
|
Resolved incidents remain eligible because this path corrects false terminal
|
|
attribution; it does not reopen incidents from an unresolved alert signal.
|
|
"""
|
|
|
|
claims: list[AnsibleCheckModeClaim] = []
|
|
async with get_db_context(project_id) as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
source_check.op_id,
|
|
source_check.parent_op_id,
|
|
incident.incident_id,
|
|
source_check.input,
|
|
incident.alertname AS incident_alertname,
|
|
incident.alert_category AS incident_alert_category,
|
|
incident.affected_services AS incident_affected_services,
|
|
apply.op_id::text AS previous_apply_op_id
|
|
FROM automation_operation_log apply
|
|
JOIN automation_operation_log source_check
|
|
ON source_check.op_id = apply.parent_op_id
|
|
AND source_check.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
JOIN incidents incident
|
|
ON incident.incident_id = coalesce(
|
|
apply.input ->> 'incident_id',
|
|
apply.incident_id::text
|
|
)
|
|
AND incident.project_id = :project_id
|
|
WHERE apply.operation_type = 'ansible_apply_executed'
|
|
AND apply.status = 'success'
|
|
AND apply.output ->> 'independent_post_verifier_passed'
|
|
= 'true'
|
|
AND apply.created_at >= NOW() - (
|
|
:window_hours * INTERVAL '1 hour'
|
|
)
|
|
ORDER BY apply.created_at DESC
|
|
LIMIT :scan_limit
|
|
FOR UPDATE OF apply SKIP LOCKED
|
|
"""),
|
|
{
|
|
"project_id": project_id,
|
|
"window_hours": max(1, min(window_hours, 7 * 24)),
|
|
"scan_limit": min(100, max(10, limit * 10)),
|
|
},
|
|
)
|
|
seen_incident_ids: set[str] = set()
|
|
for row_value in result.mappings().all():
|
|
row = dict(row_value)
|
|
incident_id = str(row.get("incident_id") or "")
|
|
if not incident_id or incident_id in seen_incident_ids:
|
|
continue
|
|
seen_incident_ids.add(incident_id)
|
|
previous_input = _json_loads(row.get("input"))
|
|
previous_catalog_id = str(
|
|
previous_input.get("catalog_id") or ""
|
|
)
|
|
canonical_claim = _claim_from_stale_check_mode_row(row)
|
|
if (
|
|
canonical_claim is None
|
|
or not previous_catalog_id
|
|
or canonical_claim.catalog_id == previous_catalog_id
|
|
):
|
|
continue
|
|
current_revision = str(
|
|
canonical_claim.input_payload.get("catalog_revision") or ""
|
|
)
|
|
previous_apply_op_id = str(
|
|
row.get("previous_apply_op_id") or ""
|
|
)
|
|
if not current_revision or not previous_apply_op_id:
|
|
continue
|
|
semantic_repair_run_id = str(
|
|
uuid5(
|
|
NAMESPACE_URL,
|
|
(
|
|
"awoooi:ansible-semantic-repair:"
|
|
f"{project_id}:{previous_apply_op_id}:"
|
|
f"{current_revision}"
|
|
),
|
|
)
|
|
)
|
|
replay_input = {
|
|
**canonical_claim.input_payload,
|
|
"automation_run_id": semantic_repair_run_id,
|
|
"semantic_route_reconciliation": True,
|
|
"semantic_repair_of_apply_op_id": previous_apply_op_id,
|
|
"catalog_replay_revision": current_revision,
|
|
"historical_projection_backfill": True,
|
|
"telegram_provider_delivery": "shadow_only",
|
|
"apply_idempotency_key": (
|
|
"ansible-semantic-repair:"
|
|
f"{previous_apply_op_id}:{current_revision}"
|
|
),
|
|
"bounded_execution_scope": (
|
|
"semantic_catalog_reconciliation_single_apply"
|
|
),
|
|
}
|
|
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
|
|
) SELECT
|
|
'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
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM automation_operation_log existing_replay
|
|
WHERE existing_replay.operation_type
|
|
= 'ansible_check_mode_executed'
|
|
AND existing_replay.input
|
|
->> 'semantic_repair_of_apply_op_id'
|
|
= :previous_apply_op_id
|
|
AND existing_replay.input
|
|
->> 'catalog_replay_revision'
|
|
= :catalog_replay_revision
|
|
)
|
|
RETURNING op_id
|
|
"""),
|
|
{
|
|
"incident_db_id": _automation_operation_log_incident_id(
|
|
canonical_claim.incident_id
|
|
),
|
|
"input": json.dumps(replay_input, ensure_ascii=False),
|
|
"dry_run_result": json.dumps(
|
|
{
|
|
"check_mode_executed": False,
|
|
"apply_executed": False,
|
|
"claim_state": (
|
|
"semantic_catalog_reconciliation"
|
|
),
|
|
"controlled_apply_allowed": bool(
|
|
replay_input.get("controlled_apply_allowed")
|
|
),
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
"parent_op_id": (
|
|
canonical_claim.source_candidate_op_id
|
|
),
|
|
"previous_apply_op_id": previous_apply_op_id,
|
|
"catalog_replay_revision": current_revision,
|
|
"tags": [
|
|
"ansible",
|
|
"check_mode",
|
|
"pending",
|
|
"semantic_catalog_reconciliation",
|
|
],
|
|
},
|
|
)
|
|
op_id = inserted.scalar_one_or_none()
|
|
if op_id is None:
|
|
continue
|
|
claims.append(
|
|
AnsibleCheckModeClaim(
|
|
op_id=str(op_id),
|
|
source_candidate_op_id=(
|
|
canonical_claim.source_candidate_op_id
|
|
),
|
|
incident_id=canonical_claim.incident_id,
|
|
catalog_id=canonical_claim.catalog_id,
|
|
playbook_path=canonical_claim.playbook_path,
|
|
apply_playbook_path=(
|
|
canonical_claim.apply_playbook_path
|
|
),
|
|
inventory_hosts=canonical_claim.inventory_hosts,
|
|
risk_level=canonical_claim.risk_level,
|
|
input_payload=replay_input,
|
|
)
|
|
)
|
|
if len(claims) >= max(1, limit):
|
|
break
|
|
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:
|
|
trust_gate = candidate_input.get("playbook_trust_gate")
|
|
if not isinstance(trust_gate, dict):
|
|
trust_gate = {}
|
|
input_payload = {
|
|
"automation_run_id": str(source_candidate_op_id),
|
|
"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,
|
|
"playbook_trust_gate": trust_gate,
|
|
}
|
|
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",
|
|
"next_controlled_action": (
|
|
"generate_or_verify_playbook_repair_candidate"
|
|
if reason == "playbook_trust_circuit_open"
|
|
else "repair_candidate_input_before_retry"
|
|
),
|
|
"playbook_trust_gate": trust_gate,
|
|
}, ensure_ascii=False),
|
|
"dry_run_result": json.dumps({
|
|
"check_mode_executed": False,
|
|
"apply_executed": False,
|
|
"skipped": True,
|
|
"reason": reason,
|
|
"playbook_trust_gate": trust_gate,
|
|
}, 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,
|
|
input = jsonb_set(
|
|
jsonb_set(
|
|
coalesce(input, '{}'::jsonb),
|
|
'{automation_run_id}',
|
|
to_jsonb(CAST(:automation_run_id AS text)),
|
|
true
|
|
),
|
|
'{execution_capability}',
|
|
CAST(:execution_capability AS jsonb),
|
|
true
|
|
),
|
|
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,
|
|
"automation_run_id": str(
|
|
claim.input_payload.get("automation_run_id")
|
|
or claim.source_candidate_op_id
|
|
),
|
|
"execution_capability": json.dumps(
|
|
claim.input_payload.get("execution_capability") or {},
|
|
ensure_ascii=False,
|
|
),
|
|
"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:
|
|
bounded_timeout_seconds = _execution_capability_timeout_seconds(
|
|
claim,
|
|
execution_mode="check_mode",
|
|
requested_timeout_seconds=timeout_seconds,
|
|
)
|
|
spec = build_ansible_check_mode_command(
|
|
playbook_path=claim.playbook_path,
|
|
inventory_hosts=claim.inventory_hosts,
|
|
)
|
|
result = await _run_ansible_command(
|
|
spec,
|
|
timeout_seconds=bounded_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
|
|
|
|
bounded_timeout_seconds = _execution_capability_timeout_seconds(
|
|
claim,
|
|
execution_mode="controlled_apply",
|
|
requested_timeout_seconds=timeout_seconds,
|
|
)
|
|
|
|
apply_idempotency_key = str(
|
|
claim.input_payload.get("apply_idempotency_key")
|
|
or f"ansible-apply:{claim.source_candidate_op_id}:{claim.catalog_id}"
|
|
)
|
|
async with get_db_context(project_id) as db:
|
|
await db.execute(
|
|
text("""
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtextextended(:idempotency_key, 0)
|
|
)
|
|
"""),
|
|
{"idempotency_key": apply_idempotency_key},
|
|
)
|
|
existing = await db.execute(
|
|
text("""
|
|
SELECT op_id, status
|
|
FROM automation_operation_log
|
|
WHERE operation_type = 'ansible_apply_executed'
|
|
AND parent_op_id = CAST(:parent_op_id AS uuid)
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
"""),
|
|
{"parent_op_id": claim.op_id},
|
|
)
|
|
existing_row = existing.mappings().first()
|
|
if existing_row is not None:
|
|
claim.input_payload["apply_duplicate_suppressed"] = True
|
|
claim.input_payload["existing_apply_op_id"] = str(
|
|
existing_row.get("op_id") or ""
|
|
)
|
|
claim.input_payload["existing_apply_status"] = str(
|
|
existing_row.get("status") or ""
|
|
)
|
|
logger.warning(
|
|
"ansible_controlled_apply_duplicate_suppressed",
|
|
source_candidate_op_id=claim.source_candidate_op_id,
|
|
check_mode_op_id=claim.op_id,
|
|
existing_apply_op_id=claim.input_payload["existing_apply_op_id"],
|
|
existing_apply_status=claim.input_payload["existing_apply_status"],
|
|
idempotency_key=apply_idempotency_key,
|
|
)
|
|
return None
|
|
if not await _revalidate_claim_playbook_trust(db, claim):
|
|
logger.warning(
|
|
"ansible_controlled_apply_blocked_by_playbook_trust",
|
|
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="playbook_trust_circuit_open",
|
|
)
|
|
return None
|
|
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,
|
|
"automation_run_id": str(
|
|
claim.input_payload.get("automation_run_id")
|
|
or claim.source_candidate_op_id
|
|
),
|
|
"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,
|
|
"apply_idempotency_key": apply_idempotency_key,
|
|
"single_writer_executor": "awoooi-ansible-executor-broker",
|
|
"router_source_sha": str(
|
|
claim.input_payload.get("router_source_sha") or ""
|
|
)
|
|
.strip()
|
|
.lower(),
|
|
"runtime_stage_receipts": (
|
|
build_ansible_pre_apply_runtime_stage_receipts(claim)
|
|
),
|
|
}, 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())
|
|
|
|
lifecycle_started = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"EXECUTION_STARTED",
|
|
apply_op_id=apply_op_id,
|
|
success=True,
|
|
action_detail=f"controlled_apply_started:{claim.catalog_id}",
|
|
project_id=project_id,
|
|
)
|
|
cancelled = False
|
|
if not lifecycle_started:
|
|
result = AnsibleRunResult(
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="alert_lifecycle_start_receipt_not_persisted",
|
|
duration_ms=0,
|
|
)
|
|
else:
|
|
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=bounded_timeout_seconds,
|
|
)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
result = AnsibleRunResult(
|
|
returncode=130,
|
|
stdout="",
|
|
stderr="ansible_controlled_apply_interrupted_by_worker_shutdown",
|
|
duration_ms=0,
|
|
)
|
|
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,
|
|
},
|
|
)
|
|
if cancelled:
|
|
logger.warning(
|
|
"ansible_controlled_apply_interrupted",
|
|
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,
|
|
)
|
|
raise asyncio.CancelledError
|
|
writeback = await _record_post_apply_verifier_and_learning(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
verified_result = replace(
|
|
result,
|
|
post_verifier_passed=(writeback.get("verification_passed") is True),
|
|
)
|
|
receipt_written = await _record_auto_repair_execution_receipt(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
timeline_receipt = await _record_timeline_projection_receipt(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
learning_receipts = tuple(
|
|
receipt
|
|
for receipt in writeback.get("runtime_stage_receipts", [])
|
|
if isinstance(receipt, dict)
|
|
)
|
|
runtime_stage_receipts_written = await _record_runtime_stage_receipts(
|
|
claim,
|
|
result,
|
|
apply_op_id=apply_op_id,
|
|
verifier_ready=bool(writeback.get("verification_passed")),
|
|
project_id=project_id,
|
|
extra_receipts=(
|
|
*((timeline_receipt,) if timeline_receipt else ()),
|
|
*learning_receipts,
|
|
),
|
|
)
|
|
approval_projection_written = await _finalize_controlled_approval_projection(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=apply_op_id,
|
|
project_id=project_id,
|
|
)
|
|
execution_success = bool(
|
|
verified_result.returncode == 0
|
|
and verified_result.post_verifier_passed is True
|
|
)
|
|
lifecycle_completed = await _append_alert_lifecycle_receipt(
|
|
claim,
|
|
"EXECUTION_COMPLETED",
|
|
apply_op_id=apply_op_id,
|
|
success=execution_success,
|
|
action_detail=f"controlled_apply_completed:{claim.catalog_id}",
|
|
project_id=project_id,
|
|
error_message=(
|
|
None
|
|
if execution_success
|
|
else _tail(
|
|
verified_result.stderr or "independent_post_verifier_failed",
|
|
2000,
|
|
)
|
|
),
|
|
post_verifier_passed=verified_result.post_verifier_passed,
|
|
)
|
|
# Single dispatch entry: `_send_controlled_apply_telegram_receipt` and the
|
|
# `"TELEGRAM_RESULT_SENT"` lifecycle are owned by closure reconciliation.
|
|
try:
|
|
closure = await _reconcile_verified_apply_closure_projections(
|
|
claim,
|
|
verified_result,
|
|
apply_op_id=apply_op_id,
|
|
writeback=writeback,
|
|
project_id=project_id,
|
|
)
|
|
except Exception as exc:
|
|
closure = {
|
|
"status": "closure_reconcile_retry_required",
|
|
"closed": False,
|
|
"missing": [f"closure_reconcile:{type(exc).__name__}"],
|
|
}
|
|
logger.warning(
|
|
"ansible_verified_apply_closure_reconcile_failed",
|
|
automation_run_id=_automation_run_id_for_claim(claim),
|
|
incident_id=claim.incident_id,
|
|
apply_op_id=apply_op_id,
|
|
error_type=type(exc).__name__,
|
|
runtime_apply_replay_required=False,
|
|
)
|
|
|
|
telegram_receipt_sent = (
|
|
closure.get("telegram_receipt_acknowledged") is True
|
|
)
|
|
lifecycle_telegram_written = (
|
|
closure.get("telegram_lifecycle_written") is True
|
|
)
|
|
|
|
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_verification_passed=writeback.get("verification_passed"),
|
|
post_apply_verification_result=writeback.get("verification_result"),
|
|
post_apply_learning_written=writeback.get("learning"),
|
|
rag_writeback_written=writeback.get("rag_writeback"),
|
|
playbook_trust_written=writeback.get("trust_learning"),
|
|
timeline_projection_written=timeline_receipt is not None,
|
|
runtime_stage_receipts_written=runtime_stage_receipts_written,
|
|
approval_projection_written=approval_projection_written,
|
|
alert_lifecycle_started=lifecycle_started,
|
|
alert_lifecycle_completed=lifecycle_completed,
|
|
telegram_receipt_sent=telegram_receipt_sent,
|
|
alert_lifecycle_telegram_written=lifecycle_telegram_written,
|
|
incident_closure_status=closure.get("status"),
|
|
incident_closure_readback=closure.get("closed") is True,
|
|
incident_closure_missing=closure.get("missing") or [],
|
|
)
|
|
return verified_result
|
|
|
|
|
|
async def run_pending_check_modes_once(
|
|
*,
|
|
project_id: str = "awoooi",
|
|
limit: int = 1,
|
|
timeout_seconds: int | None = None,
|
|
) -> dict[str, Any]:
|
|
expired_capability_count = await _expire_stale_ansible_execution_capabilities(
|
|
project_id=project_id,
|
|
)
|
|
effective_timeout_seconds = (
|
|
timeout_seconds or settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS
|
|
)
|
|
stdin_projection_stats = (
|
|
await backfill_missing_stdin_boundary_terminal_projections_once(
|
|
project_id=project_id,
|
|
window_hours=_STDIN_BOUNDARY_REPLAY_WINDOW_HOURS,
|
|
limit=_STDIN_BOUNDARY_TERMINAL_PROJECTION_BATCH_LIMIT,
|
|
)
|
|
)
|
|
stdin_projection_summary = {
|
|
"stdin_terminal_projection_scanned": int(
|
|
stdin_projection_stats.get("scanned") or 0
|
|
),
|
|
"stdin_terminal_projection_written": int(
|
|
stdin_projection_stats.get("written") or 0
|
|
),
|
|
"stdin_terminal_projection_learning_written": int(
|
|
stdin_projection_stats.get("learning_written") or 0
|
|
),
|
|
"stdin_terminal_projection_telegram_acknowledged": int(
|
|
stdin_projection_stats.get("telegram_receipt_acknowledged") or 0
|
|
),
|
|
"stdin_terminal_projection_incident_receipt_written": int(
|
|
stdin_projection_stats.get("incident_receipt_written") or 0
|
|
),
|
|
"stdin_terminal_projection_verified": int(
|
|
stdin_projection_stats.get("verified") or 0
|
|
),
|
|
"stdin_terminal_projection_blockers": [
|
|
str(item)[:300]
|
|
for item in stdin_projection_stats.get("blockers") or []
|
|
],
|
|
"stdin_terminal_projection_error": (
|
|
str(stdin_projection_stats.get("error") or "")[:500] or None
|
|
),
|
|
"stdin_terminal_projection_runtime_apply_executed": False,
|
|
}
|
|
if (
|
|
stdin_projection_summary["stdin_terminal_projection_scanned"]
|
|
or stdin_projection_summary["stdin_terminal_projection_blockers"]
|
|
or stdin_projection_summary["stdin_terminal_projection_error"]
|
|
):
|
|
logger.info(
|
|
"ansible_stdin_boundary_projection_priority_tick",
|
|
project_id=project_id,
|
|
**stdin_projection_summary,
|
|
)
|
|
receipt_stats = await backfill_missing_auto_repair_execution_receipts_once(
|
|
project_id=project_id,
|
|
window_hours=24,
|
|
limit=max(1, min(int(limit), 5)),
|
|
)
|
|
receipt_backfill_summary = {
|
|
"repair_receipt_backfill_scanned": int(
|
|
receipt_stats.get("scanned") or 0
|
|
),
|
|
"repair_receipt_backfill_written": int(
|
|
receipt_stats.get("written") or 0
|
|
),
|
|
"repair_receipt_closure_written": int(
|
|
receipt_stats.get("incident_closure_written") or 0
|
|
),
|
|
"repair_receipt_telegram_acknowledged": int(
|
|
receipt_stats.get("telegram_receipt_acknowledged") or 0
|
|
),
|
|
"retry_terminal_projection_scanned": int(
|
|
receipt_stats.get("retry_terminal_projection_scanned") or 0
|
|
),
|
|
"retry_terminal_projection_written": int(
|
|
receipt_stats.get("retry_terminal_projection_written") or 0
|
|
),
|
|
"retry_terminal_projection_retry_receipt_written": int(
|
|
receipt_stats.get(
|
|
"retry_terminal_projection_retry_receipt_written"
|
|
)
|
|
or 0
|
|
),
|
|
"retry_terminal_projection_telegram_receipt_acknowledged": int(
|
|
receipt_stats.get(
|
|
"retry_terminal_projection_telegram_receipt_acknowledged"
|
|
)
|
|
or 0
|
|
),
|
|
"retry_terminal_projection_verified": int(
|
|
receipt_stats.get("retry_terminal_projection_verified") or 0
|
|
),
|
|
"retry_terminal_projection_runtime_apply_executed": False,
|
|
"repair_receipt_backfill_error": (
|
|
str(receipt_stats.get("error") or "")[:500] or None
|
|
),
|
|
}
|
|
receipt_backfill_priority_tick = bool(
|
|
receipt_backfill_summary["repair_receipt_backfill_scanned"]
|
|
or receipt_backfill_summary["repair_receipt_backfill_error"]
|
|
)
|
|
receipt_backfill_summary["repair_receipt_backfill_priority_tick"] = (
|
|
receipt_backfill_priority_tick
|
|
)
|
|
if receipt_backfill_priority_tick:
|
|
logger.info(
|
|
"ansible_execution_broker_receipt_backfill_priority_tick",
|
|
project_id=project_id,
|
|
**receipt_backfill_summary,
|
|
)
|
|
retry_stats = await run_failed_apply_check_mode_replay_once(
|
|
project_id=project_id,
|
|
window_hours=24,
|
|
timeout_seconds=effective_timeout_seconds,
|
|
)
|
|
failed_apply_retry_blockers = [
|
|
str(item)[:200]
|
|
for item in retry_stats.get("blockers") or []
|
|
]
|
|
failed_apply_retry_error = (
|
|
str(retry_stats.get("error") or "")[:500] or None
|
|
)
|
|
failed_apply_retry_summary = {
|
|
"failed_apply_retry_scanned": int(retry_stats.get("scanned") or 0),
|
|
"failed_apply_retry_replayed": int(retry_stats.get("replayed") or 0),
|
|
"failed_apply_retry_check_mode_passed": int(
|
|
retry_stats.get("check_mode_passed") or 0
|
|
),
|
|
"failed_apply_retry_check_mode_failed": int(
|
|
retry_stats.get("check_mode_failed") or 0
|
|
),
|
|
"failed_apply_retry_stage_receipt_written": int(
|
|
retry_stats.get("runtime_stage_receipt_written") or 0
|
|
),
|
|
"failed_apply_retry_stale_reclaimed": int(
|
|
retry_stats.get("stale_retry_reclaimed") or 0
|
|
),
|
|
"failed_apply_retry_stale_terminalized": int(
|
|
retry_stats.get("stale_retry_terminalized") or 0
|
|
),
|
|
"failed_apply_retry_blockers": failed_apply_retry_blockers,
|
|
"failed_apply_retry_error": failed_apply_retry_error,
|
|
}
|
|
failed_apply_retry_priority_tick = bool(
|
|
failed_apply_retry_summary["failed_apply_retry_scanned"]
|
|
or failed_apply_retry_summary["failed_apply_retry_replayed"]
|
|
or failed_apply_retry_blockers
|
|
or failed_apply_retry_error
|
|
)
|
|
failed_apply_retry_summary["failed_apply_retry_priority_tick"] = (
|
|
failed_apply_retry_priority_tick
|
|
)
|
|
if failed_apply_retry_priority_tick:
|
|
logger.info(
|
|
"ansible_execution_broker_failed_apply_retry_priority_tick",
|
|
project_id=project_id,
|
|
**failed_apply_retry_summary,
|
|
)
|
|
blockers = _runtime_blockers()
|
|
if blockers:
|
|
logger.warning("ansible_check_mode_runtime_blocked", blockers=blockers)
|
|
return {
|
|
"claimed": 0,
|
|
"completed": 0,
|
|
"failed": 0,
|
|
"capability_expired": expired_capability_count,
|
|
"blockers": blockers,
|
|
**stdin_projection_summary,
|
|
**receipt_backfill_summary,
|
|
**failed_apply_retry_summary,
|
|
}
|
|
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,
|
|
"capability_expired": expired_capability_count,
|
|
"blockers": transport_blockers,
|
|
**stdin_projection_summary,
|
|
**receipt_backfill_summary,
|
|
**failed_apply_retry_summary,
|
|
}
|
|
reclaimed_claims = await claim_stale_pending_check_modes(
|
|
project_id=project_id,
|
|
limit=limit,
|
|
stale_after_seconds=max(300, effective_timeout_seconds + 120),
|
|
)
|
|
remaining_limit = max(0, limit - len(reclaimed_claims))
|
|
stdin_boundary_replay_claims: list[AnsibleCheckModeClaim] = []
|
|
stdin_boundary_replay_error: str | None = None
|
|
if remaining_limit:
|
|
try:
|
|
stdin_boundary_replay_claims = (
|
|
await claim_stdin_boundary_failed_check_modes(
|
|
project_id=project_id,
|
|
limit=remaining_limit,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
stdin_boundary_replay_error = type(exc).__name__
|
|
logger.warning(
|
|
"ansible_stdin_boundary_replay_claim_failed",
|
|
project_id=project_id,
|
|
error_type=stdin_boundary_replay_error,
|
|
fresh_candidate_claim_continues=True,
|
|
)
|
|
remaining_limit = max(
|
|
0,
|
|
remaining_limit - len(stdin_boundary_replay_claims),
|
|
)
|
|
semantic_catalog_reconciliation_claims: list[
|
|
AnsibleCheckModeClaim
|
|
] = []
|
|
semantic_catalog_reconciliation_error: str | None = None
|
|
if remaining_limit:
|
|
try:
|
|
semantic_catalog_reconciliation_claims = (
|
|
await claim_semantically_misrouted_verified_applies(
|
|
project_id=project_id,
|
|
limit=remaining_limit,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
semantic_catalog_reconciliation_error = type(exc).__name__
|
|
logger.warning(
|
|
"ansible_semantic_catalog_reconciliation_claim_failed",
|
|
project_id=project_id,
|
|
error_type=semantic_catalog_reconciliation_error,
|
|
fresh_candidate_claim_continues=True,
|
|
)
|
|
remaining_limit = max(
|
|
0,
|
|
remaining_limit - len(semantic_catalog_reconciliation_claims),
|
|
)
|
|
failed_apply_catalog_replay_claims: list[AnsibleCheckModeClaim] = []
|
|
failed_apply_catalog_replay_error: str | None = None
|
|
if remaining_limit:
|
|
try:
|
|
failed_apply_catalog_replay_claims = (
|
|
await claim_catalog_drift_failed_applies(
|
|
project_id=project_id,
|
|
limit=remaining_limit,
|
|
candidate_max_age_hours=(
|
|
settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS
|
|
),
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
failed_apply_catalog_replay_error = type(exc).__name__
|
|
logger.warning(
|
|
"ansible_failed_apply_catalog_repair_claim_failed",
|
|
project_id=project_id,
|
|
error_type=failed_apply_catalog_replay_error,
|
|
fresh_candidate_claim_continues=True,
|
|
)
|
|
remaining_limit = max(
|
|
0,
|
|
remaining_limit - len(failed_apply_catalog_replay_claims),
|
|
)
|
|
catalog_replay_claims: list[AnsibleCheckModeClaim] = []
|
|
catalog_replay_error: str | None = None
|
|
if remaining_limit:
|
|
try:
|
|
catalog_replay_claims = await claim_catalog_drift_failed_check_modes(
|
|
project_id=project_id,
|
|
limit=remaining_limit,
|
|
candidate_max_age_hours=(
|
|
settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
catalog_replay_error = type(exc).__name__
|
|
logger.warning(
|
|
"ansible_catalog_drift_replay_claim_failed",
|
|
project_id=project_id,
|
|
error_type=catalog_replay_error,
|
|
fresh_candidate_claim_continues=True,
|
|
)
|
|
remaining_limit = max(0, remaining_limit - len(catalog_replay_claims))
|
|
fresh_claims = (
|
|
await claim_pending_check_modes(
|
|
project_id=project_id,
|
|
limit=remaining_limit,
|
|
candidate_max_age_hours=(
|
|
settings.AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS
|
|
),
|
|
)
|
|
if remaining_limit
|
|
else []
|
|
)
|
|
claims = [
|
|
*reclaimed_claims,
|
|
*stdin_boundary_replay_claims,
|
|
*semantic_catalog_reconciliation_claims,
|
|
*failed_apply_catalog_replay_claims,
|
|
*catalog_replay_claims,
|
|
*fresh_claims,
|
|
]
|
|
completed = 0
|
|
failed = 0
|
|
apply_completed = 0
|
|
apply_failed = 0
|
|
apply_blocked = 0
|
|
apply_duplicate_suppressed = 0
|
|
capability_issued = 0
|
|
capability_revoked = 0
|
|
capability_revoke_failed = 0
|
|
stdin_boundary_replay_terminal_written = 0
|
|
for claim in claims:
|
|
capability_op_id = ""
|
|
terminal_status = "broker_interrupted_before_execution"
|
|
try:
|
|
claim, capability_op_id = await _issue_ansible_execution_capability(
|
|
claim,
|
|
ttl_seconds=(
|
|
effective_timeout_seconds
|
|
+ settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_TIMEOUT_SECONDS
|
|
+ 120
|
|
),
|
|
project_id=project_id,
|
|
)
|
|
capability_issued += 1
|
|
result = await run_claimed_check_mode(
|
|
claim,
|
|
timeout_seconds=effective_timeout_seconds,
|
|
project_id=project_id,
|
|
)
|
|
completed += 1
|
|
if claim.input_payload.get(
|
|
"historical_stdin_boundary_replay"
|
|
) is True:
|
|
terminal_written = (
|
|
await _record_stdin_boundary_replay_terminal(
|
|
claim,
|
|
result,
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
stdin_boundary_replay_terminal_written += int(
|
|
terminal_written
|
|
)
|
|
apply_blocked += 1
|
|
if result.returncode != 0:
|
|
failed += 1
|
|
terminal_status = (
|
|
"stdin_boundary_replay_failed_no_write_terminal"
|
|
if terminal_written
|
|
else "stdin_boundary_replay_failed_terminal_missing"
|
|
)
|
|
else:
|
|
terminal_status = (
|
|
"stdin_boundary_replay_passed_no_write_terminal"
|
|
if terminal_written
|
|
else "stdin_boundary_replay_passed_terminal_missing"
|
|
)
|
|
continue
|
|
if result.returncode != 0:
|
|
failed += 1
|
|
terminal_status = "check_mode_failed"
|
|
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:
|
|
if claim.input_payload.get("apply_duplicate_suppressed") is True:
|
|
apply_duplicate_suppressed += 1
|
|
terminal_status = "controlled_apply_duplicate_suppressed"
|
|
else:
|
|
apply_blocked += 1
|
|
terminal_status = "check_mode_passed_apply_blocked_by_policy"
|
|
else:
|
|
apply_completed += 1
|
|
if apply_result.returncode != 0:
|
|
apply_failed += 1
|
|
terminal_status = "controlled_apply_failed"
|
|
elif apply_result.post_verifier_passed is not True:
|
|
apply_failed += 1
|
|
terminal_status = "controlled_apply_verifier_failed"
|
|
else:
|
|
terminal_status = "controlled_apply_verified"
|
|
except asyncio.CancelledError:
|
|
terminal_status = "broker_shutdown"
|
|
raise
|
|
except ValueError as exc:
|
|
failed += 1
|
|
terminal_status = f"broker_policy_error:{exc}"
|
|
if not capability_op_id:
|
|
await finalize_check_mode_claim(
|
|
claim,
|
|
AnsibleRunResult(
|
|
returncode=1,
|
|
stdout="",
|
|
stderr=f"ansible_execution_capability_rejected: {exc}",
|
|
duration_ms=0,
|
|
),
|
|
project_id=project_id,
|
|
)
|
|
completed += 1
|
|
logger.warning(
|
|
"ansible_execution_broker_claim_rejected",
|
|
op_id=claim.op_id,
|
|
automation_run_id=_automation_run_id_for_claim(claim),
|
|
blocker=str(exc),
|
|
)
|
|
except Exception as exc:
|
|
failed += 1
|
|
terminal_status = f"broker_error:{type(exc).__name__}"
|
|
logger.warning(
|
|
"ansible_execution_broker_claim_failed",
|
|
op_id=claim.op_id,
|
|
automation_run_id=_automation_run_id_for_claim(claim),
|
|
error_type=type(exc).__name__,
|
|
)
|
|
if not capability_op_id:
|
|
raise
|
|
finally:
|
|
if capability_op_id:
|
|
try:
|
|
revoked = await _revoke_ansible_execution_capability(
|
|
claim,
|
|
capability_op_id=capability_op_id,
|
|
terminal_status=terminal_status,
|
|
project_id=project_id,
|
|
)
|
|
capability_revoked += 1 if revoked else 0
|
|
except Exception as exc:
|
|
capability_revoke_failed += 1
|
|
logger.warning(
|
|
"ansible_execution_capability_revoke_failed",
|
|
capability_op_id=capability_op_id,
|
|
automation_run_id=_automation_run_id_for_claim(claim),
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return {
|
|
"claimed": len(claims),
|
|
"reclaimed": len(reclaimed_claims),
|
|
"stdin_boundary_replayed": len(stdin_boundary_replay_claims),
|
|
"stdin_boundary_replay_terminal_written": (
|
|
stdin_boundary_replay_terminal_written
|
|
),
|
|
"stdin_boundary_replay_error": stdin_boundary_replay_error,
|
|
"semantic_catalog_reconciled": len(
|
|
semantic_catalog_reconciliation_claims
|
|
),
|
|
"semantic_catalog_reconciliation_error": (
|
|
semantic_catalog_reconciliation_error
|
|
),
|
|
"failed_apply_catalog_replayed": len(
|
|
failed_apply_catalog_replay_claims
|
|
),
|
|
"failed_apply_catalog_replay_error": (
|
|
failed_apply_catalog_replay_error
|
|
),
|
|
"catalog_replayed": len(catalog_replay_claims),
|
|
"completed": completed,
|
|
"failed": failed,
|
|
"apply_completed": apply_completed,
|
|
"apply_failed": apply_failed,
|
|
"apply_blocked": apply_blocked,
|
|
"apply_duplicate_suppressed": apply_duplicate_suppressed,
|
|
"capability_issued": capability_issued,
|
|
"capability_revoked": capability_revoked,
|
|
"capability_expired": expired_capability_count,
|
|
"capability_revoke_failed": capability_revoke_failed,
|
|
"catalog_replay_error": catalog_replay_error,
|
|
**stdin_projection_summary,
|
|
**receipt_backfill_summary,
|
|
**failed_apply_retry_summary,
|
|
"blockers": [],
|
|
}
|