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
|
# 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"
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
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