- adds safe_executor.py: REPO_ROOT confinement, strict script allowlist, env key allowlist (STRICT/SOFIIA_URL/BFF_A/BFF_B/NODE_ID/AGENT_ID), stdin=DEVNULL, 8KB output cap, timeout clamp (max 300s), non-root warn - integrates script action_type into runbook_runner: next_step handles http_check and script branches; running_as_root -> step_status=warn - extends runbook_parser: rehearsal-v1 now includes 3 built-in script steps (preflight, idempotency smoke, generate evidence) after http_checks - adds tests/test_sofiia_safe_executor.py: 12 tests covering path traversal, absolute path, non-allowlist, env drop, timeout, exit_code, mocked subprocess Made-with: Cursor
170 lines
5.3 KiB
Python
170 lines
5.3 KiB
Python
"""
|
||
Runbook parser — markdown → list of steps (manual / http_check).
|
||
PR2: sections by H2 → manual steps; for rehearsal-v1 prepend 3 http_check steps.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, List
|
||
|
||
|
||
@dataclass
|
||
class RunbookStep:
|
||
step_index: int
|
||
title: str
|
||
section: str | None
|
||
action_type: str # manual | http_check
|
||
action_json: Dict[str, Any]
|
||
status: str = "pending"
|
||
|
||
|
||
def _rehearsal_http_check_steps(base_url: str) -> List[RunbookStep]:
|
||
"""Three built-in http_check steps for rehearsal v1."""
|
||
base = (base_url or "http://127.0.0.1:8002").rstrip("/")
|
||
return [
|
||
RunbookStep(
|
||
step_index=0,
|
||
title="Health check",
|
||
section="0–5 min — Preflight",
|
||
action_type="http_check",
|
||
action_json={
|
||
"method": "GET",
|
||
"url_path": "/api/health",
|
||
"expect": {"status": [200]},
|
||
"headers": {},
|
||
},
|
||
),
|
||
RunbookStep(
|
||
step_index=1,
|
||
title="Metrics check",
|
||
section="0–5 min — Preflight",
|
||
action_type="http_check",
|
||
action_json={
|
||
"method": "GET",
|
||
"url_path": "/metrics",
|
||
"expect": {"status": [200]},
|
||
"headers": {},
|
||
},
|
||
),
|
||
RunbookStep(
|
||
step_index=2,
|
||
title="Audit auth (expect 401 without key)",
|
||
section="10–15 min — Smoke",
|
||
action_type="http_check",
|
||
action_json={
|
||
"method": "GET",
|
||
"url_path": "/api/audit",
|
||
"expect": {"status": [401]},
|
||
"headers": {},
|
||
},
|
||
),
|
||
]
|
||
|
||
|
||
def _parse_sections(markdown: str) -> List[tuple]:
|
||
"""Split by ## or ###, return [(section_title, content), ...]."""
|
||
sections: List[tuple] = []
|
||
current_title = ""
|
||
current_lines: List[str] = []
|
||
for line in markdown.splitlines():
|
||
if re.match(r"^##\s+", line):
|
||
if current_lines:
|
||
sections.append((current_title, "\n".join(current_lines).strip()))
|
||
current_title = line.lstrip("#").strip()[:300]
|
||
current_lines = []
|
||
elif re.match(r"^###\s+", line):
|
||
if current_lines:
|
||
sections.append((current_title, "\n".join(current_lines).strip()))
|
||
current_title = line.lstrip("#").strip()[:300]
|
||
current_lines = []
|
||
else:
|
||
current_lines.append(line)
|
||
if current_lines:
|
||
sections.append((current_title, "\n".join(current_lines).strip()))
|
||
return sections
|
||
|
||
|
||
def _rehearsal_script_steps(offset: int) -> List[RunbookStep]:
|
||
"""PR3: 3 allowlisted script steps for rehearsal v1 (after http_checks)."""
|
||
return [
|
||
RunbookStep(
|
||
step_index=offset,
|
||
title="Preflight check (STRICT=1)",
|
||
section="0–5 min — Preflight",
|
||
action_type="script",
|
||
action_json={
|
||
"script": "ops/preflight_sofiia_console.sh",
|
||
"env": {"STRICT": "1"},
|
||
"timeout_s": 120,
|
||
},
|
||
),
|
||
RunbookStep(
|
||
step_index=offset + 1,
|
||
title="Redis idempotency smoke test",
|
||
section="10–15 min — Smoke",
|
||
action_type="script",
|
||
action_json={
|
||
"script": "ops/redis_idempotency_smoke.sh",
|
||
"env": {},
|
||
"timeout_s": 60,
|
||
},
|
||
),
|
||
RunbookStep(
|
||
step_index=offset + 2,
|
||
title="Generate release evidence",
|
||
section="25–30 min — Evidence",
|
||
action_type="script",
|
||
action_json={
|
||
"script": "ops/generate_release_evidence.sh",
|
||
"env": {},
|
||
"timeout_s": 60,
|
||
},
|
||
),
|
||
]
|
||
|
||
|
||
def parse_runbook(
|
||
runbook_path: str,
|
||
markdown: str,
|
||
sofiia_url: str = "http://127.0.0.1:8002",
|
||
) -> List[RunbookStep]:
|
||
"""
|
||
Parse markdown into steps.
|
||
For rehearsal-v1: prepend 3 http_check + 3 script steps; rest are manual.
|
||
"""
|
||
path_lower = runbook_path.lower()
|
||
steps: List[RunbookStep] = []
|
||
offset = 0
|
||
|
||
if "rehearsal" in path_lower and "30min" in path_lower:
|
||
http_steps = _rehearsal_http_check_steps(sofiia_url)
|
||
steps.extend(http_steps)
|
||
offset = len(http_steps)
|
||
script_steps = _rehearsal_script_steps(offset)
|
||
steps.extend(script_steps)
|
||
offset += len(script_steps)
|
||
|
||
sections = _parse_sections(markdown)
|
||
for i, (title, content) in enumerate(sections):
|
||
if not title and not content.strip():
|
||
continue
|
||
title_clean = title or f"Step {i + 1}"
|
||
# First section might be title-only; still add as manual
|
||
instructions = content.strip() or title_clean
|
||
if len(instructions) > 8000:
|
||
instructions = instructions[:8000] + "\n..."
|
||
steps.append(
|
||
RunbookStep(
|
||
step_index=offset + i,
|
||
title=title_clean[:200],
|
||
section=title_clean[:200] if title else None,
|
||
action_type="manual",
|
||
action_json={"instructions_md": instructions, "accept_input": True},
|
||
status="pending",
|
||
)
|
||
)
|
||
|
||
return steps
|