from __future__ import annotations from typing import Any from urllib.parse import urlparse import httpx from ..connectors.portainer import PortainerConnector from ..core.config import settings from ..core.redaction import redact_message from ..db.models import JobRun from ..services.approval_service import create_approval from ..services.audit_logger import log_event from ..services.job_runner import complete_job, create_job, start_job from ..services.policy_engine import evaluate_policy def _widefrog_health_endpoint() -> tuple[str | None, str | None]: raw_url = str(settings.apis.get("WIDEFROG_URL") or "").strip() if not raw_url: return None, "missing_config" candidate = ( raw_url if raw_url.startswith(("http://", "https://")) else f"http://{raw_url}" ) try: parsed = urlparse(candidate) except Exception: return None, "invalid_widefrog_url" host = parsed.hostname if not host: return None, "invalid_widefrog_url" port = parsed.port or 1207 return f"{parsed.scheme}://{host}:{port}/", None def _widefrog_health_check() -> dict[str, Any]: health_url, reason = _widefrog_health_endpoint() if not health_url: return {"status": "error", "reason": reason or "missing_config"} try: with httpx.Client(timeout=4.0, verify=settings.verify_tls) as client: response = client.get(health_url) except (httpx.RequestError, ValueError) as exc: return {"status": "error", "reason": redact_message(str(exc))} if response.status_code >= 500: return { "status": "error", "reason": f"widefrog health check failed: HTTP {response.status_code}", } if response.status_code >= 400: return { "status": "warning", "reason": f"widefrog health returned HTTP {response.status_code}", } return {"status": "ok"} def _format_success_result( summary: str, *, steps: list[str] | None = None, before_state: dict[str, Any] | None = None, after_state: dict[str, Any] | None = None, post_checks: list[str] | None = None, ) -> dict[str, Any]: return { "success": True, "decision": "allowed", "status": "success", "policy_decision": "allowed", "result": "success", "message": summary, "plan": steps or [], "steps": steps or [], "before_state": before_state or {}, "after_state": after_state or {}, "post_checks": post_checks or [], } def _format_blocked_result(audit_id: str, reason: str) -> dict[str, Any]: return { "success": False, "decision": "blocked", "status": "failed", "policy_decision": "blocked", "result": "blocked", "message": reason, "auditId": audit_id, } def _format_approval_result( audit_id: str, approval_id: str, reason: str ) -> dict[str, Any]: return { "success": False, "decision": "approval_required", "status": "failed", "policy_decision": "approval_required", "result": "approval_required", "message": reason, "approvalId": approval_id, "auditId": audit_id, } def execute_container_restart( *, session, action: str, target: str, source: str = "manual", risk: str = "medium", steps: list[str] | None = None, approved: bool = False, policy_override: dict[str, Any] | None = None, ): plan_steps = steps or [ "Resolve container", "Restart container", "Post-check health", ] if policy_override is not None: decision = { "decision": str(policy_override.get("decision") or "blocked"), "reason": str(policy_override.get("reason") or "Policy override."), "risk": str(policy_override.get("risk") or risk), } elif approved: decision = { "decision": "allowed", "reason": "Exact action context was approved by an AppOps operator.", "risk": risk, } else: decision = evaluate_policy(session, action=action, target=target, source=source) if decision["decision"] == "blocked": audit = log_event( session, source="action", identity=source, action_id=action, target=target, risk=risk, policy_decision=decision["decision"], reason=decision["reason"], result="blocked", execution_plan={"plan": plan_steps, "policyReason": decision["reason"]}, ) return _format_blocked_result(audit.id, decision["reason"]) if decision["decision"] == "approval_required": approval = create_approval( session, action=action, target=target, source=source, risk=risk, reason=decision["reason"], requester=source, ) audit = log_event( session, source="action", identity=source, action_id=action, target=target, risk=risk, policy_decision=decision["decision"], reason=decision["reason"], result="approval_requested", execution_plan={"plan": plan_steps, "approvalId": approval.id}, redacted_metadata={"approval_id": approval.id}, ) return _format_approval_result( audit.id, approval.id, "Action requires approval before execution." ) # Allowed restart flow job: JobRun = create_job( session, action_id=action, target=target, source=source, risk=risk, policy_decision=decision["decision"], steps=plan_steps, ) start_job(session, job) try: connector = PortainerConnector() precheck = connector.get_container_status(target) if precheck.get("status") != "ok": # keep execution context in logs while still allowing best-effort continuation precheck = { "status": "warn", "container": {"name": target}, "reason": precheck.get("reason"), } restart_result = connector.restart_container(target) if restart_result.get("status") != "ok": raise RuntimeError( str(restart_result.get("reason", "container_restart_failed")) ) post_check = {"container": restart_result.get("container", target)} if target == "widefrog": health = _widefrog_health_check() post_checks = [ f"widefrog health: {health.get('status')}", f"widefrog reason: {health.get('reason', '')}".strip(), ] post_check["widefrog_health"] = health else: post_checks = [] health = {} completed = complete_job( session, job, status="succeeded", result_summary="Container restart executed through Portainer connector.", ) audit = log_event( session, source="action", identity=source, action_id=action, target=target, risk=risk, policy_decision=decision["decision"], reason=decision["reason"], result="success", execution_plan={ "plan": plan_steps, "jobId": completed.id, "precheck": precheck, }, before_state=precheck, after_state=post_check, post_checks=health if target == "widefrog" else None, ) result = _format_success_result( "Container restart executed.", steps=plan_steps, before_state=precheck, after_state=post_check, post_checks=post_checks, ) result["jobId"] = completed.id result["auditId"] = audit.id return result except Exception as exc: failed = complete_job( session, job, status="failed", result_summary=f"Container restart failed: {redact_message(str(exc))}", ) audit = log_event( session, source="action", identity=source, action_id=action, target=target, risk=risk, policy_decision=decision["decision"], reason="Container restart execution failed.", result="failed", execution_plan={"plan": plan_steps, "jobId": failed.id}, after_state={"status": "failed", "reason": redact_message(str(exc))}, ) return { "success": False, "decision": "failed", "status": "failed", "policy_decision": decision["decision"], "result": "failed", "message": redact_message(str(exc)), "jobId": failed.id, "auditId": audit.id, }