From 535836054a4d1b1254df69880ae7b80d2b2f2c15 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 00:24:05 +0200 Subject: [PATCH] Add runbook pack spec, validator, legacy launcher, and workplans RBT-WP-0002..0006 Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477 --- README.md | 20 ++- docs/pack-spec.md | 74 ++++++++++ registry/README.md | 12 -- .../capability.infotech.runbook-tutorials.md | 17 +++ registry/indexes/capabilities.yaml | 13 ++ rtut/__init__.py | 2 + rtut/__main__.py | 82 +++++++++++ rtut/legacy.py | 41 ++++++ rtut/pack.py | 127 ++++++++++++++++++ runbooks/example-hello/pack.yaml | 25 ++++ schema/runbook-pack.schema.json | 98 ++++++++++++++ tests/test_legacy.py | 46 +++++++ tests/test_pack.py | 63 +++++++++ workplans/RBT-WP-0001-statehub-bootstrap.md | 8 +- ...WP-0002-runbook-pack-spec-and-validator.md | 74 ++++++++++ ...0003-engine-core-run-state-and-evidence.md | 74 ++++++++++ workplans/RBT-WP-0004-local-runbook-ui.md | 72 ++++++++++ ...05-legacy-adapter-and-console-migration.md | 87 ++++++++++++ ...T-WP-0006-execution-mode-and-pack-index.md | 42 ++++++ 19 files changed, 960 insertions(+), 17 deletions(-) create mode 100644 docs/pack-spec.md delete mode 100644 registry/README.md create mode 100644 registry/capabilities/capability.infotech.runbook-tutorials.md create mode 100644 registry/indexes/capabilities.yaml create mode 100644 rtut/__init__.py create mode 100644 rtut/__main__.py create mode 100644 rtut/legacy.py create mode 100644 rtut/pack.py create mode 100644 runbooks/example-hello/pack.yaml create mode 100644 schema/runbook-pack.schema.json create mode 100644 tests/test_legacy.py create mode 100644 tests/test_pack.py create mode 100644 workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md create mode 100644 workplans/RBT-WP-0003-engine-core-run-state-and-evidence.md create mode 100644 workplans/RBT-WP-0004-local-runbook-ui.md create mode 100644 workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md create mode 100644 workplans/RBT-WP-0006-execution-mode-and-pack-index.md diff --git a/README.md b/README.md index b77d54e..49f5f67 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # runbook-tutorials -A userinterface specialized to present runbook tutorials that with parameters can guide through actual it administration and operation tasks. \ No newline at end of file +A user interface specialized to present runbook tutorials that with parameters +can guide through actual IT administration and operation tasks. + +- **Intent:** `INTENT.md` · **Scope:** `SCOPE.md` +- **Pack specification:** `docs/pack-spec.md`, schema in `schema/` +- **Packs live where the procedure lives:** `runbooks//pack.yaml` in the owning repo. + Examples: `net-kingdom/runbooks/`, and `runbooks/example-hello/` here. + +```bash +python3 -m rtut validate # validate every pack +python3 -m rtut list # id, engine, exercise status, title +python3 -m rtut show # steps with owners and done-when +python3 -m rtut render -p name=value +python3 -m rtut launch # legacy-command packs: start the existing console +make test +``` + +Status: the specification, validator and legacy launch work; run state, evidence +and the UI are planned (`workplans/RBT-WP-0003` and `RBT-WP-0004`). diff --git a/docs/pack-spec.md b/docs/pack-spec.md new file mode 100644 index 0000000..abb4938 --- /dev/null +++ b/docs/pack-spec.md @@ -0,0 +1,74 @@ +# Runbook pack specification v0.1 + +A **runbook pack** is a directory `runbooks//` in the repository that +owns the procedure. It contains `pack.yaml`, validated by +`schema/runbook-pack.schema.json`, plus optional supporting files. The engine +discovers packs by scanning `runbooks/*/pack.yaml` under a repository root, or +under any root given on the command line. + +## Manifest + +| Field | Required | Meaning | +| --- | --- | --- | +| `spec` | yes | Literal `runbook-pack/v0.1` | +| `id` | yes | Stable dotted id, lower-case, e.g. `nk.ssh-certificates` | +| `title`, `outcome` | title yes | What the operator is doing; what is true afterwards | +| `owner` | yes | The repository that owns the procedure | +| `exercise_status` | yes | `unexercised` or `exercised YYYY-MM-DD by ` | +| `engine` | yes | `native` or `legacy-command` | +| `parameters` | no | Operator-supplied, non-secret values | +| `prerequisites` | no | `{text, owner}` list | +| `steps` | native | Ordered steps (below) | +| `threat_checks` | native | What must never be logged, committed or pasted | +| `ownership` | no | Concern-to-owner table | +| `legacy` | legacy | How to launch the existing console | + +## Parameters + +Each parameter has `id` (`[a-z][a-z0-9_]*`), `label`, `type` +(`string|enum|path|bool|int`), optional `values`, `default`, `pattern`, `help`. +**`secret` may only be `false`**: a parameter is never a secret. Steps refer to +parameters as `{{id}}`. An undefined placeholder, an unused parameter, an +`enum` without `values`, or a default that violates its `pattern`/`values` is a +validation error. + +## Steps + +Every step has `id`, `title`, `owner` (the repo or tool that does the work), +and `verify`. Optional: `description`, `command` (a template shown to the +operator, copyable, never run by the engine unless a future execution mode is +enabled for that step), `risk` (`read-only|changes-state|attended|destructive`), +`rollback`, `evidence`. + +`verify` is mandatory, so a runbook cannot be prose only: + +- `done_when`: the observable outcome, in words; +- `command` and `expect` (`manual|exit-0|output-contains`, with `contains`). + +A step with `risk` of `changes-state`, `attended` or `destructive` must state a +`rollback` (which may say "not reversible" and why). + +## Exercise status + +Following the attended-procedure standard, a pack is `unexercised` until an +operator has completed it, and the operator is recorded. The engine displays the +status prominently and only the engine's evidence writer changes it. + +## Secrets + +The engine must not collect, store, log or transmit secret values. The +validator rejects manifests containing secret-looking markers (`hvs.…`, +private-key blocks, `otpauth://`). + +## Legacy packs + +`engine: legacy-command` wraps an existing guided console unchanged. `legacy.command` +is the argv to launch, `legacy.cwd` is relative to the pack root's repository, +`legacy.url` is where the console serves. The engine lists, describes and +launches it; it does not interpret its internals. Migration to `native` is +optional and per pack. + +## Versioning + +`spec` names the version. A change that invalidates existing packs raises the +version; the engine keeps loading older versions it declares support for. diff --git a/registry/README.md b/registry/README.md deleted file mode 100644 index 569abe9..0000000 --- a/registry/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Capability Registry - -Markdown-first capability index for federation and reuse planning. - -## Authoring - -1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`). -2. Add the row to `indexes/capabilities.yaml`. -3. Run `reuse-surface validate` from a checkout with the CLI installed. -4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`. - -Federation contract: reuse-surface `docs/RegistryFederation.md`. diff --git a/registry/capabilities/capability.infotech.runbook-tutorials.md b/registry/capabilities/capability.infotech.runbook-tutorials.md new file mode 100644 index 0000000..b86b97e --- /dev/null +++ b/registry/capabilities/capability.infotech.runbook-tutorials.md @@ -0,0 +1,17 @@ +--- +id: capability.infotech.runbook-tutorials +name: Runbook-tutorial engine +summary: Pack specification, validator and launcher for guided, parameterized operator runbooks; packs live in the repos that own the procedure. +owner: runbook-tutorials +status: draft +domain: infotech +tags: + - runbook + - tutorial + - operator + - guided +--- + +Draft. Specification `runbook-pack/v0.1`, validator and legacy launcher exist; run +state, evidence and UI are planned (RBT-WP-0003, RBT-WP-0004). Maturity vector to be +assessed after the spec is frozen (RBT-WP-0002-T04). diff --git a/registry/indexes/capabilities.yaml b/registry/indexes/capabilities.yaml new file mode 100644 index 0000000..917d6a4 --- /dev/null +++ b/registry/indexes/capabilities.yaml @@ -0,0 +1,13 @@ +version: 1 +updated: '2026-09-29' +domain: infotech +capabilities: + - id: capability.infotech.runbook-tutorials + name: Runbook-tutorial engine + summary: Pack specification, validator and launcher for guided, parameterized operator runbooks. + domain: infotech + status: draft + owner: runbook-tutorials + path: registry/capabilities/capability.infotech.runbook-tutorials.md + tags: [runbook, tutorial, operator, guided] + consumption_modes: [git clone, cli] diff --git a/rtut/__init__.py b/rtut/__init__.py new file mode 100644 index 0000000..49f094e --- /dev/null +++ b/rtut/__init__.py @@ -0,0 +1,2 @@ +"""Runbook-tutorial engine.""" +__version__ = "0.1.0" diff --git a/rtut/__main__.py b/rtut/__main__.py new file mode 100644 index 0000000..40c8adb --- /dev/null +++ b/rtut/__main__.py @@ -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()) diff --git a/rtut/legacy.py b/rtut/legacy.py new file mode 100644 index 0000000..c7f6790 --- /dev/null +++ b/rtut/legacy.py @@ -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//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 diff --git a/rtut/pack.py b/rtut/pack.py new file mode 100644 index 0000000..e382571 --- /dev/null +++ b/rtut/pack.py @@ -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 ''}: {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) diff --git a/runbooks/example-hello/pack.yaml b/runbooks/example-hello/pack.yaml new file mode 100644 index 0000000..0262814 --- /dev/null +++ b/runbooks/example-hello/pack.yaml @@ -0,0 +1,25 @@ +spec: runbook-pack/v0.1 +id: rtut.example-hello +title: Example - check a host answers +owner: runbook-tutorials +outcome: You have proven a host answers on the network. +exercise_status: unexercised +engine: native +parameters: + - {id: host, label: Host name, type: string, default: localhost, pattern: "[a-zA-Z0-9.-]+"} + - {id: count, label: Ping count, type: int, default: 1} +prerequisites: + - {text: A shell with ping installed, owner: workstation} +steps: + - id: ping + title: Ping the host + owner: workstation + command: ping -c {{count}} {{host}} + verify: + done_when: ping reports 0% packet loss + command: ping -c {{count}} {{host}} + expect: exit-0 +threat_checks: + - Do not paste credentials into any parameter; parameters are never secret. +ownership: + - {concern: The host itself, owner: whoever runs it} diff --git a/schema/runbook-pack.schema.json b/schema/runbook-pack.schema.json new file mode 100644 index 0000000..c8c4e85 --- /dev/null +++ b/schema/runbook-pack.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://runbook-tutorials.invalid/schema/runbook-pack/v0.1", + "title": "Runbook pack manifest", + "type": "object", + "required": ["spec", "id", "title", "owner", "exercise_status", "engine"], + "additionalProperties": false, + "properties": { + "spec": {"const": "runbook-pack/v0.1"}, + "id": {"type": "string", "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$"}, + "title": {"type": "string", "minLength": 3}, + "owner": {"type": "string", "minLength": 1, "description": "Repository that owns the procedure"}, + "outcome": {"type": "string"}, + "exercise_status": { + "type": "string", + "pattern": "^(unexercised|exercised [0-9]{4}-[0-9]{2}-[0-9]{2} by \\S+)$" + }, + "engine": {"enum": ["native", "legacy-command"]}, + "legacy": { + "type": "object", + "required": ["command"], + "additionalProperties": false, + "properties": { + "command": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "cwd": {"type": "string"}, + "url": {"type": "string"}, + "note": {"type": "string"} + } + }, + "parameters": {"type": "array", "items": {"$ref": "#/$defs/parameter"}}, + "prerequisites": {"type": "array", "items": {"$ref": "#/$defs/prerequisite"}}, + "steps": {"type": "array", "items": {"$ref": "#/$defs/step"}}, + "threat_checks": {"type": "array", "items": {"type": "string"}}, + "ownership": {"type": "array", "items": {"$ref": "#/$defs/ownership"}} + }, + "allOf": [ + {"if": {"properties": {"engine": {"const": "native"}}}, + "then": {"required": ["steps", "threat_checks"]}}, + {"if": {"properties": {"engine": {"const": "legacy-command"}}}, + "then": {"required": ["legacy"]}} + ], + "$defs": { + "parameter": { + "type": "object", + "required": ["id", "label", "type"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "label": {"type": "string"}, + "type": {"enum": ["string", "enum", "path", "bool", "int"]}, + "values": {"type": "array", "items": {"type": "string"}}, + "default": {"type": ["string", "integer", "boolean"]}, + "pattern": {"type": "string"}, + "help": {"type": "string"}, + "secret": {"const": false, "description": "Parameters are never secret. Secrets are entered by the operator into the tool that owns them, never into the engine."} + } + }, + "prerequisite": { + "type": "object", + "required": ["text", "owner"], + "additionalProperties": false, + "properties": {"text": {"type": "string"}, "owner": {"type": "string"}} + }, + "check": { + "type": "object", + "required": ["done_when"], + "additionalProperties": false, + "properties": { + "done_when": {"type": "string", "minLength": 3}, + "command": {"type": "string"}, + "expect": {"enum": ["manual", "exit-0", "output-contains"]}, + "contains": {"type": "string"} + } + }, + "step": { + "type": "object", + "required": ["id", "title", "owner", "verify"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "title": {"type": "string"}, + "owner": {"type": "string"}, + "description": {"type": "string"}, + "command": {"type": "string", "description": "Template; {{param}} placeholders only"}, + "risk": {"enum": ["read-only", "changes-state", "attended", "destructive"], "default": "read-only"}, + "verify": {"$ref": "#/$defs/check"}, + "rollback": {"type": "string"}, + "evidence": {"type": "array", "items": {"type": "string"}, "description": "Names of non-secret facts recorded when the step completes"} + } + }, + "ownership": { + "type": "object", + "required": ["concern", "owner"], + "additionalProperties": false, + "properties": {"concern": {"type": "string"}, "owner": {"type": "string"}} + } + } +} diff --git a/tests/test_legacy.py b/tests/test_legacy.py new file mode 100644 index 0000000..b067a46 --- /dev/null +++ b/tests/test_legacy.py @@ -0,0 +1,46 @@ +import socket +import sys +import textwrap + +import pytest +import yaml + +from rtut import legacy, pack as P + + +def free_port(): + s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p + + +def make_repo(tmp_path, command, url, cwd="."): + d = tmp_path / "runbooks" / "old" + d.mkdir(parents=True) + (d / "pack.yaml").write_text(yaml.safe_dump({ + "spec": "runbook-pack/v0.1", "id": "t.old", "title": "Old console", "owner": "t", + "exercise_status": "unexercised", "engine": "legacy-command", + "legacy": {"command": command, "cwd": cwd, "url": url}})) + return P.load(d / "pack.yaml") + + +def test_launch_and_stop(tmp_path): + port = free_port() + pk = make_repo(tmp_path, [sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1"], + f"http://127.0.0.1:{port}") + proc = legacy.launch(pk, timeout=10) + try: + assert proc.poll() is None + finally: + proc.terminate(); proc.wait(5) + + +def test_timeout_when_console_never_answers(tmp_path): + pk = make_repo(tmp_path, [sys.executable, "-c", "import time; time.sleep(30)"], + f"http://127.0.0.1:{free_port()}") + with pytest.raises(TimeoutError): + legacy.launch(pk, timeout=1) + + +def test_cwd_escape_refused(tmp_path): + pk = make_repo(tmp_path, ["true"], "", cwd="../..") + with pytest.raises(ValueError): + legacy.launch(pk) diff --git a/tests/test_pack.py b/tests/test_pack.py new file mode 100644 index 0000000..9f9e383 --- /dev/null +++ b/tests/test_pack.py @@ -0,0 +1,63 @@ +import copy +from pathlib import Path + +import pytest +import yaml + +from rtut import pack as P + +ROOT = Path(__file__).resolve().parent.parent +GOOD = yaml.safe_load((ROOT / "runbooks/example-hello/pack.yaml").read_text()) + + +def check(tmp_path, data): + f = tmp_path / "pack.yaml" + f.write_text(yaml.safe_dump(data)) + return P.load(f) + + +def test_example_valid(): + assert P.load(ROOT / "runbooks/example-hello/pack.yaml").ok + + +def test_discover_finds_pack(): + assert any(p.parent.name == "example-hello" for p in P.discover(ROOT)) + + +def mut(fn): + d = copy.deepcopy(GOOD); fn(d); return d + + +@pytest.mark.parametrize("fn,frag", [ + (lambda d: d.pop("exercise_status"), "exercise_status"), + (lambda d: d.update(exercise_status="done"), "exercise_status"), + (lambda d: d["parameters"][0].update(secret=True), "secret"), + (lambda d: d["steps"][0].pop("verify"), "verify"), + (lambda d: d["steps"][0].update(command="x {{nope}}"), "undefined placeholder"), + (lambda d: d["steps"][0].update(risk="destructive"), "requires rollback"), + (lambda d: d["steps"][0]["verify"].update(expect="output-contains"), "contains"), + (lambda d: d["parameters"][0].update(default="bad host"), "pattern"), + (lambda d: d.pop("threat_checks"), "threat_checks"), + (lambda d: d["steps"][0].update(description="hvs.ABCDEFGHIJKLMN"), "secret-looking"), + (lambda d: d["steps"].append(copy.deepcopy(d["steps"][0])), "duplicate step id"), + (lambda d: d["parameters"].append({"id": "extra", "label": "x", "type": "string"}), "never used"), +]) +def test_invalid(tmp_path, fn, frag): + p = check(tmp_path, mut(fn)) + assert not p.ok and any(frag in e for e in p.errors), p.errors + + +def test_legacy_requires_command(tmp_path): + d = {"spec": "runbook-pack/v0.1", "id": "x.y", "title": "Legacy x", "owner": "o", + "exercise_status": "unexercised", "engine": "legacy-command"} + assert not check(tmp_path, d).ok + d["legacy"] = {"command": ["true"]} + assert check(tmp_path, d).ok + + +def test_render(): + pk = P.load(ROOT / "runbooks/example-hello/pack.yaml") + cmd = pk.data["steps"][0]["command"] + assert P.render(cmd, {"host": "example.org", "count": "2"}, pk) == "ping -c 2 example.org" + with pytest.raises(ValueError): + P.render(cmd, {"host": "a;rm -rf /"}, pk) diff --git a/workplans/RBT-WP-0001-statehub-bootstrap.md b/workplans/RBT-WP-0001-statehub-bootstrap.md index 570a241..da482dc 100644 --- a/workplans/RBT-WP-0001-statehub-bootstrap.md +++ b/workplans/RBT-WP-0001-statehub-bootstrap.md @@ -4,7 +4,7 @@ type: workplan title: "Bootstrap State Hub integration" domain: infotech repo: runbook-tutorials -status: ready +status: finished owner: codex topic_slug: stack created: "2026-09-29" @@ -19,7 +19,7 @@ Runbook-tutorial engine and UI that guides operators through parameterized IT ad ```task id: RBT-WP-0001-T01 -status: todo +status: done priority: high ``` @@ -30,7 +30,7 @@ Replace generated placeholders with repo-specific facts where needed. ```task id: RBT-WP-0001-T02 -status: todo +status: done priority: high ``` @@ -42,7 +42,7 @@ changes confidently. ```task id: RBT-WP-0001-T03 -status: todo +status: done priority: medium ``` diff --git a/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md b/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md new file mode 100644 index 0000000..52c9e20 --- /dev/null +++ b/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md @@ -0,0 +1,74 @@ +--- +id: RBT-WP-0002 +type: workplan +title: "Runbook pack specification and validator" +domain: infotech +repo: runbook-tutorials +status: active +flavor: implementation +owner: claude +topic_slug: stack +created: "2026-09-29" +updated: "2026-09-29" +--- + +## Goal + +Define what a runbook pack is, so packs can live in the repos that own them and +the engine can load, validate and present them. The pack spec is the contract +between this repo and every pack owner. + +## Publish the pack specification + +```task +id: RBT-WP-0002-T01 +status: done +priority: high +``` + +`docs/pack-spec.md` v0.1: manifest fields, parameters (never secret), steps with +mandatory verification, rollback for state-changing steps, exercise status per the +attended-procedure standard, and the legacy-command engine. + +## Encode the specification as a JSON Schema + +```task +id: RBT-WP-0002-T02 +status: done +priority: high +``` + +`schema/runbook-pack.schema.json` with conditional requirements per engine. + +## Implement discovery, validation and rendering + +```task +id: RBT-WP-0002-T03 +status: done +priority: high +``` + +`rtut/pack.py` and the CLI (`python -m rtut list|validate|show|render`), with +semantic checks the schema cannot express (placeholders, duplicates, rollback, +secret markers, parameter defaults) and 16 tests. Parameter rendering rejects +unsafe characters. + +## Review the specification with the operator and freeze v0.1 + +```task +id: RBT-WP-0002-T04 +status: todo +priority: high +``` + +Walk through the spec with Bernd against the real NetKingdom packs and the +legacy console. Open questions: step branching and conditional steps; whether +`verify` may run a command automatically; how packs declare dependencies on +other packs; signed packs. Record decisions and freeze `runbook-pack/v0.1`, or +raise v0.2 with a migration note. + +## Acceptance Criteria + +- Every pack in this repo and in `net-kingdom` validates. +- Invalid packs fail with a clear error; tests cover each rule. +- The spec names how the pre-existing console stays working. diff --git a/workplans/RBT-WP-0003-engine-core-run-state-and-evidence.md b/workplans/RBT-WP-0003-engine-core-run-state-and-evidence.md new file mode 100644 index 0000000..b53b431 --- /dev/null +++ b/workplans/RBT-WP-0003-engine-core-run-state-and-evidence.md @@ -0,0 +1,74 @@ +--- +id: RBT-WP-0003 +type: workplan +title: "Engine core: run state and evidence" +domain: infotech +repo: runbook-tutorials +status: ready +flavor: implementation +owner: claude +topic_slug: stack +created: "2026-09-29" +updated: "2026-09-29" +depends_on: [RBT-WP-0002] +--- + +## Goal + +Give the engine memory: a run of a pack keeps non-secret parameter values, +per-step state and evidence, and is the only thing allowed to change a pack's +exercise status. + +## Design the run record + +```task +id: RBT-WP-0003-T01 +status: todo +priority: high +``` + +Specify `runbook-run/v0.1`: pack id and revision, operator label, parameter +values, per-step state (`todo|running|done|failed|skipped`), timestamps, +evidence facts, verification results. Non-secret only, with the same secret-marker +rejection as packs. Stored under `.local/runbook-runs/` in the repo being operated. + +## Implement the run store and step state machine + +```task +id: RBT-WP-0003-T02 +status: todo +priority: high +``` + +Create, resume and list runs. Enforce order, block a step until the previous +one verified, record failures without losing state. Tests for restart and +resume. + +## Implement verification + +```task +id: RBT-WP-0003-T03 +status: todo +priority: medium +``` + +`manual`, `exit-0` and `output-contains` checks. Commands are shown to the +operator; the engine runs a verify command only when the operator confirms +it, with a timeout and captured output size only (no output stored). + +## Exercise-status writer + +```task +id: RBT-WP-0003-T04 +status: todo +priority: medium +``` + +When every step of a run has verified, offer to write +`exercise_status: exercised by ` into `pack.yaml` via a +reviewable diff. Never edit automatically. + +## Acceptance Criteria + +- A run survives restart and records outcomes without secrets. +- A pack becomes `exercised` only through a completed, verified run. diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md new file mode 100644 index 0000000..4f77743 --- /dev/null +++ b/workplans/RBT-WP-0004-local-runbook-ui.md @@ -0,0 +1,72 @@ +--- +id: RBT-WP-0004 +type: workplan +title: "Local runbook UI" +domain: infotech +repo: runbook-tutorials +status: ready +flavor: implementation +owner: claude +topic_slug: stack +created: "2026-09-29" +updated: "2026-09-29" +depends_on: [RBT-WP-0003] +--- + +## Goal + +The interface that made the operator ask for this: a local web UI (and a +terminal fallback) that shows where you are in a runbook, what to do next, and +what you have proven. + +## Review the existing console's interaction model + +```task +id: RBT-WP-0004-T01 +status: todo +priority: high +``` + +Run `make security-bootstrap-ui` in `net-kingdom` with Bernd and record what to keep +(state chips `nil/set/err/ok`, next safe action, copyable command blocks, +approval phrase) and what to change. Output: a short UX note in `docs/`. + +## Build the step view + +```task +id: RBT-WP-0004-T02 +status: todo +priority: high +``` + +Parameter form, ordered steps with owner tags, rendered copyable commands, +"done when" with confirm/verify, rollback shown beside each step, progress +overview, resume after restart. Localhost only, no external calls, whynot-design +posture, works without JavaScript frameworks. + +## Terminal mode + +```task +id: RBT-WP-0004-T03 +status: todo +priority: medium +``` + +`rtut run ` walking the same run record in the terminal, for hosts +without a browser. + +## Evidence view and export + +```task +id: RBT-WP-0004-T04 +status: todo +priority: medium +``` + +Show the run record, export it as non-secret JSON, and produce the exercise-status +diff from RBT-WP-0003-T04. + +## Acceptance Criteria + +- Bernd can complete the SSH tutorial pack in the UI without pasting outputs to an agent. +- The UI refuses to display or store anything matching the secret markers. diff --git a/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md b/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md new file mode 100644 index 0000000..e58350f --- /dev/null +++ b/workplans/RBT-WP-0005-legacy-adapter-and-console-migration.md @@ -0,0 +1,87 @@ +--- +id: RBT-WP-0005 +type: workplan +title: "Legacy adapter and console migration" +domain: infotech +repo: runbook-tutorials +status: active +flavor: implementation +owner: claude +topic_slug: stack +created: "2026-09-29" +updated: "2026-09-29" +depends_on: [RBT-WP-0002] +--- + +## Goal + +Carry the NetKingdom security bootstrap console into this ecosystem without +breaking it: first as an unchanged legacy pack, then, only where worthwhile, by +moving its generic parts into the engine. + +## Wrap the console as a legacy pack + +```task +id: RBT-WP-0005-T01 +status: done +priority: high +``` + +`net-kingdom/runbooks/security-bootstrap-console/pack.yaml` +(`engine: legacy-command`) validates and lists it, with no change to +`tools/security-bootstrap-console/`. + +## Implement launch and health for legacy packs + +```task +id: RBT-WP-0005-T02 +status: done +priority: high +``` + +`rtut launch ` runs `legacy.command` from the owning repo root, waits for +`legacy.url` to answer, and reports it. It stops the process it started. Test +with a stub server. + +## Inventory the console + +```task +id: RBT-WP-0005-T03 +status: todo +priority: high +``` + +Classify the 5,300-line `security_bootstrap_console.py` into generic engine +concerns (record and state model, gates, stage derivation, copyable blocks, +approval phrase, localhost server) and NetKingdom-specific content (custody, +OpenBao ceremony, KeyCape, privacyIDEA checks). Output: a migration map with +the tests that pin current behaviour. + +## Extract the generic engine parts + +```task +id: RBT-WP-0005-T04 +status: todo +priority: medium +``` + +Move the generic parts into `rtut` behind the run record. The console in +`net-kingdom` then imports them. Gate: the existing +`tools/security-bootstrap-console/tests` pass unchanged before and after, and +`make security-bootstrap-ui` behaves the same. + +## Decide the console's final form + +```task +id: RBT-WP-0005-T05 +status: todo +priority: low +``` + +With Bernd: keep the console as a permanent legacy pack, migrate its runbooks +to native packs, or a mix. Record as a decision. + +## Acceptance Criteria + +- The console works unchanged throughout. +- No secret handling is moved into the engine. diff --git a/workplans/RBT-WP-0006-execution-mode-and-pack-index.md b/workplans/RBT-WP-0006-execution-mode-and-pack-index.md new file mode 100644 index 0000000..5071049 --- /dev/null +++ b/workplans/RBT-WP-0006-execution-mode-and-pack-index.md @@ -0,0 +1,42 @@ +--- +id: RBT-WP-0006 +type: workplan +title: "Execution mode and multi-repo pack index" +domain: infotech +repo: runbook-tutorials +status: backlog +flavor: implementation +owner: claude +topic_slug: stack +created: "2026-09-29" +updated: "2026-09-29" +depends_on: [RBT-WP-0004] +--- + +## Goal + +Later capabilities, deliberately not started: opt-in execution of steps, and +discovery of packs across the estate. + +## Opt-in, audited step execution + +```task +id: RBT-WP-0006-T01 +status: wait +priority: low +``` + +Per-step `execute: true` allowed only for `read-only` steps first, with an +explicit operator confirmation, timeouts, and an audit record. Needs a +decision on which identity runs the command. + +## Cross-repo pack index + +```task +id: RBT-WP-0006-T02 +status: wait +priority: low +``` + +`rtut list --estate` scans registered repos for `runbooks/*/pack.yaml` and publishes +a capability entry in the registry. Needs a decision on scan scope.