Add runbook pack spec, validator, legacy launcher, and workplans RBT-WP-0002..0006
All checks were successful
Work Records / validate (push) Successful in 21s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 17s

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:
tegwick 2026-09-29 00:24:05 +02:00
parent 13512e68c8
commit 535836054a
19 changed files with 960 additions and 17 deletions

View file

@ -1,3 +1,21 @@
# runbook-tutorials # runbook-tutorials
A userinterface specialized to present runbook tutorials that with parameters can guide through actual it administration and operation tasks. 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>/pack.yaml` in the owning repo.
Examples: `net-kingdom/runbooks/`, and `runbooks/example-hello/` here.
```bash
python3 -m rtut validate <repo-root> # validate every pack
python3 -m rtut list <repo-root> # id, engine, exercise status, title
python3 -m rtut show <pack.yaml> # steps with owners and done-when
python3 -m rtut render <pack.yaml> <step> -p name=value
python3 -m rtut launch <pack.yaml> # 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`).

74
docs/pack-spec.md Normal file
View file

@ -0,0 +1,74 @@
# Runbook pack specification v0.1
A **runbook pack** is a directory `runbooks/<pack-dir>/` 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 <operator>` |
| `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.

View file

@ -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`.

View file

@ -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).

View file

@ -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]

2
rtut/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""Runbook-tutorial engine."""
__version__ = "0.1.0"

82
rtut/__main__.py Normal file
View 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
View 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
View 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)

View file

@ -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}

View file

@ -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"}}
}
}
}

46
tests/test_legacy.py Normal file
View file

@ -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)

63
tests/test_pack.py Normal file
View file

@ -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)

View file

@ -4,7 +4,7 @@ type: workplan
title: "Bootstrap State Hub integration" title: "Bootstrap State Hub integration"
domain: infotech domain: infotech
repo: runbook-tutorials repo: runbook-tutorials
status: ready status: finished
owner: codex owner: codex
topic_slug: stack topic_slug: stack
created: "2026-09-29" created: "2026-09-29"
@ -19,7 +19,7 @@ Runbook-tutorial engine and UI that guides operators through parameterized IT ad
```task ```task
id: RBT-WP-0001-T01 id: RBT-WP-0001-T01
status: todo status: done
priority: high priority: high
``` ```
@ -30,7 +30,7 @@ Replace generated placeholders with repo-specific facts where needed.
```task ```task
id: RBT-WP-0001-T02 id: RBT-WP-0001-T02
status: todo status: done
priority: high priority: high
``` ```
@ -42,7 +42,7 @@ changes confidently.
```task ```task
id: RBT-WP-0001-T03 id: RBT-WP-0001-T03
status: todo status: done
priority: medium priority: medium
``` ```

View file

@ -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.

View file

@ -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 <date> by <operator>` 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.

View file

@ -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 <pack>` 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.

View file

@ -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 <pack>` 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.

View file

@ -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.