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
20
README.md
20
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.
|
||||
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
74
docs/pack-spec.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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).
|
||||
13
registry/indexes/capabilities.yaml
Normal file
13
registry/indexes/capabilities.yaml
Normal 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
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)
|
||||
25
runbooks/example-hello/pack.yaml
Normal file
25
runbooks/example-hello/pack.yaml
Normal 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}
|
||||
98
schema/runbook-pack.schema.json
Normal file
98
schema/runbook-pack.schema.json
Normal 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
46
tests/test_legacy.py
Normal 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
63
tests/test_pack.py
Normal 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)
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
74
workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md
Normal file
74
workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md
Normal 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.
|
||||
74
workplans/RBT-WP-0003-engine-core-run-state-and-evidence.md
Normal file
74
workplans/RBT-WP-0003-engine-core-run-state-and-evidence.md
Normal 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.
|
||||
72
workplans/RBT-WP-0004-local-runbook-ui.md
Normal file
72
workplans/RBT-WP-0004-local-runbook-ui.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
42
workplans/RBT-WP-0006-execution-mode-and-pack-index.md
Normal file
42
workplans/RBT-WP-0006-execution-mode-and-pack-index.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue