""" Runbook runs API — create run, get run, next step, complete step, abort (PR2). Evidence + post-review generation (PR4). All under require_auth. """ from __future__ import annotations from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Body from .auth import require_auth from . import runbook_runner as runner from . import runbook_artifacts as artifacts runbook_runs_router = APIRouter(prefix="/api/runbooks/runs", tags=["runbook-runs"]) @runbook_runs_router.post("") async def create_run( body: Dict[str, Any] = Body(...), _auth: str = Depends(require_auth), ): """Create a new run from runbook_path. Returns run_id, status, current_step, steps_total.""" runbook_path = (body.get("runbook_path") or "").strip() if not runbook_path or ".." in runbook_path: raise HTTPException(status_code=400, detail="Invalid runbook_path") try: out = await runner.create_run( runbook_path=runbook_path, operator_id=(body.get("operator_id") or "").strip() or None, node_id=(body.get("node_id") or "").strip() or None, sofiia_url=(body.get("sofiia_url") or "").strip() or None, data_json=body.get("data"), ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return out @runbook_runs_router.get("/{run_id}") async def get_run( run_id: str, _auth: str = Depends(require_auth), ): """Get run and steps (light).""" run = await runner.get_run(run_id) if not run: raise HTTPException(status_code=404, detail="Run not found") return run @runbook_runs_router.post("/{run_id}/next") async def next_step( run_id: str, body: Dict[str, Any] = Body(default=None), _auth: str = Depends(require_auth), ): """Execute next step: if http_check run it and return result; if manual return instructions.""" operator_id = (body or {}).get("operator_id") or "" out = await runner.next_step(run_id, operator_id=operator_id) if out is None: raise HTTPException(status_code=404, detail="Run not found or not active") return out @runbook_runs_router.post("/{run_id}/steps/{step_index}/complete") async def complete_step( run_id: str, step_index: int, body: Dict[str, Any] = Body(...), _auth: str = Depends(require_auth), ): """Mark manual step complete. Body: status (ok|warn|fail|skipped), notes, data.""" status = (body.get("status") or "ok").strip() notes = (body.get("notes") or "").strip() data = body.get("data") operator_id = (body.get("operator_id") or "").strip() ok = await runner.complete_step(run_id, step_index, status=status, notes=notes, data=data, operator_id=operator_id) if not ok: raise HTTPException(status_code=404, detail="Run/step not found or step not current") return {"ok": True, "run_id": run_id, "step_index": step_index, "next_step": step_index + 1} @runbook_runs_router.get("/{run_id}/artifacts") async def list_artifacts( run_id: str, _auth: str = Depends(require_auth), ): """List generated artifacts for a run (paths, sizes, mtimes).""" return await artifacts.list_run_artifacts(run_id) @runbook_runs_router.post("/{run_id}/evidence") async def generate_evidence( run_id: str, _auth: str = Depends(require_auth), ): """Generate release evidence markdown from run step results. Returns {evidence_path, bytes, created_at}.""" try: out = await artifacts.render_release_evidence(run_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return out @runbook_runs_router.post("/{run_id}/post_review") async def generate_post_review( run_id: str, _auth: str = Depends(require_auth), ): """Generate post-release review markdown. Returns {path, bytes, created_at}.""" try: out = await artifacts.render_post_review(run_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return out @runbook_runs_router.post("/{run_id}/abort") async def abort_run( run_id: str, body: Dict[str, Any] = Body(default=None), _auth: str = Depends(require_auth), ): """Abort run.""" operator_id = (body or {}).get("operator_id") or "" ok = await runner.abort_run(run_id, operator_id=operator_id) if not ok: raise HTTPException(status_code=404, detail="Run not found") return {"ok": True, "run_id": run_id, "status": "aborted"}