UI for projects, templates, attributes and maturity (RBT-WP-0008-T08); stop a test from writing runs into the repo
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
3470552789
commit
eacbf8a21d
9 changed files with 560 additions and 12 deletions
|
|
@ -18,7 +18,7 @@ python3 -m rtut launch <pack.yaml> # legacy-command packs: start the exis
|
|||
python3 -m rtut maturity <pack.yaml> # draft | reviewed | rehearsed | proven | stale, from run history
|
||||
python3 -m rtut review <pack.yaml> --by you # record a review of the current revision
|
||||
python3 -m rtut project list|validate|status|attrs|new|run <repo-root> ... # projects, attribute sets, templates
|
||||
python3 -m rtut ui <repo-root> # local web UI on 127.0.0.1:8877: walk a pack, record a run
|
||||
python3 -m rtut ui <repo-root> [<repo-root> ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks, runs
|
||||
python3 -m rtut run new|start|verify|done|finish <pack.yaml> ... # the same run record from the terminal
|
||||
make test
|
||||
```
|
||||
|
|
|
|||
|
|
@ -109,9 +109,11 @@ def fold(layers: list[tuple[str, dict]]) -> dict[str, dict]:
|
|||
for source, attrs in layers:
|
||||
for name, a in attrs.items():
|
||||
cur = out.setdefault(name, {"value": None, "requirement": None, "source": None, "modified": False,
|
||||
"history": []})
|
||||
"history": [], "note": ""})
|
||||
if a.get("requirement"):
|
||||
cur["requirement"] = a["requirement"]
|
||||
if a.get("note"):
|
||||
cur["note"] = a["note"]
|
||||
if a.get("value") is not None:
|
||||
if cur["value"] is not None and cur["value"] != a["value"]:
|
||||
cur["modified"] = True
|
||||
|
|
|
|||
|
|
@ -242,3 +242,41 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s
|
|||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(yaml.safe_dump(project, sort_keys=False))
|
||||
return target
|
||||
|
||||
|
||||
# ---- editing -----------------------------------------------------------------------------------------------------
|
||||
|
||||
def set_attributes(reg: Registry, doc: Doc, values: dict[str, str]) -> Path:
|
||||
"""Give attributes values in the project file itself. Refuses values a consuming runbook rejects."""
|
||||
d = doc.data
|
||||
if d["spec"] != "project/v0.1":
|
||||
raise ValueError("only a project, not a template, takes values this way")
|
||||
known: dict[str, list] = {}
|
||||
for m in d["runbooks"]:
|
||||
pack = reg.packs.get(m["pack"])
|
||||
if pack is None:
|
||||
continue
|
||||
for x in (pack.data.get("datapoints") or pack.data.get("parameters") or []):
|
||||
known.setdefault(x["id"], []).append(pack)
|
||||
attrs = dict(d.get("attributes") or {})
|
||||
for name, value in values.items():
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
continue
|
||||
if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
||||
raise ValueError(f"bad attribute name {name}")
|
||||
from .pack import SECRET_RE
|
||||
if SECRET_RE.search(value):
|
||||
raise ValueError(f"{name}: value looks like a secret; attributes are never secret")
|
||||
for pack in known.get(name, []):
|
||||
err = check_value(pack, name, value)
|
||||
if err:
|
||||
raise ValueError(f"{name}: {err} (for {pack.id})")
|
||||
if isinstance(attrs.get(name), dict):
|
||||
attrs[name] = {**attrs[name], "value": value}
|
||||
else:
|
||||
attrs[name] = value
|
||||
text = yaml.safe_load(doc.path.read_text())
|
||||
text["attributes"] = attrs
|
||||
doc.path.write_text(yaml.safe_dump(text, sort_keys=False))
|
||||
return doc.path
|
||||
|
|
|
|||
63
rtut/ui.py
63
rtut/ui.py
|
|
@ -11,6 +11,8 @@ from pathlib import Path
|
|||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from . import pack as P, run as R
|
||||
from . import ui_projects as UP
|
||||
from .attrs import Registry
|
||||
|
||||
CSS = """body{font:15px/1.5 system-ui,sans-serif;max-width:52rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fff}
|
||||
@media(prefers-color-scheme:dark){body{color:#eee;background:#111}pre,input,select{background:#1b1b1b;color:#eee}}
|
||||
|
|
@ -19,7 +21,7 @@ h1,h2{font-weight:600}a{color:inherit}hr{border:0;border-top:1px solid #8886}
|
|||
.step{border:1px solid #8886;padding:.8rem 1rem;margin:.8rem 0}.step.running{border-color:currentColor}
|
||||
.pill{font:12px ui-monospace,monospace;border:1px solid #8886;padding:0 .4rem}
|
||||
pre{border:1px solid #8886;padding:.5rem;overflow:auto;margin:.4rem 0}
|
||||
button,input,select{font:inherit;padding:.25rem .5rem}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top}summary{cursor:pointer}.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}#current{scroll-margin-top:1rem}.note{opacity:.75}.bad{font-weight:600}"""
|
||||
button,input,select{font:inherit;padding:.25rem .5rem}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top}summary{cursor:pointer}.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}#current{scroll-margin-top:1rem}.note{opacity:.75}.bad{font-weight:600}table{border-collapse:collapse;width:100%;margin:.5rem 0}td,th{border-bottom:1px solid #8884;padding:.3rem .5rem;text-align:left;vertical-align:top}"""
|
||||
|
||||
|
||||
def page(title: str, body: str) -> bytes:
|
||||
|
|
@ -52,9 +54,17 @@ def score_line(c: dict) -> str:
|
|||
|
||||
class App:
|
||||
def __init__(self, roots: list[Path]):
|
||||
self.roots = [Path(r) for r in roots]
|
||||
self.token = secrets.token_urlsafe(16)
|
||||
self.packs = [P.load(p) for r in roots for p in P.discover(r)]
|
||||
|
||||
def registry(self) -> Registry:
|
||||
"""Fresh view of projects, attribute sets, templates and packs (files may have changed)."""
|
||||
return Registry(self.roots)
|
||||
|
||||
def pack_index(self, pack_id: str) -> int | None:
|
||||
return next((i for i, p in enumerate(self.packs) if p.id == pack_id), None)
|
||||
|
||||
def hidden(self) -> str:
|
||||
return f"<input type=hidden name=_t value='{self.token}'>"
|
||||
|
||||
|
|
@ -71,10 +81,9 @@ class App:
|
|||
rows.append(f"<li>{e(d['title'])} <span class=pill>legacy console</span> "
|
||||
f"<span class=note>start with: python -m rtut launch {e(p.path)}</span></li>")
|
||||
else:
|
||||
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> "
|
||||
f"<span class=pill>{e(d['exercise_status'])}</span> "
|
||||
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> {UP.maturity_pill(p)} "
|
||||
f"<span class=note>{e(score_line(R.scorecard(p)))}</span></li>")
|
||||
return page("Runbooks", f"<h1>Runbooks</h1><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
return page("Runbooks", UP.index_sections(self) + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
|
||||
def pack_page(self, i: int) -> bytes:
|
||||
p = self.packs[i]; d = p.data
|
||||
|
|
@ -88,7 +97,7 @@ class App:
|
|||
for x in d.get("parameters", []))
|
||||
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
|
||||
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
|
||||
f"{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
|
||||
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
|
||||
f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}"
|
||||
"<p><label>Your name (one word)<br><input name=operator required></label></p>"
|
||||
"<button>Start run</button></form>")
|
||||
|
|
@ -172,8 +181,16 @@ class App:
|
|||
|
||||
def run_page(self, i: int, rid: str, msg: str = "") -> bytes:
|
||||
p = self.packs[i]; r = R.load(p, rid); d = p.data
|
||||
if p.spec != "runbook-pack/v0.1":
|
||||
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=bad>This run uses {e(p.spec)}. The UI cannot walk "
|
||||
"v0.2 runs yet (RBT-WP-0004); the engine and CLI can.</p><p><a href='/'>← Back</a></p>")
|
||||
ctx = r.data.get("context") or {}
|
||||
where = ""
|
||||
if ctx.get("project"):
|
||||
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
|
||||
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
|
||||
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
|
||||
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}</p>"]
|
||||
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
blocked = False
|
||||
|
|
@ -280,6 +297,14 @@ def make_handler(app: App, port_holder: list):
|
|||
return self._send(app.pack_page(int(parts[1])))
|
||||
if parts[0] == "run":
|
||||
return self._send(app.run_page(int(parts[1]), parts[2]))
|
||||
if parts[0] == "project" and len(parts) == 2:
|
||||
body = UP.project_page(app, parts[1])
|
||||
if body:
|
||||
return self._send(body)
|
||||
if parts[0] == "template" and len(parts) == 2:
|
||||
body = UP.template_page(app, parts[1])
|
||||
if body:
|
||||
return self._send(body)
|
||||
except (IndexError, ValueError, R.RunError):
|
||||
pass
|
||||
self._send(page("Not found", "<p>Not found.</p>"), 404)
|
||||
|
|
@ -294,6 +319,26 @@ def make_handler(app: App, port_holder: list):
|
|||
parts = [x for x in urlparse(self.path).path.split("/") if x]
|
||||
msg = ""
|
||||
try:
|
||||
if len(parts) == 3 and parts[0] == "pack" and parts[2] == "review":
|
||||
from . import maturity as M
|
||||
i = int(parts[1])
|
||||
try:
|
||||
M.add_review(app.packs[i], form.get("by", ""), form.get("note", ""))
|
||||
except M.ReviewError as ex:
|
||||
raise ValueError(str(ex))
|
||||
return self._redirect(f"/pack/{i}#maturity")
|
||||
if len(parts) == 3 and parts[0] == "project" and parts[2] in ("attributes", "run"):
|
||||
from . import project as J
|
||||
reg = app.registry(); doc = reg.projects[parts[1]]
|
||||
if parts[2] == "attributes":
|
||||
J.set_attributes(reg, doc, {k[2:]: v for k, v in form.items() if k.startswith("a_")})
|
||||
return self._redirect(f"/project/{parts[1]}")
|
||||
r = J.start_run(reg, doc, form.get("pack", ""), form.get("operator", ""), form.get("target") or None)
|
||||
i = app.pack_index(form.get("pack", ""))
|
||||
return self._redirect(f"/run/{i}/{r.data['run_id']}#current")
|
||||
if len(parts) == 3 and parts[0] == "template" and parts[2] == "new":
|
||||
pid = UP.create_from_template(app, parts[1], form)
|
||||
return self._redirect(f"/project/{pid}")
|
||||
if parts[:2] == ["run", "new"]:
|
||||
i = int(parts[2]); p = app.packs[i]
|
||||
params = {k[2:]: v for k, v in form.items() if k.startswith("p_")}
|
||||
|
|
@ -327,6 +372,12 @@ def make_handler(app: App, port_holder: list):
|
|||
except (R.RunError, KeyError, ValueError, IndexError) as ex:
|
||||
msg = str(ex)
|
||||
try:
|
||||
if parts[0] == "project":
|
||||
return self._send(UP.project_page(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
|
||||
if parts[0] == "template":
|
||||
return self._send(UP.template_page(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
|
||||
if parts[0] == "pack":
|
||||
return self._send(app.pack_page(int(parts[1])).replace(b"<h1>", f"<p class=bad>{e(msg)}</p><h1>".encode(), 1), 400)
|
||||
if parts[:2] == ["run", "new"]:
|
||||
return self._send(app.pack_page(int(parts[2])).replace(b"<h1>", f"<p class=bad>{e(msg)}</p><h1>".encode(), 1), 400)
|
||||
return self._send(app.run_page(int(parts[1]), parts[2], msg), 400)
|
||||
|
|
|
|||
249
rtut/ui_projects.py
Normal file
249
rtut/ui_projects.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""UI pages for projects, templates, attributes and maturity. Server-rendered; no script needed."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from pathlib import Path
|
||||
|
||||
from . import maturity as M, project as J, run as R
|
||||
from .attrs import fold, set_layers
|
||||
|
||||
|
||||
def e(s) -> str:
|
||||
return html.escape(str(s))
|
||||
|
||||
|
||||
def pill(text: str) -> str:
|
||||
return f"<span class=pill>{e(text)}</span>"
|
||||
|
||||
|
||||
def _runnable_here(pack) -> bool:
|
||||
return pack.ok and pack.data.get("engine") == "native" and pack.spec == "runbook-pack/v0.1"
|
||||
|
||||
|
||||
# ---- maturity ------------------------------------------------------------------------------------------------
|
||||
|
||||
def maturity_pill(pack) -> str:
|
||||
if not pack.ok or pack.data.get("engine") != "native":
|
||||
return ""
|
||||
m = M.maturity(pack)
|
||||
label = m["level"] + (f" (was {m['stale_from']})" if m["stale_from"] else "")
|
||||
return pill(label)
|
||||
|
||||
|
||||
def idempotency_text(pack) -> str:
|
||||
if pack.spec != "runbook-pack/v0.2":
|
||||
return "not declared (v0.1 packs cannot declare it)"
|
||||
from .flow import pack_idempotency
|
||||
return pack_idempotency(pack.data)
|
||||
|
||||
|
||||
def maturity_block(app, i: int, pack) -> str:
|
||||
m = M.maturity(pack)
|
||||
ev = m["evidence"]
|
||||
revs = "".join(f"<li>{e(r['by'])} · {e(r['at'][:10])} · {'current revision' if r['digest'] == m['digest'] else 'older revision'}"
|
||||
f"{' · ' + e(r['note']) if r.get('note') else ''}</li>" for r in M._reviews(pack))
|
||||
stale = f" It was <b>{e(m['stale_from'])}</b> before the pack changed." if m["stale_from"] else ""
|
||||
return (f"<div id=maturity class=step><h2>Maturity {maturity_pill(pack)}</h2>"
|
||||
f"<p class=note>Earned from run history at revision <code>{e(m['digest'])}</code>; independent of any one environment.{stale}</p>"
|
||||
f"<p>Reviewed: <b>{'yes' if ev['reviewed'] else 'no'}</b> · clean runs: <b>{ev['clean_runs']}</b> "
|
||||
f"(in rehearsal: {ev['rehearsal_runs']}) · environments: {e(', '.join(ev['environments']) or '-')} · "
|
||||
f"operators: {e(', '.join(ev['operators']) or '-')}</p>"
|
||||
"<p class=note>proven = reviewed + rehearsed + clean in two environments by two operators. Runs without a project "
|
||||
"context, and runs with deviations, do not count.</p>"
|
||||
f"<p>Idempotency: <b>{e(idempotency_text(pack))}</b></p>"
|
||||
f"{'<ul>' + revs + '</ul>' if revs else ''}"
|
||||
f"<form method=post action='/pack/{i}/review'>{app.hidden()}<p class=row>"
|
||||
"<input name=by placeholder='your name (one word)' required> <input name=note class=grow placeholder='review note (optional)'> "
|
||||
"<button>Record my review of this revision</button></p></form></div>")
|
||||
|
||||
|
||||
# ---- index sections ---------------------------------------------------------------------------------------------
|
||||
|
||||
def index_sections(app) -> str:
|
||||
reg = app.registry()
|
||||
prj = []
|
||||
for pid, d in reg.projects.items():
|
||||
if not d.ok:
|
||||
prj.append(f"<li class=bad>{e(pid)}: invalid ({e(d.errors[0])})</li>")
|
||||
continue
|
||||
errs = J.validate(reg, d)
|
||||
st = pill("invalid") if errs else pill(J.state(reg, d)["state"])
|
||||
prj.append(f"<li><a href='/project/{e(pid)}'>{e(d.data['title'])}</a> {pill(d.data['environment'])} {st} "
|
||||
f"<span class=note>{e(d.data.get('goal', ''))}</span></li>")
|
||||
tpl = [f"<li><a href='/template/{e(tid)}'>{e(d.data['title'])}</a> "
|
||||
f"<span class=note>{e(d.data.get('goal', ''))}</span></li>" if d.ok else f"<li class=bad>{e(tid)}: invalid</li>"
|
||||
for tid, d in reg.templates.items()]
|
||||
out = f"<h1>Projects</h1><ul>{''.join(prj) or '<li class=note>No projects yet. Start one from a template.</li>'}</ul>"
|
||||
out += f"<h2>Project templates</h2><ul>{''.join(tpl) or '<li class=note>No templates found.</li>'}</ul>"
|
||||
return out
|
||||
|
||||
|
||||
# ---- attribute tables ----------------------------------------------------------------------------------------------
|
||||
|
||||
def attribute_table(rows: list[dict]) -> str:
|
||||
body = ""
|
||||
for r in rows:
|
||||
value = "" if r["value"] is None else str(r["value"])
|
||||
if r["value"] is None and "default" in r:
|
||||
value = f"(runbook default: {r['default']})"
|
||||
cls = " class=bad" if r["state"] == "err" or (r["state"] == "nil" and r["requirement"] == "required" and "default" not in r) else ""
|
||||
source = e(r["source"]) if r["source"] else "-"
|
||||
body += (f"<tr{cls}><td>{e(r['label'])} <span class=note>({e(r['id'])})</span></td><td>{e(value)}</td>"
|
||||
f"<td>{pill(r['state'])}{' ' + pill('modified') if r['modified'] else ''}</td><td>{e(r['requirement'])}</td>"
|
||||
f"<td>{source}</td><td>{e(r.get('error', ''))}</td></tr>")
|
||||
return ("<table><tr><th>Attribute</th><th>Value</th><th>State</th><th>Requirement</th><th>Source</th><th>Problem</th></tr>"
|
||||
f"{body}</table>")
|
||||
|
||||
|
||||
# ---- project page ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def project_page(app, pid: str, msg: str = "") -> bytes | None:
|
||||
from .ui import page
|
||||
reg = app.registry()
|
||||
doc = reg.projects.get(pid)
|
||||
if doc is None:
|
||||
return None
|
||||
d = doc.data
|
||||
out = [f"<h1>{e(d.get('title', pid))}</h1>"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
errs = J.validate(reg, doc)
|
||||
if errs:
|
||||
out.append("<div class=step><p class=bad>This project has problems:</p><ul>" + "".join(f"<li>{e(x)}</li>" for x in errs) + "</ul></div>")
|
||||
return page(d.get("title", pid), "".join(out))
|
||||
st = J.state(reg, doc)
|
||||
out.append(f"<p>{pill(d['environment'])} {pill(st['state'])}"
|
||||
f"{' <span class=note>from template ' + e(d['template']) + '</span>' if d.get('template') else ''}</p>")
|
||||
if d.get("goal"):
|
||||
out.append(f"<p>{e(d['goal'])}</p>")
|
||||
# what is still needed
|
||||
needs: dict[str, list[str]] = {}
|
||||
for n in st["needs"]:
|
||||
needs.setdefault(n["attribute"], []).append(n["pack"])
|
||||
if needs:
|
||||
fields = "".join(f"<p><label>{e(a)}<br><input name='a_{e(a)}'></label> <span class=note>needed by {e(', '.join(packs))}</span></p>"
|
||||
for a, packs in needs.items())
|
||||
out.append(f"<div class=step><h2>Still needed</h2><p class=note>Required attributes without a value. Never enter secrets.</p>"
|
||||
f"<form method=post action='/project/{e(pid)}/attributes'>{app.hidden()}{fields}<button>Save attributes</button></form></div>")
|
||||
# members
|
||||
rows = ""
|
||||
by_pack = {m["pack"]: m for m in st["members"]}
|
||||
for spec in d["runbooks"]:
|
||||
pack = reg.packs[spec["pack"]]
|
||||
ms = next(m for m in st["members"] if m["pack"] == spec["pack"] and m["target"] == spec.get("target"))
|
||||
i = app.pack_index(spec["pack"])
|
||||
wait = [a for a in spec.get("after", []) if by_pack[a]["status"] != "applied"]
|
||||
needed = sorted({n["attribute"] for n in st["needs"] if n["pack"] == spec["pack"]})
|
||||
if not _runnable_here(pack):
|
||||
action = "<span class=note>cannot be run in this UI yet</span>"
|
||||
elif needed:
|
||||
action = f"<span class=note>needs: {e(', '.join(needed))}</span>"
|
||||
elif wait:
|
||||
action = f"<span class=note>waiting for {e(', '.join(wait))}</span>"
|
||||
else:
|
||||
action = (f"<form method=post action='/project/{e(pid)}/run' class=row>{app.hidden()}"
|
||||
f"<input type=hidden name=pack value='{e(spec['pack'])}'><input type=hidden name=target value='{e(spec.get('target') or '')}'>"
|
||||
"<input name=operator placeholder='your name' size=10 required> <button>Start run</button></form>")
|
||||
link = f" <a href='/run/{i}/{e(ms['run'])}'>latest run</a>" if ms.get("run") and i is not None else ""
|
||||
dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else ""
|
||||
rows += (f"<tr><td>{e(pack.data.get('title', pack.id))}<br><span class=note>{e(spec['pack'])}"
|
||||
f"{' @' + e(spec['target']) if spec.get('target') else ''}</span></td>"
|
||||
f"<td>{pill(ms['status'])}{e(dev)}{link}</td><td>{maturity_pill(pack)}</td><td>{action}</td></tr>")
|
||||
out.append("<h2>Runbooks</h2>"
|
||||
f"<p class=note>Next: {e(', '.join(st['next']) or 'nothing to start')}</p>"
|
||||
"<table><tr><th>Runbook</th><th>Status</th><th>Maturity</th><th></th></tr>" + rows + "</table>")
|
||||
# attributes per member
|
||||
out.append("<h2>Attributes</h2><p class=note>What each runbook receives from this project, and where each value comes from.</p>")
|
||||
for spec in d["runbooks"]:
|
||||
res = J.member_attributes(reg, d, spec)
|
||||
unused = f"<p class=note>Unused attributes: {e(', '.join(res['unused']))}</p>" if res["unused"] else ""
|
||||
out.append(f"<details><summary><b>{e(reg.packs[spec['pack']].data.get('title', spec['pack']))}</b>"
|
||||
f"{' @' + e(spec['target']) if spec.get('target') else ''}</summary>{attribute_table(res['rows'])}{unused}</details>")
|
||||
if d.get("targets"):
|
||||
out.append("<h2>Targets</h2><ul>" + "".join(f"<li><b>{e(t)}</b> {e(v.get('title', ''))}</li>" for t, v in d["targets"].items()) + "</ul>")
|
||||
return page(d["title"], "".join(out))
|
||||
|
||||
|
||||
# ---- template page ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def template_page(app, tid: str, msg: str = "") -> bytes | None:
|
||||
from .ui import page
|
||||
reg = app.registry()
|
||||
doc = reg.templates.get(tid)
|
||||
if doc is None:
|
||||
return None
|
||||
d = doc.data
|
||||
out = [f"<h1>{e(d.get('title', tid))}</h1><p>{pill('template')}</p>"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
if not doc.ok:
|
||||
out.append("<p class=bad>This template is invalid:</p><ul>" + "".join(f"<li>{e(x)}</li>" for x in doc.errors) + "</ul>")
|
||||
return page(d.get("title", tid), "".join(out))
|
||||
if d.get("goal"):
|
||||
out.append(f"<p>{e(d['goal'])}</p>")
|
||||
layers = []
|
||||
try:
|
||||
for s in d.get("attribute_sets", []):
|
||||
layers += set_layers(reg, s)
|
||||
except ValueError as ex:
|
||||
out.append(f"<p class=bad>{e(ex)}</p>")
|
||||
from .attrs import normalize
|
||||
layers.append(("template", normalize(d.get("attributes"))))
|
||||
folded = fold(layers)
|
||||
members = "".join(f"<li>{e(m['pack'])}{' @' + e(m['target']) if m.get('target') else ''}"
|
||||
f"{' (after ' + e(', '.join(m['after'])) + ')' if m.get('after') else ''}</li>" for m in d["runbooks"])
|
||||
out.append(f"<h2>Runbooks</h2><ul>{members}</ul>")
|
||||
fields = ""
|
||||
for name, a in folded.items():
|
||||
req = a["requirement"] or "optional"
|
||||
val = "" if a["value"] is None else a["value"]
|
||||
note = f" - {e(a['note'])}" if a.get("note") else ""
|
||||
fields += (f"<p><label>{e(name)} {pill(req)}<br><input name='a_{e(name)}' value='{e(val)}'"
|
||||
f"{' required' if req == 'required' and not val else ''}></label>"
|
||||
f"<span class=note>{note}{' from ' + e(a['source']) if a['source'] else ''}</span></p>")
|
||||
envs = "".join(f"<option>{e(x)}</option>" for x in R.ENVIRONMENTS)
|
||||
out.append("<h2>Start a project from this template</h2>"
|
||||
f"<form method=post action='/template/{e(tid)}/new'>{app.hidden()}"
|
||||
"<p><label>Project id<br><input name=project_id pattern='[a-z0-9]+([.-][a-z0-9]+)*' required "
|
||||
"placeholder='lower-case, digits, dots, hyphens'></label></p>"
|
||||
f"<p><label>Environment<br><select name=environment>{envs}</select></label></p>"
|
||||
"<h3>Attributes</h3><p class=note>Values you leave as they are stay defaults; required ones must be given before the "
|
||||
f"runbooks can start. Never enter secrets.</p>{fields}<button>Create project</button></form>")
|
||||
return page(d["title"], "".join(out))
|
||||
|
||||
|
||||
# ---- actions ------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def create_from_template(app, tid: str, form: dict) -> str:
|
||||
"""Instantiate a template; returns the new project id."""
|
||||
reg = app.registry()
|
||||
doc = reg.templates.get(tid)
|
||||
if doc is None or not doc.ok:
|
||||
raise ValueError(f"no valid template {tid}")
|
||||
layers = []
|
||||
for s in doc.data.get("attribute_sets", []):
|
||||
layers += set_layers(reg, s)
|
||||
from .attrs import normalize
|
||||
layers.append(("template", normalize(doc.data.get("attributes"))))
|
||||
base = fold(layers)
|
||||
values = {}
|
||||
for k, v in form.items():
|
||||
if not k.startswith("a_"):
|
||||
continue
|
||||
name, v = k[2:], v.strip()
|
||||
if v and str(base.get(name, {}).get("value")) != v:
|
||||
values[name] = v
|
||||
from .pack import SECRET_RE
|
||||
for name, v in values.items():
|
||||
if SECRET_RE.search(v):
|
||||
raise ValueError(f"{name}: value looks like a secret; attributes are never secret")
|
||||
pid = form.get("project_id", "").strip()
|
||||
J.instantiate(reg, tid, pid, form.get("environment", ""), values, root=doc.path.parent.parent)
|
||||
reg = app.registry()
|
||||
created = reg.projects[pid]
|
||||
errs = J.validate(reg, created)
|
||||
if errs:
|
||||
created.path.unlink()
|
||||
created.path.parent.rmdir()
|
||||
raise ValueError(f"project would be invalid: {errs[0]}")
|
||||
return pid
|
||||
|
|
@ -212,3 +212,26 @@ def test_instantiate_template_records_origin_merges_values_and_lists_needs(repo)
|
|||
J.instantiate(reg, "lighthouse-watch", "harbour-x", "prod")
|
||||
with pytest.raises(R.RunError, match="template is not run"):
|
||||
J.start_run(reg, reg.templates["lighthouse-watch"], "rtut.showcase-lighthouse", "bob")
|
||||
|
||||
|
||||
def test_set_attributes_validates_against_the_consuming_runbooks(repo):
|
||||
reg = project(repo, attributes={})
|
||||
doc = reg.projects["north-watch"]
|
||||
J.set_attributes(reg, doc, {"keeper": "Bea", "logdir": "/tmp/rtut-set-here", "unrelated": "x"})
|
||||
made = yaml.safe_load(doc.path.read_text())["attributes"]
|
||||
assert made["keeper"] == "Bea" and made["logdir"] == "/tmp/rtut-set-here" and made["unrelated"] == "x"
|
||||
with pytest.raises(ValueError, match="rtut-|violates"):
|
||||
J.set_attributes(reg, doc, {"logdir": "/etc"})
|
||||
with pytest.raises(ValueError, match="secret"):
|
||||
J.set_attributes(reg, doc, {"keeper": "hvs.ABCDEFGHIJKLMN"})
|
||||
with pytest.raises(ValueError, match="bad attribute name"):
|
||||
J.set_attributes(reg, doc, {"Bad-Name": "x"})
|
||||
assert yaml.safe_load(doc.path.read_text())["attributes"]["logdir"] == "/tmp/rtut-set-here" # refused values change nothing
|
||||
reg = Registry([repo])
|
||||
with pytest.raises(ValueError, match="template"):
|
||||
J.set_attributes(reg, reg.templates["lighthouse-watch"], {"keeper": "x"})
|
||||
|
||||
|
||||
def test_attribute_notes_survive_resolution(repo):
|
||||
reg = Registry([repo])
|
||||
assert "Who keeps this light" in fold(set_layers(reg, "harbour-defaults"))["keeper"]["note"]
|
||||
|
|
|
|||
177
tests/test_ui_projects.py
Normal file
177
tests/test_ui_projects.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import http.client
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import project as J, run as R, run2, ui
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def write(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def psrv(tmp_path):
|
||||
repo = tmp_path
|
||||
shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", repo / "runbooks/show/pack.yaml") if (repo / "runbooks/show").mkdir(parents=True) is None else None
|
||||
hello = yaml.safe_load((ROOT / "runbooks/example-hello/pack.yaml").read_text())
|
||||
hello["parameters"] = [{"id": "host", "label": "Host to ping", "type": "string", "pattern": "[a-zA-Z0-9.-]+"},
|
||||
{"id": "count", "label": "Ping count", "type": "int", "default": 1}]
|
||||
write(repo / "runbooks/hello/pack.yaml", hello)
|
||||
v2 = yaml.safe_load((ROOT / "runbooks/showcase-lighthouse/pack.v0.2.yaml").read_text())
|
||||
v2["id"] = "rtut.showcase-lighthouse-v2"
|
||||
write(repo / "runbooks/lh2/pack.yaml", v2)
|
||||
for f in (ROOT / "attribute-sets").glob("*.yaml"):
|
||||
write(repo / "attribute-sets" / f.name, yaml.safe_load(f.read_text()))
|
||||
tpl = yaml.safe_load((ROOT / "project-templates/lighthouse-watch.yaml").read_text())
|
||||
tpl["runbooks"].append({"pack": "rtut.showcase-lighthouse-v2", "after": ["rtut.example-hello"]})
|
||||
write(repo / "project-templates/lighthouse-watch.yaml", tpl)
|
||||
s = ui.serve([repo], 0)
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
s.repo = repo
|
||||
yield s
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def req(s, method, path, form=None, host="127.0.0.1"):
|
||||
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
|
||||
body = urlencode(form) if form is not None else None
|
||||
c.request(method, path, body, {"Host": host, "Content-Type": "application/x-www-form-urlencoded"})
|
||||
r = c.getresponse()
|
||||
return r.status, r.getheader("Location"), r.read().decode()
|
||||
|
||||
|
||||
def make_project(s, pid="harbour", host=None):
|
||||
t = s.app.token
|
||||
form = {"_t": t, "project_id": pid, "environment": "rehearsal", "a_keeper": "Ada", "a_logdir": f"/tmp/rtut-ui-{pid}"}
|
||||
if host:
|
||||
form["a_host"] = host
|
||||
return req(s, "POST", "/template/lighthouse-watch/new", form)
|
||||
|
||||
|
||||
def test_index_has_projects_templates_runbooks_and_maturity(psrv):
|
||||
_, _, body = req(psrv, "GET", "/")
|
||||
assert "<h1>Projects</h1>" in body and "Project templates" in body and "<h2>Runbooks</h2>" in body
|
||||
assert "/template/lighthouse-watch" in body
|
||||
assert ">draft<" in body # maturity pill on every native runbook
|
||||
|
||||
|
||||
def test_template_page_shows_attributes_with_requirement_and_notes(psrv):
|
||||
st, _, body = req(psrv, "GET", "/template/lighthouse-watch")
|
||||
assert st == 200 and "A watch at a lighthouse" in body
|
||||
assert "name='a_keeper'" in body and "Who keeps this light" in body and ">required<" in body
|
||||
assert "name='a_weather'" not in body # weather is set per target, so it is not a template question
|
||||
assert "Create project" in body and "<option>rehearsal</option>" in body and "<option>production</option>" in body
|
||||
assert req(psrv, "GET", "/template/nope")[0] == 404
|
||||
|
||||
|
||||
def test_create_project_from_template_and_view_it(psrv):
|
||||
st, loc, _ = make_project(psrv)
|
||||
assert st == 303 and loc == "/project/harbour"
|
||||
made = yaml.safe_load((psrv.repo / "projects/harbour/project.yaml").read_text())
|
||||
assert made["template"] == "lighthouse-watch" and made["environment"] == "rehearsal" and made["attributes"]["keeper"] == "Ada"
|
||||
_, _, body = req(psrv, "GET", "/project/harbour")
|
||||
assert "A watch at a lighthouse" in body and "from template lighthouse-watch" in body
|
||||
assert ">planned<" in body and ">rehearsal<" in body
|
||||
assert "The night watch is played through" in body # the goal
|
||||
assert "target:north-light" in body # provenance of the fog weather
|
||||
assert "Still needed" in body and "name='a_host'" in body # hello needs a host
|
||||
assert "cannot be run in this UI yet" in body # the v0.2 member
|
||||
|
||||
|
||||
def test_needed_attributes_can_be_filled_in_and_are_validated(psrv):
|
||||
make_project(psrv)
|
||||
t = psrv.app.token
|
||||
st, _, body = req(psrv, "POST", "/project/harbour/attributes", {"_t": t, "a_host": "bad host;rm"})
|
||||
assert st == 400 and "host" in body and "Still needed" in body
|
||||
assert req(psrv, "POST", "/project/harbour/attributes", {"_t": t, "a_host": "hvs.ABCDEFGHIJKLMN"})[0] == 400
|
||||
assert req(psrv, "POST", "/project/harbour/attributes", {"_t": t, "a_host": "example.org"})[0] == 303
|
||||
_, _, body = req(psrv, "GET", "/project/harbour")
|
||||
assert "Still needed" not in body and "example.org" in body
|
||||
|
||||
|
||||
def test_start_a_run_from_a_project_uses_its_attributes_and_links_back(psrv):
|
||||
make_project(psrv, host="example.org")
|
||||
t = psrv.app.token
|
||||
_, _, body = req(psrv, "GET", "/project/harbour")
|
||||
assert "waiting for rtut.showcase-lighthouse" in body # hello waits for the showcase
|
||||
assert body.count("Start run") == 1 # only the member that may start
|
||||
st, loc, _ = req(psrv, "POST", "/project/harbour/run", {"_t": t, "pack": "rtut.showcase-lighthouse", "target": "north-light", "operator": "bernd"})
|
||||
assert st == 303 and loc.startswith("/run/") and loc.endswith("#current")
|
||||
_, _, run_page = req(psrv, "GET", loc.split("#")[0])
|
||||
assert "project <a href='/project/harbour'>harbour</a> @north-light · rehearsal" in run_page
|
||||
_, _, body = req(psrv, "GET", "/project/harbour")
|
||||
assert ">applying<" in body and "latest run" in body
|
||||
st, loc2, _ = req(psrv, "POST", "/project/harbour/run", {"_t": t, "pack": "rtut.showcase-lighthouse-v2", "operator": "bernd"})
|
||||
assert st == 303 # the engine accepts it; the UI just cannot walk v0.2 runs yet
|
||||
assert "cannot walk v0.2 runs yet" in req(psrv, "GET", loc2.split("#")[0])[2]
|
||||
|
||||
|
||||
def test_attribute_table_shows_state_source_modified_and_problems(psrv):
|
||||
make_project(psrv)
|
||||
reg_file = psrv.repo / "projects/harbour/project.yaml"
|
||||
d = yaml.safe_load(reg_file.read_text()); d["attributes"]["logdir"] = "/etc"
|
||||
reg_file.write_text(yaml.safe_dump(d, sort_keys=False))
|
||||
_, _, body = req(psrv, "GET", "/project/harbour")
|
||||
assert "<th>Source</th>" in body and "set:harbour-defaults" in body
|
||||
assert ">err<" in body and "/etc" in body # invalid value is shown as err, not hidden
|
||||
assert ">nil<" in body # hello's host has no value yet
|
||||
|
||||
|
||||
def test_review_form_records_a_review_and_raises_maturity(psrv):
|
||||
t = psrv.app.token
|
||||
i = psrv.app.pack_index("rtut.showcase-lighthouse")
|
||||
_, _, body = req(psrv, "GET", f"/pack/{i}")
|
||||
assert "Maturity" in body and "Reviewed: <b>no</b>" in body and "Record my review" in body
|
||||
st, loc, _ = req(psrv, "POST", f"/pack/{i}/review", {"_t": t, "by": "alice", "note": "read it"})
|
||||
assert st == 303 and loc.endswith("#maturity")
|
||||
_, _, body = req(psrv, "GET", f"/pack/{i}")
|
||||
assert "Reviewed: <b>yes</b>" in body and "alice" in body and "current revision" in body and ">reviewed<" in body
|
||||
st, _, body = req(psrv, "POST", f"/pack/{i}/review", {"_t": t, "by": "two words"})
|
||||
assert st == 400 and "one word" in body
|
||||
|
||||
|
||||
def test_idempotency_shown_per_spec_version(psrv):
|
||||
i = psrv.app.pack_index("rtut.showcase-lighthouse")
|
||||
_, _, body = req(psrv, "GET", f"/pack/{i}")
|
||||
assert "Idempotency: <b>not declared (v0.1 packs cannot declare it)</b>" in body
|
||||
|
||||
|
||||
def test_csrf_host_and_escaping(psrv):
|
||||
assert req(psrv, "POST", "/template/lighthouse-watch/new", {"project_id": "x", "environment": "rehearsal"})[0] == 403
|
||||
assert req(psrv, "POST", "/project/harbour/run", {"pack": "x"})[0] == 403
|
||||
assert req(psrv, "GET", "/project/harbour", host="evil.example")[0] == 403
|
||||
tpl = psrv.repo / "project-templates/lighthouse-watch.yaml"
|
||||
d = yaml.safe_load(tpl.read_text()); d["title"] = "<script>alert(1)</script> watch"
|
||||
tpl.write_text(yaml.safe_dump(d, sort_keys=False))
|
||||
_, _, body = req(psrv, "GET", "/")
|
||||
assert "<script>alert(1)" not in body and "<script>alert(1)" in body
|
||||
_, _, body = req(psrv, "GET", "/template/lighthouse-watch")
|
||||
assert "<script>alert(1)" not in body
|
||||
|
||||
|
||||
def test_bad_project_id_and_duplicate_are_refused(psrv):
|
||||
t = psrv.app.token
|
||||
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "Bad Id!", "environment": "rehearsal"})
|
||||
assert st == 400 and "project id" in body
|
||||
assert make_project(psrv)[0] == 303
|
||||
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "harbour", "environment": "rehearsal"})
|
||||
assert st == 400 and "already exists" in body
|
||||
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "other", "environment": "prod"})
|
||||
assert st == 400 and "environment" in body
|
||||
|
||||
|
||||
def test_v02_run_shows_a_guard_instead_of_crashing(psrv):
|
||||
reg = psrv.app.registry()
|
||||
pack = reg.packs["rtut.showcase-lighthouse-v2"]
|
||||
r = run2.create(pack, "bernd", {"logdir": "/tmp/rtut-ui-v2"})
|
||||
i = psrv.app.pack_index("rtut.showcase-lighthouse-v2")
|
||||
st, _, body = req(psrv, "GET", f"/run/{i}/{r.data['run_id']}")
|
||||
assert st == 200 and "cannot walk v0.2 runs yet" in body
|
||||
|
|
@ -35,9 +35,12 @@ def test_showcase_v02_is_valid_and_loads_through_pack_load():
|
|||
assert pk.spec == "runbook-pack/v0.2"
|
||||
|
||||
|
||||
def test_v02_packs_run_through_run_create():
|
||||
def test_v02_packs_run_through_run_create(tmp_path):
|
||||
import shutil
|
||||
import uuid
|
||||
r = R.create(P.load(FILE), "tester", {"logdir": f"/tmp/rtut-{uuid.uuid4().hex[:8]}"})
|
||||
d = tmp_path / "runbooks" / "lh"; d.mkdir(parents=True) # a copy: never write run records into the real repo
|
||||
shutil.copy(FILE, d / "pack.yaml")
|
||||
r = R.create(P.load(d / "pack.yaml"), "tester", {"logdir": f"/tmp/rtut-{uuid.uuid4().hex[:8]}"})
|
||||
assert r.data["spec"] == "runbook-run/v0.2" and r.data["mode"] == "manual"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,12 +110,17 @@ Retrying a failed non-idempotent step requires the operator to confirm the earli
|
|||
|
||||
```task
|
||||
id: RBT-WP-0008-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "913c46fa-7790-5d32-ada1-8dec430d4ff5"
|
||||
```
|
||||
|
||||
Show maturity and idempotency next to each runbook; a project view (goal, targets, member status, next member, needed
|
||||
Done 2026-09-29 (tested over HTTP, not yet reviewed in a browser): the runbook list shows maturity per runbook; the front page lists
|
||||
projects (environment, state, goal) and templates; a template page shows every attribute with requirement, note and source and
|
||||
creates a project; the project page shows goal, state, still-needed attributes (fillable, validated against the runbooks that
|
||||
consume them), members with status, maturity, next member and a Start run button, and per-member attribute tables with state,
|
||||
source, modified mark and problems; runs started from a project take its attributes and link back to it; the pack page shows
|
||||
maturity evidence, idempotency and a review form. Not covered: walking v0.2 runs (RBT-WP-0004). Original scope: show maturity and idempotency next to each runbook; a project view (goal, targets, member status, next member, needed
|
||||
attributes with state, source and modified marks); start a run from a project so its attributes fill the form; a
|
||||
template picker that instantiates a project.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue