""" Runbook runner — create run, next_step (execute http_check/script or return manual), complete_step, abort. PR3: adds script action_type via SafeExecutor (allowlisted, no shell=True). """ from __future__ import annotations import json import logging import time import uuid from typing import Any, Dict, List, Optional import httpx from . import db as _db from . import docs_store as _docs_store from .audit import audit_log, AuditEvent from .runbook_parser import RunbookStep, parse_runbook from . import safe_executor as _safe_exec logger = logging.getLogger(__name__) _ALLOWED_HTTP_PATHS = {"/api/health", "/metrics", "/api/audit"} def _now_ts() -> float: return time.time() async def create_run( runbook_path: str, operator_id: str = "", node_id: Optional[str] = None, sofiia_url: Optional[str] = None, data_json: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Parse runbook, insert run + steps, audit. Returns run_id, status, current_step, steps_total.""" raw = await _docs_store.get_raw(runbook_path) if not raw: raise ValueError(f"Runbook not found: {runbook_path}") base_url = sofiia_url or "http://127.0.0.1:8002" steps = parse_runbook(runbook_path, raw, sofiia_url=base_url) if not steps: raise ValueError("Runbook produced no steps") run_id = str(uuid.uuid4()) now = _now_ts() conn = await _db.get_db() await conn.execute( """INSERT INTO runbook_runs(run_id, runbook_path, status, current_step, created_at, started_at, finished_at, operator_id, node_id, sofiia_url, data_json, evidence_path) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( run_id, runbook_path, "running", 0, now, now, None, operator_id or None, node_id, base_url, json.dumps(data_json or {}, separators=(",", ":")) if data_json else None, None, ), ) for s in steps: await conn.execute( """INSERT INTO runbook_steps(run_id, step_index, title, section, action_type, action_json, status, result_json, started_at, finished_at) VALUES (?,?,?,?,?,?,?,?,?,?)""", ( run_id, s.step_index, s.title, s.section, s.action_type, json.dumps(s.action_json, separators=(",", ":")), s.status, None, None, None, ), ) await conn.commit() await audit_log( AuditEvent( event="runbook.run.created", operator_id=operator_id or "unknown", operator_id_missing=not operator_id, node_id=node_id, status="ok", data={"run_id": run_id, "runbook_path": runbook_path, "steps_total": len(steps)}, ) ) return { "run_id": run_id, "status": "running", "current_step": 0, "steps_total": len(steps), } async def get_run(run_id: str) -> Optional[Dict[str, Any]]: """Return run row + steps (light: no large action_json/result_json).""" conn = await _db.get_db() async with conn.execute( "SELECT run_id, runbook_path, status, current_step, created_at, started_at, finished_at," " operator_id, node_id, sofiia_url, evidence_path FROM runbook_runs WHERE run_id = ?", (run_id,), ) as cur: row = await cur.fetchone() if not row: return None run = { "run_id": row[0], "runbook_path": row[1], "status": row[2], "current_step": row[3], "created_at": row[4], "started_at": row[5], "finished_at": row[6], "operator_id": row[7], "node_id": row[8], "sofiia_url": row[9], "evidence_path": row[10], } async with conn.execute( "SELECT step_index, title, section, action_type, status, started_at, finished_at " "FROM runbook_steps WHERE run_id = ? ORDER BY step_index", (run_id,), ) as cur: step_rows = await cur.fetchall() run["steps"] = [ { "step_index": r[0], "title": r[1], "section": r[2], "action_type": r[3], "status": r[4], "started_at": r[5], "finished_at": r[6], } for r in (step_rows or []) ] return run async def _execute_http_check(base_url: str, action: Dict[str, Any]) -> Dict[str, Any]: """Allowlisted GET only; returns {status, status_code, ok, error}.""" path = (action.get("url_path") or "").strip() if not path.startswith("/"): path = "/" + path if path not in _ALLOWED_HTTP_PATHS: return {"ok": False, "error": "path not allowlisted", "status_code": None} url = (base_url or "http://127.0.0.1:8002").rstrip("/") + path expect_statuses = action.get("expect", {}).get("status") or [200] try: async with httpx.AsyncClient(timeout=15.0) as client: r = await client.get(url, headers=action.get("headers") or {}) ok = r.status_code in expect_statuses return { "ok": ok, "status_code": r.status_code, "expected": expect_statuses, "status": "ok" if ok else "fail", } except Exception as e: logger.warning("http_check %s failed: %s", url, e) return {"ok": False, "error": str(e)[:200], "status_code": None, "status": "fail"} async def next_step(run_id: str, operator_id: str = "") -> Optional[Dict[str, Any]]: """ Get current step; if http_check execute and advance; if manual return instructions. Returns either {type: "http_check", step_index, result, ...} or {type: "manual", step_index, title, instructions_md}. """ conn = await _db.get_db() async with conn.execute( "SELECT runbook_path, status, current_step, sofiia_url FROM runbook_runs WHERE run_id = ?", (run_id,), ) as cur: row = await cur.fetchone() if not row or row[1] not in ("running", "paused"): return None runbook_path, status, current_step, sofiia_url = row[0], row[1], row[2], row[3] async with conn.execute( "SELECT step_index, title, section, action_type, action_json, status FROM runbook_steps " "WHERE run_id = ? AND step_index = ?", (run_id, current_step), ) as cur: step_row = await cur.fetchone() if not step_row: return None step_index, title, section, action_type, action_json_str, step_status = step_row action_json = json.loads(action_json_str) if action_json_str else {} started_at = _now_ts() await conn.execute( "UPDATE runbook_steps SET status = ?, started_at = ? WHERE run_id = ? AND step_index = ?", ("running", started_at, run_id, step_index), ) await conn.commit() await audit_log( AuditEvent( event="runbook.step.started", operator_id=operator_id or "unknown", node_id=None, status="ok", data={"run_id": run_id, "step_index": step_index, "action_type": action_type, "title": title}, ) ) if action_type in ("http_check", "script"): if action_type == "http_check": result = await _execute_http_check(sofiia_url or "http://127.0.0.1:8002", action_json) auto_ok = result.get("ok", False) else: # script via SafeExecutor script = action_json.get("script", "") env = action_json.get("env") or {} timeout_s = int(action_json.get("timeout_s", 120)) result = await _safe_exec.run_script(script, env=env, timeout_s=timeout_s) auto_ok = result.get("ok", False) finished_at = _now_ts() duration_ms = int((finished_at - started_at) * 1000) # Non-root warning elevates to "warn" status (not "fail") if script exited 0 if result.get("warning") == "running_as_root" and auto_ok: step_status = "warn" else: step_status = "ok" if auto_ok else "fail" await conn.execute( "UPDATE runbook_steps SET status = ?, result_json = ?, finished_at = ? WHERE run_id = ? AND step_index = ?", (step_status, json.dumps(result, separators=(",", ":")), finished_at, run_id, step_index), ) next_current = current_step + 1 async with conn.execute("SELECT COUNT(*) FROM runbook_steps WHERE run_id = ?", (run_id,)) as cur: total = (await cur.fetchone())[0] if next_current >= total: await conn.execute( "UPDATE runbook_runs SET current_step = ?, status = ?, finished_at = ? WHERE run_id = ?", (next_current, "completed", finished_at, run_id), ) else: await conn.execute("UPDATE runbook_runs SET current_step = ? WHERE run_id = ?", (next_current, run_id)) await conn.commit() await audit_log( AuditEvent( event="runbook.step.completed", operator_id=operator_id or "unknown", node_id=None, status=step_status, duration_ms=duration_ms, data={ "run_id": run_id, "step_index": step_index, "action_type": action_type, "result_ok": auto_ok, "exit_code": result.get("exit_code"), "timeout": result.get("timeout"), }, ) ) return { "type": action_type, "step_index": step_index, "title": title, "result": result, "step_status": step_status, "next_step": next_current, "completed": next_current >= total, } # manual instructions = action_json.get("instructions_md") or title return { "type": "manual", "step_index": step_index, "title": title, "section": section, "instructions_md": instructions, } async def complete_step( run_id: str, step_index: int, status: str = "ok", notes: str = "", data: Optional[Dict[str, Any]] = None, operator_id: str = "", ) -> bool: """Record manual step completion, advance current_step.""" if status not in ("ok", "warn", "fail", "skipped"): status = "ok" conn = await _db.get_db() async with conn.execute( "SELECT current_step FROM runbook_runs WHERE run_id = ? AND runbook_runs.status IN ('running','paused')", (run_id,), ) as cur: row = await cur.fetchone() if not row or row[0] != step_index: return False finished_at = _now_ts() result_json = json.dumps({"status": status, "notes": notes[:500], **(data or {})}, separators=(",", ":")) await conn.execute( "UPDATE runbook_steps SET status = ?, result_json = ?, finished_at = ? WHERE run_id = ? AND step_index = ?", (status, result_json, finished_at, run_id, step_index), ) next_current = step_index + 1 async with conn.execute("SELECT COUNT(*) FROM runbook_steps WHERE run_id = ?", (run_id,)) as cur: total = (await cur.fetchone())[0] if next_current >= total: await conn.execute( "UPDATE runbook_runs SET current_step = ?, status = ?, finished_at = ? WHERE run_id = ?", (next_current, "completed", finished_at, run_id), ) else: await conn.execute("UPDATE runbook_runs SET current_step = ? WHERE run_id = ?", (next_current, run_id)) await conn.commit() await audit_log( AuditEvent( event="runbook.step.completed", operator_id=operator_id or "unknown", status=status, data={"run_id": run_id, "step_index": step_index, "action_type": "manual"}, ) ) return True async def abort_run(run_id: str, operator_id: str = "") -> bool: """Set status=aborted, audit.""" conn = await _db.get_db() async with conn.execute("SELECT 1 FROM runbook_runs WHERE run_id = ?", (run_id,)) as cur: if not await cur.fetchone(): return False now = _now_ts() await conn.execute( "UPDATE runbook_runs SET status = ?, finished_at = ? WHERE run_id = ?", ("aborted", now, run_id), ) await conn.commit() await audit_log( AuditEvent( event="runbook.run.aborted", operator_id=operator_id or "unknown", status="ok", data={"run_id": run_id}, ) ) return True