runbook-tutorials/rtut/run.py

417 lines
16 KiB
Python
Raw Normal View History

"""Run records (runbook-run/v0.1): non-secret progress and evidence for one pack run."""
from __future__ import annotations
import json
import re
import subprocess
import time
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path
from .legacy import repo_root
from .pack import PLACEHOLDER, SECRET_RE, Pack, digest, render
STATES = ("todo", "running", "done", "failed", "skipped")
VERIFY_TIMEOUT = 60
class RunError(Exception):
pass
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="microseconds") # sortable even within one second
def _clean(value: str, what: str) -> str:
if SECRET_RE.search(value):
raise RunError(f"{what} contains a secret-looking marker; not recorded")
return value
def runs_dir(pack: Pack) -> Path:
return repo_root(pack) / ".local" / "runbook-runs"
@dataclass
class Run:
pack: Pack
data: dict
@property
def path(self) -> Path:
return runs_dir(self.pack) / f"{self.data['run_id']}.json"
def save(self) -> None:
self.data["updated"] = _now()
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
def step_def(self, step_id: str) -> dict:
if self.pack.spec == "runbook-pack/v0.2": # steps live in stages; alternatives are applied
from . import run2
return run2.step_def(self, step_id)
for s in self.pack.data.get("steps", []):
if s["id"] == step_id:
return s
raise RunError(f"no such step: {step_id}")
def state(self, step_id: str) -> dict:
return self.data["steps"][step_id]
@property
def complete(self) -> bool:
return all(v["state"] in ("done", "skipped") for v in self.data["steps"].values())
def _writable(run: Run) -> None:
if run.data.get("outcome"):
raise RunError(f"run is finished (outcome: {run.data['outcome']}); start a new run")
ENVIRONMENTS = ("rehearsal", "staging", "production")
def _context(ctx: dict | None) -> dict:
"""Where a run applies: project id, target and environment class. Non-secret; all optional."""
ctx = dict(ctx or {})
if ctx.get("environment") and ctx["environment"] not in ENVIRONMENTS:
raise RunError(f"environment must be one of {', '.join(ENVIRONMENTS)}")
return {k: _clean(str(v), f"context {k}") for k, v in ctx.items() if v and k in ("project", "target", "environment")}
def create(pack: Pack, operator: str, params: dict[str, str] | None = None, context: dict | None = None) -> Run:
if not pack.ok or pack.data.get("engine") != "native":
raise RunError("only valid native packs can be run")
if pack.spec == "runbook-pack/v0.2":
from . import run2
return run2.create(pack, operator, params, context=context)
if pack.spec != "runbook-pack/v0.1":
raise RunError(f"{pack.spec} packs cannot be run")
operator = _clean(operator.strip(), "operator")
if not re.fullmatch(r"\S+", operator):
raise RunError("operator must be one word (name or handle)")
values = dict(params or {})
known = {p["id"] for p in pack.data.get("parameters", [])}
for k in values:
if k not in known:
raise RunError(f"unknown parameter {k}")
resolved: dict[str, str] = {}
for p in pack.data.get("parameters", []):
# render() validates values/pattern/unsafe characters and applies defaults
try:
resolved[p["id"]] = render("{{%s}}" % p["id"], values, pack)
except (KeyError, ValueError) as e:
raise RunError(str(e)) from e
_clean(resolved[p["id"]], f"parameter {p['id']}")
data = {
"spec": "runbook-run/v0.1",
"run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"pack_id": pack.id,
"pack_path": str(pack.path),
"operator": operator,
"created": _now(),
"params": resolved,
"steps": {s["id"]: {"state": "todo"} for s in pack.data["steps"]},
"pack_digest": digest(pack),
"context": _context(context),
}
run = Run(pack, data)
run.save()
return run
def load(pack: Pack, run_id: str) -> Run:
f = runs_dir(pack) / f"{run_id}.json"
if not f.exists():
raise RunError(f"no such run: {run_id}")
return Run(pack, json.loads(f.read_text()))
def list_runs(pack: Pack) -> list[str]:
"""Run ids of this pack. All packs of a repository share one run folder, so filter by pack id."""
d = runs_dir(pack)
if not d.exists():
return []
out = []
for f in sorted(d.glob("*.json")):
try:
if json.loads(f.read_text()).get("pack_id") == pack.id:
out.append(f.stem)
except (OSError, ValueError):
continue
return out
def _order(run: Run, step_id: str) -> None:
for s in run.pack.data["steps"]:
if s["id"] == step_id:
return
if run.state(s["id"])["state"] not in ("done", "skipped"):
raise RunError(f"step {s['id']} must be done or skipped before {step_id}")
def command_for(run: Run, step_id: str) -> str | None:
cmd = run.step_def(step_id).get("command")
return render(cmd, run.data["params"], run.pack) if cmd else None
def start(run: Run, step_id: str) -> None:
_writable(run)
run.step_def(step_id)
_order(run, step_id)
st = run.state(step_id)
if st["state"] in ("done", "skipped"):
raise RunError(f"step {step_id} already {st['state']}")
st.update(state="running", started=_now())
run.save()
def verify(run: Run, step_id: str, confirm: bool = False, manual_ok: bool | None = None) -> dict:
"""Run the step's check. Commands run only when the operator confirms.
Stored: exit code, duration, output size, and whether 'contains' matched.
Output text is never stored.
"""
_writable(run)
v = run.step_def(step_id)["verify"]
st = run.state(step_id)
if st["state"] != "running":
raise RunError(f"step {step_id} is not running")
expect = v.get("expect", "manual")
if expect == "manual":
if manual_ok is None:
raise RunError("manual verification needs the operator's yes/no")
result = {"kind": "manual", "passed": bool(manual_ok)}
else:
if not confirm:
raise RunError("running a verify command needs operator confirmation")
cmd = render(v["command"], run.data["params"], run.pack)
t0 = time.monotonic()
try:
cp = subprocess.run(cmd, shell=True, capture_output=True, text=True,
timeout=VERIFY_TIMEOUT, cwd=repo_root(run.pack))
code, out = cp.returncode, cp.stdout + cp.stderr
except subprocess.TimeoutExpired:
code, out = -1, ""
result = {"kind": expect, "exit_code": code, "output_bytes": len(out.encode()),
"seconds": round(time.monotonic() - t0, 2)}
if expect == "exit-0":
result["passed"] = code == 0
else:
needle = render(v["contains"], run.data["params"], run.pack)
result["passed"] = code == 0 and needle in out
result["at"] = _now()
st["verification"] = result
run.save()
return result
def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None, note: str = "") -> None:
_writable(run)
sd = run.step_def(step_id)
st = run.state(step_id)
if st["state"] != "running":
raise RunError(f"step {step_id} is not running")
if not st.get("verification", {}).get("passed"):
raise RunError(f"step {step_id} has no passing verification")
evidence = evidence or {}
missing = [n for n in sd.get("evidence", []) if n not in evidence]
if missing:
raise RunError(f"evidence required: {', '.join(missing)}")
st["evidence"] = {k: _clean(str(v), f"evidence {k}") for k, v in evidence.items()}
if note.strip():
st["note"] = _clean(note.strip(), "note")
st.update(state="done", finished=_now())
run.save()
def fail(run: Run, step_id: str, note: str) -> None:
_writable(run)
st = run.state(step_id)
if st["state"] != "running":
raise RunError(f"step {step_id} is not running")
st.update(state="failed", note=_clean(note, "note"), finished=_now())
run.save()
def retry(run: Run, step_id: str) -> None:
_writable(run)
st = run.state(step_id)
if st["state"] != "failed":
raise RunError(f"step {step_id} has not failed")
st.pop("verification", None)
st.update(state="running", started=_now())
run.save()
def skip(run: Run, step_id: str, reason: str) -> None:
_writable(run)
sd = run.step_def(step_id)
if sd.get("risk", "read-only") != "read-only":
raise RunError("only read-only steps may be skipped")
_order(run, step_id)
run.state(step_id).update(state="skipped", note=_clean(reason, "reason"), finished=_now())
run.save()
STATUS_LINE = re.compile(r"^exercise_status:.*$", re.M)
def exercise_update(run: Run, today: date | None = None) -> tuple[str, str]:
"""Return (old, new) pack.yaml text with exercised status. Does not write.
A run counts only if every step is done (skips do not exercise the pack).
"""
if any(v["state"] != "done" for v in run.data["steps"].values()):
raise RunError("every step must be done (not skipped) to exercise the pack")
if run.data.get("deviations"):
raise RunError("a run with deviations (overrides, waivers, push-through) cannot exercise the pack")
today = today or date.today()
old = run.pack.path.read_text()
new = STATUS_LINE.sub(f"exercise_status: exercised {today.isoformat()} by {run.data['operator']}",
old, count=1)
return old, new
RATINGS = {"good": ":)", "neutral": ":|", "poor": ":("}
def add_feedback(run: Run, rating: str, comment: str = "") -> dict:
"""Record how the runbook felt. Allowed at any time; the latest entry is the current one."""
if rating not in RATINGS:
raise RunError(f"rating must be one of {', '.join(RATINGS)}")
entry = {"rating": rating, "comment": _clean(comment.strip(), "comment"),
"at": datetime.now(timezone.utc).isoformat(timespec="microseconds")} # sortable even within one second
run.data.setdefault("feedback", []).append(entry)
run.save()
return entry
def latest_feedback(run: Run) -> dict | None:
fb = run.data.get("feedback") or []
return fb[-1] if fb else None
def all_feedback(pack: Pack) -> list[dict]:
"""Latest feedback of every run of this pack, newest first."""
out = []
for rid in list_runs(pack):
r = load(pack, rid)
fb = latest_feedback(r)
if fb:
out.append({"run_id": rid, "operator": r.data["operator"], **fb})
return sorted(out, key=lambda x: x["at"], reverse=True)
# ---- captures: optional documentation of what was run and what came back --------------------
CAPTURE_MAX_OUTPUT = 20000
CAPTURE_MAX_COMMAND = 2000
def add_capture(run: Run, step_id: str, output: str, command: str = "", source: str = "operator") -> dict:
"""Keep the command actually run and its output as documentation or for inspection.
The engine cannot detect every secret. It refuses obvious markers and oversized text;
the operator remains responsible for what is pasted.
"""
_writable(run)
run.step_def(step_id)
st = run.state(step_id)
if st["state"] == "todo":
raise RunError("start the step before capturing its output")
if len(output) > CAPTURE_MAX_OUTPUT or len(command) > CAPTURE_MAX_COMMAND:
raise RunError(f"capture too large (output max {CAPTURE_MAX_OUTPUT}, command max {CAPTURE_MAX_COMMAND} characters)")
if not output.strip() and not command.strip():
raise RunError("nothing to capture")
entry = {"command": _clean(command.strip(), "captured command"), "output": _clean(output.rstrip(), "captured output"),
"source": source, "at": _now()}
st.setdefault("captures", []).append(entry)
run.save()
return entry
def verify_pasted(run: Run, step_id: str, output: str, command: str = "") -> dict:
"""Check an output-contains step against pasted output, for systems the engine cannot reach."""
_writable(run)
v = run.step_def(step_id)["verify"]
st = run.state(step_id)
if st["state"] != "running":
raise RunError(f"step {step_id} is not running")
if v.get("expect") != "output-contains":
raise RunError("only output-contains checks can be verified from pasted output")
add_capture(run, step_id, output, command, source="pasted-for-verification")
needle = render(v["contains"], run.data["params"], run.pack)
result = {"kind": "output-contains", "source": "pasted", "passed": needle in output,
"output_bytes": len(output.encode()), "at": _now()}
st["verification"] = result
run.save()
return result
# ---- outcome and score ------------------------------------------------------------------------
OUTCOMES = ("succeeded", "failed", "abandoned")
STALE_DAYS = 7
def finalize(run: Run, outcome: str, note: str = "") -> None:
"""Close a run. 'succeeded' needs every step done; 'failed' needs a failed step."""
if run.data.get("outcome"):
raise RunError(f"run already finished ({run.data['outcome']})")
if outcome not in OUTCOMES:
raise RunError(f"outcome must be one of {', '.join(OUTCOMES)}")
states = [v["state"] for v in run.data["steps"].values()]
if outcome == "succeeded" and any(x != "done" for x in states):
raise RunError("a run succeeds only when every step is done (skipped steps do not count)")
if outcome == "succeeded" and any(g["state"] not in ("passed", "waived") for g in run.data.get("gates", {}).values()):
raise RunError("a run succeeds only when every gate has passed or been waived")
gstates = [g["state"] for g in run.data.get("gates", {}).values()]
if outcome == "failed" and not ({"failed", "pushed"} & set(states) or {"failed", "pushed"} & set(gstates)):
raise RunError("a run can be finished as failed only when a step or gate has failed or was pushed through; otherwise abandon it")
run.data["outcome"] = outcome
run.data["finished"] = _now()
if note.strip():
run.data["outcome_note"] = _clean(note.strip(), "note")
run.save()
def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = STALE_DAYS) -> str:
"""finished outcome, or 'stalled' (unfinished), or 'abandoned' for an unfinished run untouched for stale_days."""
if run.data.get("outcome"):
return run.data["outcome"]
now = now or datetime.now(timezone.utc)
last = datetime.fromisoformat(run.data.get("updated") or run.data["created"])
return "abandoned" if (now - last).days >= stale_days else "stalled"
def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict:
"""How often a runbook was tried and how it went, derived from its run records."""
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "stalled": 0,
"feedback": {"good": 0, "neutral": 0, "poor": 0}}
for rid in list_runs(pack):
r = load(pack, rid)
card["tried"] += 1
card[effective_outcome(r, now, stale_days)] += 1
fb = latest_feedback(r)
if fb:
card["feedback"][fb["rating"]] += 1
return card
def apply_exercise(run: Run, today: date | None = None) -> str:
"""Write 'exercised <date> by <operator>' into pack.yaml for a succeeded run. Returns the new status."""
if run.data.get("outcome") != "succeeded":
raise RunError("only a run finished as succeeded can exercise the pack")
_, new = exercise_update(run, today)
run.pack.path.write_text(new)
status = STATUS_LINE.search(new).group(0).split(": ", 1)[1]
run.pack.data["exercise_status"] = status
run.data["exercise_recorded"] = status
run.save()
return status