Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
"""Discover, load and validate runbook packs (spec runbook-pack/v0.1)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from jsonschema import Draft202012Validator
|
|
|
|
SCHEMA_PATH = Path(__file__).resolve().parent / "schema" / "runbook-pack.schema.json"
|
|
PLACEHOLDER = re.compile(r"\{\{\s*([a-z][a-z0-9_]*)\s*\}\}")
|
|
SECRET_RE = re.compile(
|
|
r"(hvs\.[A-Za-z0-9]{8,}|s\.[A-Za-z0-9]{24}|-----BEGIN [A-Z ]*PRIVATE KEY|otpauth://)")
|
|
RISKY = {"changes-state", "attended", "destructive"}
|
|
|
|
|
|
@dataclass
|
|
class Pack:
|
|
path: Path
|
|
data: dict
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
return str(self.data.get("id", self.path.parent.name))
|
|
|
|
@property
|
|
def spec(self) -> str:
|
|
return str(self.data.get("spec", ""))
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return not self.errors
|
|
|
|
|
|
def discover(root: Path) -> list[Path]:
|
|
"""Find runbooks/*/pack.yaml under root (root itself may be a runbooks dir)."""
|
|
root = Path(root)
|
|
found = sorted(root.glob("runbooks/*/pack.yaml")) + sorted(root.glob("*/pack.yaml"))
|
|
return list(dict.fromkeys(found))
|
|
|
|
|
|
def _schema_errors(data: dict) -> list[str]:
|
|
schema = json.loads(SCHEMA_PATH.read_text())
|
|
v = Draft202012Validator(schema)
|
|
return [f"{'/'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
|
|
for e in sorted(v.iter_errors(data), key=lambda e: list(map(str, e.absolute_path)))]
|
|
|
|
|
|
def _semantic_errors(data: dict) -> list[str]:
|
|
errs: list[str] = []
|
|
params = {p["id"]: p for p in data.get("parameters", []) if isinstance(p, dict) and "id" in p}
|
|
seen = [p["id"] for p in data.get("parameters", []) if isinstance(p, dict) and "id" in p]
|
|
if len(seen) != len(set(seen)):
|
|
errs.append("duplicate parameter id")
|
|
for pid, p in params.items():
|
|
if p.get("type") == "enum" and not p.get("values"):
|
|
errs.append(f"parameter {pid}: enum without values")
|
|
d = p.get("default")
|
|
if d is not None:
|
|
if p.get("values") and d not in p["values"]:
|
|
errs.append(f"parameter {pid}: default not in values")
|
|
if p.get("pattern") and isinstance(d, str) and not re.fullmatch(p["pattern"], d):
|
|
errs.append(f"parameter {pid}: default violates pattern")
|
|
ids = [s["id"] for s in data.get("steps", []) if isinstance(s, dict) and "id" in s]
|
|
if len(ids) != len(set(ids)):
|
|
errs.append("duplicate step id")
|
|
used: set[str] = set()
|
|
for s in data.get("steps", []):
|
|
if not isinstance(s, dict):
|
|
continue
|
|
texts = [s.get("command", ""), s.get("description", ""),
|
|
(s.get("verify") or {}).get("command", "")]
|
|
for t in texts:
|
|
for name in PLACEHOLDER.findall(t or ""):
|
|
used.add(name)
|
|
if name not in params:
|
|
errs.append(f"step {s.get('id')}: undefined placeholder {{{{{name}}}}}")
|
|
if s.get("risk") in RISKY and not s.get("rollback"):
|
|
errs.append(f"step {s.get('id')}: risk {s['risk']} requires rollback")
|
|
v = s.get("verify") or {}
|
|
if v.get("expect") in ("exit-0", "output-contains") and not v.get("command"):
|
|
errs.append(f"step {s.get('id')}: verify expect {v['expect']} needs a command")
|
|
if v.get("expect") == "output-contains" and not v.get("contains"):
|
|
errs.append(f"step {s.get('id')}: verify output-contains needs 'contains'")
|
|
for name in params:
|
|
if name not in used and data.get("engine") == "native":
|
|
errs.append(f"parameter {name}: declared but never used")
|
|
if SECRET_RE.search(json.dumps(data)):
|
|
errs.append("contains secret-looking marker")
|
|
return errs
|
|
|
|
|
|
def load(path: Path) -> Pack:
|
|
path = Path(path)
|
|
try:
|
|
data = yaml.safe_load(path.read_text())
|
|
except yaml.YAMLError as e:
|
|
return Pack(path, {}, [f"invalid YAML: {e}"])
|
|
if not isinstance(data, dict):
|
|
return Pack(path, {}, ["manifest must be a mapping"])
|
|
if data.get("spec") == "runbook-pack/v0.2":
|
|
from . import v2
|
|
return Pack(path, data, v2.validate(data))
|
|
errs = _schema_errors(data)
|
|
if not errs:
|
|
errs = _semantic_errors(data)
|
|
return Pack(path, data, errs)
|
|
|
|
|
|
def render(command: str, values: dict[str, str], pack: Pack) -> str:
|
|
"""Substitute parameters into a command template. Unknown or unset -> error."""
|
|
params = {p["id"]: p for p in (pack.data.get("parameters") or pack.data.get("datapoints") or [])}
|
|
|
|
def sub(m: re.Match) -> str:
|
|
name = m.group(1)
|
|
if name not in params:
|
|
raise KeyError(f"undefined parameter {name}")
|
|
val = values.get(name, params[name].get("default"))
|
|
if val is None:
|
|
raise KeyError(f"parameter {name} has no value")
|
|
val = str(val)
|
|
p = params[name]
|
|
if p.get("values") and val not in p["values"]:
|
|
raise ValueError(f"parameter {name}: {val!r} not in {p['values']}")
|
|
if p.get("pattern") and not re.fullmatch(p["pattern"], val):
|
|
raise ValueError(f"parameter {name}: {val!r} violates pattern")
|
|
# "text" is single-line free text meant to sit inside double quotes:
|
|
# spaces are allowed, shell metacharacters are not.
|
|
bad = r"[;&|`$<>\n\\'\"]" if p.get("type") in ("text", "path") else r"[;&|`$<>\n\\'\"\s]"
|
|
if re.search(bad, val):
|
|
raise ValueError(f"parameter {name}: unsafe characters")
|
|
return val
|
|
|
|
return PLACEHOLDER.sub(sub, command)
|
|
|
|
|
|
REVIEW_FILE = "REVIEWS.yaml"
|
|
|
|
|
|
def digest(pack: "Pack") -> str:
|
|
"""Revision of a pack: hash of every file in its directory, except review records and local state.
|
|
|
|
`exercise_status:` lines (a deprecated field) are ignored, so dropping it from a pack is not a new revision.
|
|
"""
|
|
import hashlib
|
|
h = hashlib.sha256()
|
|
root = pack.path.resolve().parent
|
|
for f in sorted(root.rglob("*")):
|
|
rel = f.relative_to(root)
|
|
if not f.is_file() or f.name == REVIEW_FILE or ".local" in rel.parts or "__pycache__" in rel.parts:
|
|
continue
|
|
data = f.read_bytes()
|
|
if f.name == "pack.yaml" or f.name.startswith("pack."):
|
|
data = b"\n".join(x for x in data.split(b"\n") if not x.startswith(b"exercise_status:"))
|
|
h.update(str(rel).encode() + b"\0" + data + b"\0")
|
|
return h.hexdigest()[:16]
|