diff --git a/README.md b/README.md index 8515c34..3a9ee25 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ python3 -m rtut launch # legacy-command packs: start the exis python3 -m rtut maturity # draft | reviewed | rehearsed | proven | stale, from run history python3 -m rtut review --by you # record a review of the current revision python3 -m rtut project list|validate|status|attrs|new|run ... # projects, attribute sets, templates -python3 -m rtut ui # local web UI on 127.0.0.1:8877: walk a pack, record a run +python3 -m rtut ui [ ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks, runs python3 -m rtut run new|start|verify|done|finish ... # the same run record from the terminal make test ``` diff --git a/rtut/attrs.py b/rtut/attrs.py index 6d15d90..52584b8 100644 --- a/rtut/attrs.py +++ b/rtut/attrs.py @@ -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 diff --git a/rtut/project.py b/rtut/project.py index dbeeefa..3b82fb2 100644 --- a/rtut/project.py +++ b/rtut/project.py @@ -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 diff --git a/rtut/ui.py b/rtut/ui.py index 10837d1..4c62ace 100644 --- a/rtut/ui.py +++ b/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"" @@ -71,10 +81,9 @@ class App: rows.append(f"
  • {e(d['title'])} legacy console " f"start with: python -m rtut launch {e(p.path)}
  • ") else: - rows.append(f"
  • {e(d['title'])} " - f"{e(d['exercise_status'])} " + rows.append(f"
  • {e(d['title'])} {UP.maturity_pill(p)} " f"{e(score_line(R.scorecard(p)))}
  • ") - return page("Runbooks", f"

    Runbooks

      {''.join(rows) or '
    • No packs found.
    • '}
    ") + return page("Runbooks", UP.index_sections(self) + f"

    Runbooks

      {''.join(rows) or '
    • No packs found.
    • '}
    ") 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"

    {e(d['title'])}

    {e(d['exercise_status'])}

    " f"

    {e(d.get('outcome', ''))}

    {e(score_line(R.scorecard(p)))}

    " - f"{self.runs_list(i, p)}

    Prerequisites

      {prereq}
    " + f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}

    Prerequisites

      {prereq}
    " f"
    {self.hidden()}{fields}" "

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

    {e(d['title'])}

    This run uses {e(p.spec)}. The UI cannot walk " + "v0.2 runs yet (RBT-WP-0004); the engine and CLI can.

    ← Back

    ") + ctx = r.data.get("context") or {} + where = "" + if ctx.get("project"): + where = (f" · project {e(ctx['project'])}" + f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}") out = [f"

    {e(d['title'])}

    {e(d['exercise_status'])}

    " - f"

    run {e(rid)} · operator {e(r.data['operator'])}

    "] + f"

    run {e(rid)} · operator {e(r.data['operator'])}{where}

    "] if msg: out.append(f"

    {e(msg)}

    ") 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", "

    Not found.

    "), 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"

    {e(msg)}

    "), 400) + if parts[0] == "template": + return self._send(UP.template_page(app, parts[1], msg) or page("Error", f"

    {e(msg)}

    "), 400) + if parts[0] == "pack": + return self._send(app.pack_page(int(parts[1])).replace(b"

    ", f"

    {e(msg)}

    ".encode(), 1), 400) if parts[:2] == ["run", "new"]: return self._send(app.pack_page(int(parts[2])).replace(b"

    ", f"

    {e(msg)}

    ".encode(), 1), 400) return self._send(app.run_page(int(parts[1]), parts[2], msg), 400) diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py new file mode 100644 index 0000000..249043f --- /dev/null +++ b/rtut/ui_projects.py @@ -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"{e(text)}" + + +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"
  • {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 ''}
  • " for r in M._reviews(pack)) + stale = f" It was {e(m['stale_from'])} before the pack changed." if m["stale_from"] else "" + return (f"

    Maturity {maturity_pill(pack)}

    " + f"

    Earned from run history at revision {e(m['digest'])}; independent of any one environment.{stale}

    " + f"

    Reviewed: {'yes' if ev['reviewed'] else 'no'} · clean runs: {ev['clean_runs']} " + f"(in rehearsal: {ev['rehearsal_runs']}) · environments: {e(', '.join(ev['environments']) or '-')} · " + f"operators: {e(', '.join(ev['operators']) or '-')}

    " + "

    proven = reviewed + rehearsed + clean in two environments by two operators. Runs without a project " + "context, and runs with deviations, do not count.

    " + f"

    Idempotency: {e(idempotency_text(pack))}

    " + f"{'
      ' + revs + '
    ' if revs else ''}" + f"
    {app.hidden()}

    " + " " + "

    ") + + +# ---- 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"
  • {e(pid)}: invalid ({e(d.errors[0])})
  • ") + continue + errs = J.validate(reg, d) + st = pill("invalid") if errs else pill(J.state(reg, d)["state"]) + prj.append(f"
  • {e(d.data['title'])} {pill(d.data['environment'])} {st} " + f"{e(d.data.get('goal', ''))}
  • ") + tpl = [f"
  • {e(d.data['title'])} " + f"{e(d.data.get('goal', ''))}
  • " if d.ok else f"
  • {e(tid)}: invalid
  • " + for tid, d in reg.templates.items()] + out = f"

    Projects

      {''.join(prj) or '
    • No projects yet. Start one from a template.
    • '}
    " + out += f"

    Project templates

      {''.join(tpl) or '
    • No templates found.
    • '}
    " + 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"{e(r['label'])} ({e(r['id'])}){e(value)}" + f"{pill(r['state'])}{' ' + pill('modified') if r['modified'] else ''}{e(r['requirement'])}" + f"{source}{e(r.get('error', ''))}") + return ("" + f"{body}
    AttributeValueStateRequirementSourceProblem
    ") + + +# ---- 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"

    {e(d.get('title', pid))}

    "] + if msg: + out.append(f"

    {e(msg)}

    ") + errs = J.validate(reg, doc) + if errs: + out.append("

    This project has problems:

      " + "".join(f"
    • {e(x)}
    • " for x in errs) + "
    ") + return page(d.get("title", pid), "".join(out)) + st = J.state(reg, doc) + out.append(f"

    {pill(d['environment'])} {pill(st['state'])}" + f"{' from template ' + e(d['template']) + '' if d.get('template') else ''}

    ") + if d.get("goal"): + out.append(f"

    {e(d['goal'])}

    ") + # 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"

    needed by {e(', '.join(packs))}

    " + for a, packs in needs.items()) + out.append(f"

    Still needed

    Required attributes without a value. Never enter secrets.

    " + f"
    {app.hidden()}{fields}
    ") + # 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 = "cannot be run in this UI yet" + elif needed: + action = f"needs: {e(', '.join(needed))}" + elif wait: + action = f"waiting for {e(', '.join(wait))}" + else: + action = (f"
    {app.hidden()}" + f"" + "
    ") + link = f" latest run" if ms.get("run") and i is not None else "" + dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else "" + rows += (f"{e(pack.data.get('title', pack.id))}
    {e(spec['pack'])}" + f"{' @' + e(spec['target']) if spec.get('target') else ''}" + f"{pill(ms['status'])}{e(dev)}{link}{maturity_pill(pack)}{action}") + out.append("

    Runbooks

    " + f"

    Next: {e(', '.join(st['next']) or 'nothing to start')}

    " + "" + rows + "
    RunbookStatusMaturity
    ") + # attributes per member + out.append("

    Attributes

    What each runbook receives from this project, and where each value comes from.

    ") + for spec in d["runbooks"]: + res = J.member_attributes(reg, d, spec) + unused = f"

    Unused attributes: {e(', '.join(res['unused']))}

    " if res["unused"] else "" + out.append(f"
    {e(reg.packs[spec['pack']].data.get('title', spec['pack']))}" + f"{' @' + e(spec['target']) if spec.get('target') else ''}{attribute_table(res['rows'])}{unused}
    ") + if d.get("targets"): + out.append("

    Targets

      " + "".join(f"
    • {e(t)} {e(v.get('title', ''))}
    • " for t, v in d["targets"].items()) + "
    ") + 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"

    {e(d.get('title', tid))}

    {pill('template')}

    "] + if msg: + out.append(f"

    {e(msg)}

    ") + if not doc.ok: + out.append("

    This template is invalid:

      " + "".join(f"
    • {e(x)}
    • " for x in doc.errors) + "
    ") + return page(d.get("title", tid), "".join(out)) + if d.get("goal"): + out.append(f"

    {e(d['goal'])}

    ") + layers = [] + try: + for s in d.get("attribute_sets", []): + layers += set_layers(reg, s) + except ValueError as ex: + out.append(f"

    {e(ex)}

    ") + from .attrs import normalize + layers.append(("template", normalize(d.get("attributes")))) + folded = fold(layers) + members = "".join(f"
  • {e(m['pack'])}{' @' + e(m['target']) if m.get('target') else ''}" + f"{' (after ' + e(', '.join(m['after'])) + ')' if m.get('after') else ''}
  • " for m in d["runbooks"]) + out.append(f"

    Runbooks

      {members}
    ") + 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"

    " + f"{note}{' from ' + e(a['source']) if a['source'] else ''}

    ") + envs = "".join(f"" for x in R.ENVIRONMENTS) + out.append("

    Start a project from this template

    " + f"
    {app.hidden()}" + "

    " + f"

    " + "

    Attributes

    Values you leave as they are stay defaults; required ones must be given before the " + f"runbooks can start. Never enter secrets.

    {fields}
    ") + 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 diff --git a/tests/test_projects.py b/tests/test_projects.py index b41f00b..a966229 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -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"] diff --git a/tests/test_ui_projects.py b/tests/test_ui_projects.py new file mode 100644 index 0000000..e14cb1c --- /dev/null +++ b/tests/test_ui_projects.py @@ -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 "

    Projects

    " in body and "Project templates" in body and "

    Runbooks

    " 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 "" in body and "" 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 harbour @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 "Source" 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: no" 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: yes" 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: not declared (v0.1 packs cannot declare it)" 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"] = " watch" + tpl.write_text(yaml.safe_dump(d, sort_keys=False)) + _, _, body = req(psrv, "GET", "/") + assert "