Projects have their own name, description and goal; the template is shown only as origin
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 52s
Work Records / validate (push) Successful in 12s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 14:12:21 +02:00
parent 334e0d3eb6
commit f8ce4717a8
8 changed files with 125 additions and 24 deletions

View file

@ -161,9 +161,10 @@ member status `not-applied | applying | applied | stale | failed`).
### Templates
A **project template** (`spec: project-template/v0.1`) is a project skeleton: goal, members, attribute sets and attributes that
still need values. Instantiating a template creates a project file, records which template it came from, and shows which required
attributes are still needed. Templates make a set of runbooks reusable as a product.
A **project template** (`spec: project-template/v0.1`) is a project skeleton: members, attribute sets and attributes that
still need values. Instantiating a template creates a project file with **its own name, description and goal**, records which template it
came from, and shows which required attributes are still needed. Nothing of the template's own title, goal or description is copied into the
project: a project is not a copy of its template, it only remembers where it came from. Templates make a set of runbooks reusable as a product.
### Modes

View file

@ -0,0 +1,30 @@
spec: project/v0.1
id: trial-1
title: A watch at a lighthouse
goal: The night watch is played through and its logbook entry is written and removed
again.
attribute_sets:
- harbour-defaults
targets:
north-light:
title: The northern light
attributes:
weather: fog
south-light:
title: The southern light
attributes:
weather: clear
runbooks:
- pack: rtut.showcase-lighthouse
target: north-light
- pack: rtut.example-hello
after:
- rtut.showcase-lighthouse
environment: rehearsal
template: lighthouse-watch
attributes:
logdir:
requirement: optional
note: Scratch folder; must start with /tmp/rtut-
value: /tmp/rtut-trial-1
keeper: Dana

View file

@ -78,8 +78,8 @@ def _project(a) -> int:
print(f"{tid}\ttemplate\t-\t{d.data.get('title', '')}")
return 0
if a.action == "new":
path = J.instantiate(reg, a.name, a.extra, a.environment,
dict(kv.split("=", 1) for kv in a.set))
path = J.instantiate(reg, a.name, a.extra, a.environment, dict(kv.split("=", 1) for kv in a.set),
title=a.title, description=a.description, goal=a.goal)
print(path); return 0
d = reg.projects.get(a.name)
if d is None:
@ -154,6 +154,9 @@ def main(argv: list[str] | None = None) -> int:
sp.add_argument("--environment", "-e", default="")
sp.add_argument("--set", "-s", action="append", default=[], help="new: attribute=value")
sp.add_argument("--operator", "-o", default="")
sp.add_argument("--title", default=None, help="new: the project's own name (default: its id)")
sp.add_argument("--description", default="", help="new: what the project is")
sp.add_argument("--goal", default="", help="new: what should be true when it is done")
sp.add_argument("--target", default=None)
sp.add_argument("--mode", default="manual", choices=["manual", "auto-confirm"])
sp = sub.add_parser("score", help="how often each runbook was tried and how it went")

View file

@ -212,8 +212,13 @@ def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str
# ---- templates -----------------------------------------------------------------------------------------------
def instantiate(reg: Registry, template_id: str, project_id: str, environment: str,
values: dict[str, str] | None = None, title: str | None = None, root: Path | None = None) -> Path:
"""Write projects/<id>/project.yaml from a template. Required attributes may stay unset; `state()` lists them."""
values: dict[str, str] | None = None, title: str | None = None, root: Path | None = None,
description: str = "", goal: str = "") -> Path:
"""Write projects/<id>/project.yaml from a template. Required attributes may stay unset; `state()` lists them.
The project has its own name, description and goal. Nothing of the template's own title, goal or description
is copied: the project only records which template it came from.
"""
t = reg.templates.get(template_id)
if t is None or not t.ok:
raise ValueError(f"no valid template {template_id}")
@ -221,7 +226,11 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s
raise ValueError("project id must be lower-case letters, digits, dots and hyphens")
if environment not in R.ENVIRONMENTS:
raise ValueError(f"environment must be one of {', '.join(R.ENVIRONMENTS)}")
d = {k: v for k, v in t.data.items() if k not in ("spec", "id", "title")}
from .pack import SECRET_RE
for label, text in (("title", title or ""), ("description", description), ("goal", goal)):
if SECRET_RE.search(text):
raise ValueError(f"the {label} looks like a secret; projects are never secret")
d = {k: v for k, v in t.data.items() if k not in ("spec", "id", "title", "goal", "description")}
attrs = normalize(d.get("attributes"))
for k, v in (values or {}).items():
attrs.setdefault(k, {"value": None, "requirement": None})
@ -230,9 +239,15 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s
for k, a in attrs.items():
out_attrs[k] = a["value"] if a.get("value") is not None and not a.get("requirement") and not a.get("note") else \
{kk: vv for kk, vv in a.items() if vv is not None}
project = {"spec": "project/v0.1", "id": project_id, "title": title or t.data["title"],
**{k: v for k, v in d.items() if k not in ("attributes", "environment")}, "environment": environment,
"template": template_id}
if len((title or project_id).strip()) < 3:
raise ValueError("the project name needs at least 3 characters")
project = {"spec": "project/v0.1", "id": project_id, "title": (title or project_id).strip()}
if description.strip():
project["description"] = description.strip()
if goal.strip():
project["goal"] = goal.strip()
project.update({k: v for k, v in d.items() if k not in ("attributes", "environment")})
project.update({"environment": environment, "template": template_id})
if out_attrs:
project["attributes"] = out_attrs
home = Path(root) if root else t.path.parent.parent

View file

@ -68,8 +68,10 @@ def index_sections(app) -> str:
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>")
origin = (f" <span class=note>from template <a href='/template/{e(d.data['template'])}'>{e(d.data['template'])}</a></span>"
if d.data.get("template") else "")
desc = f"<br><span class=note>{e(d.data['description'])}</span>" if d.data.get("description") else ""
prj.append(f"<li><a href='/project/{e(pid)}'>{e(d.data['title'])}</a> {pill(d.data['environment'])} {st}{origin}{desc}</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()]
@ -113,9 +115,11 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
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>")
f"{' <span class=note>from template <a href=' + chr(39) + '/template/' + e(d['template']) + chr(39) + '>' + e(d['template']) + '</a></span>' if d.get('template') else ''}</p>")
if d.get("description"):
out.append(f"<p>{e(d['description'])}</p>")
if d.get("goal"):
out.append(f"<p>{e(d['goal'])}</p>")
out.append(f"<p><b>Goal:</b> {e(d['goal'])}</p>")
# what is still needed
needs: dict[str, list[str]] = {}
for n in st["needs"]:
@ -204,6 +208,12 @@ def template_page(app, tid: str, msg: str = "") -> bytes | None:
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 name<br><input name=title class=grow style='width:100%' minlength=3 required "
"placeholder='what you call this project'></label></p>"
"<p><label>Description<br><textarea name=description rows=2 style='width:100%' "
"placeholder='what this project is (optional)'></textarea></label></p>"
"<p><label>Goal<br><input name=goal class=grow style='width:100%' "
"placeholder='what should be true when it is done (optional)'></label></p>"
"<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>"
@ -238,7 +248,10 @@ def create_from_template(app, tid: str, form: dict) -> str:
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)
if len(form.get("title", "").strip()) < 3:
raise ValueError("give the project a name of at least 3 characters")
J.instantiate(reg, tid, pid, form.get("environment", ""), values, title=form.get("title", ""),
root=doc.path.parent.parent, description=form.get("description", ""), goal=form.get("goal", ""))
reg = app.registry()
created = reg.projects[pid]
errs = J.validate(reg, created)

View file

@ -99,6 +99,10 @@
}
}
}
},
"description": {
"type": "string",
"description": "What this project is, in its own words. Not inherited from a template."
}
},
"allOf": [

View file

@ -197,10 +197,13 @@ def test_runs_of_other_projects_do_not_count(repo):
def test_instantiate_template_records_origin_merges_values_and_lists_needs(repo):
reg = Registry([repo])
out = J.instantiate(reg, "lighthouse-watch", "harbour-north", "rehearsal", {"keeper": "Ada", "logdir": "/tmp/rtut-harbour"})
out = J.instantiate(reg, "lighthouse-watch", "harbour-north", "rehearsal", {"keeper": "Ada", "logdir": "/tmp/rtut-harbour"},
title="North harbour watch", description="Our own harbour", goal="Both lights keep watch")
made = yaml.safe_load(out.read_text())
assert made["spec"] == "project/v0.1" and made["template"] == "lighthouse-watch" and made["environment"] == "rehearsal"
assert made["attributes"]["keeper"] == "Ada" and made["goal"].startswith("The night watch")
assert made["attributes"]["keeper"] == "Ada"
assert made["title"] == "North harbour watch" and made["description"] == "Our own harbour" and made["goal"] == "Both lights keep watch"
assert "The night watch is played through" not in out.read_text() and "A watch at a lighthouse" not in out.read_text() # nothing of the template's text is copied
assert set(made["targets"]) == {"north-light", "south-light"}
reg = Registry([repo])
assert reg.projects["harbour-north"].ok and J.validate(reg, reg.projects["harbour-north"]) == []
@ -235,3 +238,14 @@ def test_set_attributes_validates_against_the_consuming_runbooks(repo):
def test_attribute_notes_survive_resolution(repo):
reg = Registry([repo])
assert "Who keeps this light" in fold(set_layers(reg, "harbour-defaults"))["keeper"]["note"]
def test_project_without_a_given_name_uses_its_id_never_the_templates_title(repo):
reg = Registry([repo])
out = J.instantiate(reg, "lighthouse-watch", "plain-one", "staging")
made = yaml.safe_load(out.read_text())
assert made["title"] == "plain-one" and "goal" not in made and "description" not in made
with pytest.raises(ValueError, match="3 characters"):
J.instantiate(reg, "lighthouse-watch", "ab", "staging")
with pytest.raises(ValueError, match="secret"):
J.instantiate(reg, "lighthouse-watch", "leaky-one", "staging", description="token hvs.ABCDEFGHIJKLMN")

View file

@ -50,7 +50,8 @@ def req(s, method, path, form=None, host="127.0.0.1"):
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}"}
form = {"_t": t, "project_id": pid, "environment": "rehearsal", "a_keeper": "Ada", "a_logdir": f"/tmp/rtut-ui-{pid}",
"title": "Harbour watch", "description": "Our own harbour, two lights.", "goal": "Both lights keep watch tonight"}
if host:
form["a_host"] = host
return req(s, "POST", "/template/lighthouse-watch/new", form)
@ -68,6 +69,7 @@ def test_template_page_shows_attributes_with_requirement_and_notes(psrv):
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 "name=title" in body and "name=description" in body and "name=goal" in body # the project's own identity
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
@ -78,9 +80,11 @@ def test_create_project_from_template_and_view_it(psrv):
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 "<h1>Harbour watch</h1>" in body and "Our own harbour, two lights." in body and "Both lights keep watch tonight" in body
assert "from template <a href='/template/lighthouse-watch'>lighthouse-watch</a>" in body # origin is shown, as a link
assert "A watch at a lighthouse" not in body # the template's title...
assert "The night watch is played through" not in body # ...and its description are not
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
@ -159,12 +163,12 @@ def test_csrf_host_and_escaping(psrv):
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"})
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "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"})
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "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"})
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "other", "environment": "prod"})
assert st == 400 and "environment" in body
@ -175,3 +179,20 @@ def test_v02_run_shows_a_guard_instead_of_crashing(psrv):
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
def test_front_page_lists_projects_by_their_own_name_and_shows_the_template_as_origin(psrv):
make_project(psrv)
_, _, body = req(psrv, "GET", "/")
projects = body.split("<h2>Project templates</h2>")[0]
assert "Harbour watch" in projects and "Our own harbour, two lights." in projects
assert "from template <a href='/template/lighthouse-watch'>lighthouse-watch</a>" in projects
assert "The night watch is played through" not in projects and "A watch at a lighthouse" not in projects
assert "A watch at a lighthouse" in body.split("<h2>Project templates</h2>")[1] # the template keeps its own listing
def test_creating_a_project_needs_a_name(psrv):
t = psrv.app.token
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "nameless", "environment": "rehearsal", "title": "x"})
assert st == 400 and "name" in body
assert not (psrv.repo / "projects/nameless").exists()