Add runbook pack spec, validator, legacy launcher, and workplans RBT-WP-0002..0006
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
This commit is contained in:
parent
13512e68c8
commit
535836054a
19 changed files with 960 additions and 17 deletions
2
rtut/__init__.py
Normal file
2
rtut/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Runbook-tutorial engine."""
|
||||
__version__ = "0.1.0"
|
||||
82
rtut/__main__.py
Normal file
82
rtut/__main__.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""CLI: python -m rtut {list,validate,show,render} ..."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import pack as P
|
||||
|
||||
|
||||
def _packs(root: str) -> list[P.Pack]:
|
||||
return [P.load(p) for p in P.discover(Path(root))]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="rtut")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
for name in ("list", "validate"):
|
||||
sp = sub.add_parser(name)
|
||||
sp.add_argument("root", nargs="?", default=".")
|
||||
sp = sub.add_parser("launch"); sp.add_argument("manifest")
|
||||
sp.add_argument("--timeout", type=float, default=30.0)
|
||||
sp = sub.add_parser("show"); sp.add_argument("manifest")
|
||||
sp = sub.add_parser("render"); sp.add_argument("manifest"); sp.add_argument("step")
|
||||
sp.add_argument("--param", "-p", action="append", default=[], help="name=value")
|
||||
a = ap.parse_args(argv)
|
||||
|
||||
if a.cmd in ("list", "validate"):
|
||||
packs = _packs(a.root)
|
||||
if not packs:
|
||||
print("no packs found", file=sys.stderr)
|
||||
return 1
|
||||
bad = 0
|
||||
for p in packs:
|
||||
d = p.data
|
||||
if a.cmd == "list":
|
||||
print(f"{p.id}\t{d.get('engine')}\t{d.get('exercise_status')}\t{d.get('title')}")
|
||||
else:
|
||||
print(f"{'ok ' if p.ok else 'FAIL'} {p.id}")
|
||||
for e in p.errors:
|
||||
print(f" - {e}")
|
||||
bad += not p.ok
|
||||
return 1 if bad and a.cmd == "validate" else 0
|
||||
pk = P.load(Path(a.manifest))
|
||||
if not pk.ok:
|
||||
print("\n".join(pk.errors), file=sys.stderr)
|
||||
return 1
|
||||
if a.cmd == "launch":
|
||||
from . import legacy
|
||||
try:
|
||||
proc = legacy.launch(pk, a.timeout)
|
||||
except (ValueError, TimeoutError) as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"running: {pk.data['legacy'].get('url', '(no url)')} (Ctrl-C stops it)")
|
||||
try:
|
||||
return proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
proc.terminate()
|
||||
return 0
|
||||
if a.cmd == "show":
|
||||
d = pk.data
|
||||
print(f"{d['title']} [{d['exercise_status']}] engine={d['engine']}")
|
||||
for i, s in enumerate(d.get("steps", []), 1):
|
||||
print(f"{i}. [{s['owner']}] {s['title']} ({s.get('risk', 'read-only')})")
|
||||
print(f" done when: {s['verify']['done_when']}")
|
||||
return 0
|
||||
values = dict(kv.split("=", 1) for kv in a.param)
|
||||
step = next((s for s in pk.data.get("steps", []) if s["id"] == a.step), None)
|
||||
if not step or "command" not in step:
|
||||
print("no such step or step has no command", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
print(P.render(step["command"], values, pk))
|
||||
except (KeyError, ValueError) as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
41
rtut/legacy.py
Normal file
41
rtut/legacy.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Launch a legacy-command pack: start the existing console and wait for it."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from .pack import Pack
|
||||
|
||||
|
||||
def repo_root(pack: Pack) -> Path:
|
||||
"""runbooks/<dir>/pack.yaml -> repository root."""
|
||||
return pack.path.resolve().parents[2]
|
||||
|
||||
|
||||
def wait_ready(url: str, timeout: float = 30.0) -> bool:
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2):
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def launch(pack: Pack, timeout: float = 30.0) -> subprocess.Popen:
|
||||
"""Start the console in the owning repo. Caller owns the returned process."""
|
||||
if pack.data.get("engine") != "legacy-command":
|
||||
raise ValueError("not a legacy-command pack")
|
||||
spec = pack.data["legacy"]
|
||||
cwd = (repo_root(pack) / spec.get("cwd", ".")).resolve()
|
||||
if repo_root(pack) not in [cwd, *cwd.parents]:
|
||||
raise ValueError("legacy.cwd escapes the owning repository")
|
||||
proc = subprocess.Popen(spec["command"], cwd=cwd)
|
||||
url = spec.get("url")
|
||||
if url and not wait_ready(url, timeout):
|
||||
proc.terminate()
|
||||
raise TimeoutError(f"{url} did not answer within {timeout}s")
|
||||
return proc
|
||||
127
rtut/pack.py
Normal file
127
rtut/pack.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""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.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 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"])
|
||||
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", [])}
|
||||
|
||||
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")
|
||||
if re.search(r"[;&|`$<>\n\\'\"\s]", val) and p.get("type") != "path":
|
||||
raise ValueError(f"parameter {name}: unsafe characters")
|
||||
return val
|
||||
|
||||
return PLACEHOLDER.sub(sub, command)
|
||||
Loading…
Add table
Add a link
Reference in a new issue