Files
microdao-daarion/services/sofiia-console/app/runbook_runs_router.py
Apple 8879da1e7f feat(sofiia-console): add auto-evidence and post-review generation from runbook runs
- adds runbook_artifacts.py: server-side render of release_evidence.md and
  post_review.md from DB step results (no shell); saves to
  SOFIIA_DATA_DIR/release_artifacts/<run_id>/
- evidence: auto-fills preflight/smoke/script outcomes, step table, timestamps
- post_review: auto-fills metadata, smoke results, incidents from step statuses;
  leaves [TODO] markers for manual observation sections
- adds POST /api/runbooks/runs/{run_id}/evidence and /post_review endpoints
- updates runbook_runs.evidence_path in DB after render
- adds 11 tests covering file creation, key sections, TODO markers, 404s, API

Made-with: Cursor
2026-03-03 05:07:52 -08:00

123 lines
4.2 KiB
Python

"""
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.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"}