feat(sofiia-console): add guided runbook runner with http checks and audit integration
adds runbook_runs/runbook_steps state machine parses markdown runbooks into guided steps supports allowlisted http_check (health/metrics/audit) integrates runbook execution with audit trail exposes authenticated runbook runs API Made-with: Cursor
This commit is contained in:
176
tests/test_sofiia_runbook_runner.py
Normal file
176
tests/test_sofiia_runbook_runner.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Tests for runbook runner (PR2): create run, next (http_check + manual), complete, abort.
|
||||
Uses tmp docs + rebuilt index; http_check monkeypatched to avoid network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_docs_rehearsal(tmp_path):
|
||||
"""Create tmp_path/docs/runbook/rehearsal-v1-30min-checklist.md."""
|
||||
docs_root = tmp_path / "docs"
|
||||
runbook_dir = docs_root / "runbook"
|
||||
runbook_dir.mkdir(parents=True)
|
||||
(runbook_dir / "rehearsal-v1-30min-checklist.md").write_text(
|
||||
"# Rehearsal v1 — 30-minute execution plan\n\n"
|
||||
"## 0–5 min — Preflight\n\n"
|
||||
"Run STRICT=1 bash ops/preflight_sofiia_console.sh\n\n"
|
||||
"## 5–10 min — Restart\n\n"
|
||||
"Restart sofiia-console.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return docs_root
|
||||
|
||||
|
||||
def test_create_run_returns_run_id_and_steps_total(sofiia_module, tmp_path, tmp_docs_rehearsal, monkeypatch):
|
||||
"""Create run with rehearsal runbook returns run_id, status, steps_total >= 1."""
|
||||
import app.docs_index as docs_index_mod
|
||||
import app.runbook_runner as runner_mod
|
||||
|
||||
monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data"))
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def run():
|
||||
await docs_index_mod.rebuild_index(tmp_docs_rehearsal)
|
||||
return await runner_mod.create_run(
|
||||
"runbook/rehearsal-v1-30min-checklist.md",
|
||||
operator_id="test-op",
|
||||
sofiia_url="http://127.0.0.1:8002",
|
||||
)
|
||||
|
||||
out = loop.run_until_complete(run())
|
||||
assert "run_id" in out
|
||||
assert out["status"] == "running"
|
||||
assert out["current_step"] == 0
|
||||
assert out["steps_total"] >= 1
|
||||
|
||||
|
||||
def test_get_run_404_for_unknown_run_id(sofiia_client):
|
||||
"""GET /api/runbooks/runs/{run_id} returns 404 for unknown run_id."""
|
||||
r = sofiia_client.get("/api/runbooks/runs/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_create_run_400_for_invalid_path(sofiia_client):
|
||||
"""POST /api/runbooks/runs with invalid runbook_path returns 400."""
|
||||
r = sofiia_client.post(
|
||||
"/api/runbooks/runs",
|
||||
json={"runbook_path": "../../../etc/passwd"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_next_step_http_check_then_manual(sofiia_module, sofiia_client, tmp_path, tmp_docs_rehearsal, monkeypatch):
|
||||
"""Create run, call next 3 times (http_check mocked 200), then next returns manual step."""
|
||||
import app.docs_index as docs_index_mod
|
||||
import app.runbook_runner as runner_mod
|
||||
|
||||
monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data"))
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def create():
|
||||
await docs_index_mod.rebuild_index(tmp_docs_rehearsal)
|
||||
return await runner_mod.create_run(
|
||||
"runbook/rehearsal-v1-30min-checklist.md",
|
||||
sofiia_url="http://127.0.0.1:8002",
|
||||
)
|
||||
|
||||
create_out = loop.run_until_complete(create())
|
||||
run_id = create_out["run_id"]
|
||||
|
||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
|
||||
def resp_for_url(url):
|
||||
return type("Res", (), {"status_code": 401 if "audit" in str(url) else 200})()
|
||||
|
||||
async def fake_get(url, **kwargs):
|
||||
return resp_for_url(url)
|
||||
|
||||
mock_get.side_effect = fake_get
|
||||
for _ in range(3):
|
||||
r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data.get("type") in ("http_check", "manual")
|
||||
if data.get("type") == "http_check":
|
||||
assert data.get("result", {}).get("ok") is True
|
||||
|
||||
r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data.get("type") == "manual"
|
||||
assert "instructions_md" in data or "title" in data
|
||||
|
||||
|
||||
def test_complete_step_advances_run(sofiia_module, sofiia_client, tmp_path, tmp_docs_rehearsal, monkeypatch):
|
||||
"""After manual step returned by next, complete_step advances and returns ok."""
|
||||
import app.docs_index as docs_index_mod
|
||||
import app.runbook_runner as runner_mod
|
||||
|
||||
monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data"))
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def create():
|
||||
await docs_index_mod.rebuild_index(tmp_docs_rehearsal)
|
||||
return await runner_mod.create_run(
|
||||
"runbook/rehearsal-v1-30min-checklist.md",
|
||||
sofiia_url="http://127.0.0.1:8002",
|
||||
)
|
||||
|
||||
create_out = loop.run_until_complete(create())
|
||||
run_id = create_out["run_id"]
|
||||
|
||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
|
||||
def resp(url):
|
||||
return type("Res", (), {"status_code": 401 if "audit" in str(url) else 200})()
|
||||
|
||||
async def fake_get(url, **kwargs):
|
||||
return resp(url)
|
||||
|
||||
mock_get.side_effect = fake_get
|
||||
for _ in range(3):
|
||||
sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={})
|
||||
r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={})
|
||||
assert r.status_code == 200
|
||||
manual = r.json()
|
||||
assert manual.get("type") == "manual"
|
||||
step_index = manual["step_index"]
|
||||
|
||||
r2 = sofiia_client.post(
|
||||
f"/api/runbooks/runs/{run_id}/steps/{step_index}/complete",
|
||||
json={"status": "ok", "notes": "done"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json().get("ok") is True
|
||||
|
||||
|
||||
def test_abort_run(sofiia_module, sofiia_client, tmp_path, tmp_docs_rehearsal, monkeypatch):
|
||||
"""Create run, abort returns ok; get_run then shows status aborted."""
|
||||
import app.docs_index as docs_index_mod
|
||||
import app.runbook_runner as runner_mod
|
||||
|
||||
monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data"))
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def create():
|
||||
await docs_index_mod.rebuild_index(tmp_docs_rehearsal)
|
||||
return await runner_mod.create_run(
|
||||
"runbook/rehearsal-v1-30min-checklist.md",
|
||||
sofiia_url="http://127.0.0.1:8002",
|
||||
)
|
||||
|
||||
create_out = loop.run_until_complete(create())
|
||||
run_id = create_out["run_id"]
|
||||
|
||||
r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/abort", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("status") == "aborted"
|
||||
|
||||
run = loop.run_until_complete(runner_mod.get_run(run_id))
|
||||
assert run is not None
|
||||
assert run["status"] == "aborted"
|
||||
Reference in New Issue
Block a user