Maturity from run history, pack revision digests, attribute sets, projects and templates (RBT-WP-0008)
Also fixes run lists and scores mixing runs of different packs in one repository. 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
2f60d47746
commit
0203bf553c
19 changed files with 1311 additions and 33 deletions
|
|
@ -65,6 +65,59 @@ def _run(pk, a) -> int:
|
|||
return 1
|
||||
|
||||
|
||||
def _project(a) -> int:
|
||||
from . import project as J
|
||||
from .attrs import Registry
|
||||
reg = Registry([Path(a.root)])
|
||||
try:
|
||||
if a.action == "list":
|
||||
for pid, d in reg.projects.items():
|
||||
st = J.state(reg, d)["state"] if d.ok else "invalid"
|
||||
print(f"{pid}\t{d.data.get('environment', '?')}\t{st}\t{d.data.get('title', '')}")
|
||||
for tid, d in reg.templates.items():
|
||||
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))
|
||||
print(path); return 0
|
||||
d = reg.projects.get(a.name)
|
||||
if d is None:
|
||||
print(f"error: no project {a.name}", file=sys.stderr); return 1
|
||||
errs = J.validate(reg, d)
|
||||
if a.action == "validate":
|
||||
print(("ok " if not errs else "FAIL ") + d.id)
|
||||
for e in errs:
|
||||
print(" -", e)
|
||||
return 1 if errs else 0
|
||||
if errs:
|
||||
print("\n".join(errs), file=sys.stderr); return 1
|
||||
if a.action == "status":
|
||||
st = J.state(reg, d)
|
||||
print(f"{d.id}: {st['state']} [{d.data['environment']}] {d.data.get('goal', '')}")
|
||||
for m in st["members"]:
|
||||
print(f" {m['status']:12} {m['pack']}" + (f" @{m['target']}" if m["target"] else ""))
|
||||
print(" next:", ", ".join(st["next"]) or "-")
|
||||
for n in st["needs"]:
|
||||
print(f" needs: {n['attribute']} ({n['pack']})")
|
||||
return 0
|
||||
if a.action == "attrs":
|
||||
member = next(m for m in d.data["runbooks"] if m["pack"] == a.extra)
|
||||
res = J.member_attributes(reg, d.data, member)
|
||||
for r in res["rows"]:
|
||||
print(f"{r['state']:4} {r['requirement']:9} {r['id']} = {r['value']!r} <- {r['source'] or 'runbook default' if 'default' in r else r['source'] or '-'}"
|
||||
+ (" (modified)" if r["modified"] else "") + (f" ERROR: {r['error']}" if r.get("error") else ""))
|
||||
if res["unused"]:
|
||||
print("unused attributes:", ", ".join(res["unused"]))
|
||||
return 0
|
||||
if a.action == "run":
|
||||
r = J.start_run(reg, d, a.extra, a.operator, a.target, a.mode)
|
||||
print(r.data["run_id"]); return 0
|
||||
except Exception as e: # user-facing command: report the problem, do not print a trace
|
||||
print(f"error: {e}", file=sys.stderr); return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="rtut")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
|
@ -89,6 +142,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||
sp = sub.add_parser("ui", help="serve the local UI on 127.0.0.1")
|
||||
sp.add_argument("roots", nargs="*", default=["."])
|
||||
sp.add_argument("--port", type=int, default=8877)
|
||||
sp = sub.add_parser("maturity", help="derived maturity of a pack at its current revision")
|
||||
sp.add_argument("manifest")
|
||||
sp = sub.add_parser("review", help="record that you reviewed the pack at its current revision")
|
||||
sp.add_argument("manifest"); sp.add_argument("--by", required=True); sp.add_argument("--note", default="")
|
||||
sp = sub.add_parser("project", help="projects: list, validate, status, attrs, new (from a template), run")
|
||||
sp.add_argument("action", choices=["list", "validate", "status", "attrs", "new", "run"])
|
||||
sp.add_argument("root")
|
||||
sp.add_argument("name", nargs="?", help="project id (new: template id)")
|
||||
sp.add_argument("extra", nargs="?", help="attrs/run: pack id; new: the new project id")
|
||||
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("--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")
|
||||
sp.add_argument("root", nargs="?", default=".")
|
||||
sp = sub.add_parser("feedback", help="list the latest feedback of every run of a pack")
|
||||
|
|
@ -109,6 +176,24 @@ def main(argv: list[str] | None = None) -> int:
|
|||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
if a.cmd == "project":
|
||||
return _project(a)
|
||||
if a.cmd in ("maturity", "review"):
|
||||
from . import maturity as M
|
||||
pk = P.load(Path(a.manifest))
|
||||
if not pk.ok:
|
||||
print("\n".join(pk.errors), file=sys.stderr); return 1
|
||||
if a.cmd == "review":
|
||||
try:
|
||||
M.add_review(pk, a.by, a.note)
|
||||
except M.ReviewError as e:
|
||||
print(f"error: {e}", file=sys.stderr); return 1
|
||||
print("review recorded for revision", P.digest(pk)); return 0
|
||||
m = M.maturity(pk); ev = m["evidence"]
|
||||
print(f"{pk.id}: {m['level']}" + (f" (was {m['stale_from']})" if m["stale_from"] else "") + f" revision {m['digest']}")
|
||||
print(f" reviewed: {ev['reviewed']} clean runs: {ev['clean_runs']} (rehearsal {ev['rehearsal_runs']}) "
|
||||
f"environments: {', '.join(ev['environments']) or '-'} operators: {', '.join(ev['operators']) or '-'}")
|
||||
return 0
|
||||
if a.cmd == "score":
|
||||
from . import run as R
|
||||
for p in _packs(a.root):
|
||||
|
|
|
|||
142
rtut/attrs.py
Normal file
142
rtut/attrs.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Attribute sets: named, composable, non-secret attribute collections with layered resolution.
|
||||
|
||||
An attribute has a *requirement* (required | optional), declared, and a *state*, derived with the same
|
||||
words as run datapoints: nil (no value), set, err (invalid for a consuming runbook). `modified` marks a
|
||||
value that a later layer replaced. Every value keeps its source, so a wrong value is traceable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from . import pack as P
|
||||
from .pack import SECRET_RE
|
||||
|
||||
SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
|
||||
|
||||
|
||||
def _schema_errors(data: dict, schema_file: str) -> list[str]:
|
||||
v = Draft202012Validator(json.loads((SCHEMA_DIR / schema_file).read_text()))
|
||||
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 normalize(attrs: dict | None) -> dict[str, dict]:
|
||||
"""scalar or {value, requirement, note} -> {value, requirement, note}"""
|
||||
out = {}
|
||||
for k, v in (attrs or {}).items():
|
||||
out[k] = dict(v) if isinstance(v, dict) else {"value": v}
|
||||
out[k].setdefault("value", None)
|
||||
out[k].setdefault("requirement", None)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Doc:
|
||||
path: Path
|
||||
data: dict
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return str(self.data.get("id", self.path.stem))
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
def _load(path: Path, schema_file: str) -> Doc:
|
||||
try:
|
||||
data = yaml.safe_load(Path(path).read_text())
|
||||
except yaml.YAMLError as e:
|
||||
return Doc(Path(path), {}, [f"invalid YAML: {e}"])
|
||||
if not isinstance(data, dict):
|
||||
return Doc(Path(path), {}, ["must be a mapping"])
|
||||
errs = _schema_errors(data, schema_file)
|
||||
if not errs and SECRET_RE.search(json.dumps(data)):
|
||||
errs.append("contains secret-looking marker")
|
||||
return Doc(Path(path), data, errs)
|
||||
|
||||
|
||||
def load_set(path: Path) -> Doc:
|
||||
return _load(path, "attribute-set.schema.json")
|
||||
|
||||
|
||||
class Registry:
|
||||
"""Everything found under one or more repository roots."""
|
||||
|
||||
def __init__(self, roots: list[Path]):
|
||||
self.roots = [Path(r) for r in roots]
|
||||
self.sets: dict[str, Doc] = {}
|
||||
self.projects: dict[str, Doc] = {}
|
||||
self.templates: dict[str, Doc] = {}
|
||||
self.packs: dict[str, P.Pack] = {}
|
||||
for r in self.roots:
|
||||
for f in sorted(r.glob("attribute-sets/*.yaml")):
|
||||
d = load_set(f); self.sets[d.id] = d
|
||||
for f in sorted(r.glob("projects/*/project.yaml")):
|
||||
d = _load(f, "project.schema.json"); self.projects[d.id] = d
|
||||
for f in sorted(r.glob("project-templates/*.yaml")):
|
||||
d = _load(f, "project.schema.json"); self.templates[d.id] = d
|
||||
for f in P.discover(r):
|
||||
pk = P.load(f); self.packs[pk.id] = pk
|
||||
|
||||
|
||||
def set_layers(reg: Registry, set_id: str, _seen: tuple = ()) -> list[tuple[str, dict]]:
|
||||
"""Layers of an attribute set, its `extends` first. Raises on unknown sets and cycles."""
|
||||
if set_id in _seen:
|
||||
raise ValueError("attribute set cycle: " + " -> ".join(_seen + (set_id,)))
|
||||
doc = reg.sets.get(set_id)
|
||||
if doc is None:
|
||||
raise ValueError(f"unknown attribute set {set_id}")
|
||||
if not doc.ok:
|
||||
raise ValueError(f"attribute set {set_id} is invalid: {doc.errors[0]}")
|
||||
layers: list[tuple[str, dict]] = []
|
||||
for base in doc.data.get("extends", []):
|
||||
layers += set_layers(reg, base, _seen + (set_id,))
|
||||
layers.append((f"set:{set_id}", normalize(doc.data["attributes"])))
|
||||
return layers
|
||||
|
||||
|
||||
def fold(layers: list[tuple[str, dict]]) -> dict[str, dict]:
|
||||
"""Later layers win. Keeps provenance and marks a value replaced by a later layer as modified."""
|
||||
out: 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": []})
|
||||
if a.get("requirement"):
|
||||
cur["requirement"] = a["requirement"]
|
||||
if a.get("value") is not None:
|
||||
if cur["value"] is not None and cur["value"] != a["value"]:
|
||||
cur["modified"] = True
|
||||
cur["value"] = a["value"]
|
||||
cur["source"] = source
|
||||
cur["history"].append({"source": source, "value": a["value"]})
|
||||
for cur in out.values():
|
||||
cur["state"] = "set" if cur["value"] is not None else "nil"
|
||||
return out
|
||||
|
||||
|
||||
def check_value(pack: P.Pack, name: str, value) -> str | None:
|
||||
"""None if the value is valid for the pack's datapoint/parameter `name`, else the reason."""
|
||||
defs = {d["id"]: d for d in (pack.data.get("datapoints") or pack.data.get("parameters") or [])}
|
||||
d = defs.get(name)
|
||||
if d is None:
|
||||
return None
|
||||
v = str(value).lower() if isinstance(value, bool) else str(value)
|
||||
import re
|
||||
if d["type"] == "int" and not re.fullmatch(r"-?\d+", v):
|
||||
return "not an integer"
|
||||
if d["type"] == "bool" and v not in ("true", "false"):
|
||||
return "must be true or false"
|
||||
try:
|
||||
P.render("{{%s}}" % name, {name: v}, pack)
|
||||
except (ValueError, KeyError) as e:
|
||||
return str(e)
|
||||
return None
|
||||
83
rtut/maturity.py
Normal file
83
rtut/maturity.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Runbook maturity: earned from run history, independent of any one environment, bound to a pack revision.
|
||||
|
||||
draft valid, no review and no clean rehearsal at the current revision
|
||||
reviewed a review is recorded for the current revision
|
||||
rehearsed at least one clean run in a rehearsal environment at the current revision
|
||||
proven reviewed, rehearsed, and clean in at least two environments by at least two operators
|
||||
stale nothing holds at the current revision, but a higher level was reached at an earlier one
|
||||
|
||||
A run is *clean* when it succeeded with no deviation (no override, waiver or push-through).
|
||||
Runs without an environment context are not attributable and never count.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import yaml
|
||||
|
||||
from . import run as R
|
||||
from .pack import REVIEW_FILE, Pack, digest
|
||||
|
||||
LEVELS = ("draft", "reviewed", "rehearsed", "proven")
|
||||
|
||||
|
||||
class ReviewError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _reviews(pack: Pack) -> list[dict]:
|
||||
f = pack.path.parent / REVIEW_FILE
|
||||
if not f.exists():
|
||||
return []
|
||||
return list((yaml.safe_load(f.read_text()) or {}).get("reviews", []))
|
||||
|
||||
|
||||
def add_review(pack: Pack, by: str, note: str = "") -> dict:
|
||||
"""Record that `by` reviewed the pack at its current revision."""
|
||||
if not by.strip() or len(by.split()) != 1:
|
||||
raise ReviewError("reviewer must be one word (name or handle)")
|
||||
try:
|
||||
R._clean(by, "reviewer"); R._clean(note, "review note")
|
||||
except R.RunError as e:
|
||||
raise ReviewError(str(e)) from e
|
||||
entry = {"by": by.strip(), "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"digest": digest(pack), "note": note.strip()}
|
||||
revs = _reviews(pack) + [entry]
|
||||
(pack.path.parent / REVIEW_FILE).write_text(yaml.safe_dump({"reviews": revs}, sort_keys=False))
|
||||
return entry
|
||||
|
||||
|
||||
def _env_key(ctx: dict) -> str:
|
||||
return f"{ctx.get('project', '-')}/{ctx.get('target', '-')}"
|
||||
|
||||
|
||||
def _evidence(pack: Pack, dig: str, runs: list[R.Run]) -> dict:
|
||||
clean = [r for r in runs if r.data.get("pack_digest") == dig and r.data.get("outcome") == "succeeded"
|
||||
and not r.data.get("deviations") and r.data.get("context", {}).get("environment")]
|
||||
reviewed = any(rv.get("digest") == dig for rv in _reviews(pack))
|
||||
rehearsal = [r for r in clean if r.data["context"]["environment"] == "rehearsal"]
|
||||
envs = {_env_key(r.data["context"]) for r in clean}
|
||||
ops = {r.data["operator"] for r in clean}
|
||||
proven = reviewed and bool(rehearsal) and len(envs) >= 2 and len(ops) >= 2
|
||||
level = "proven" if proven else "rehearsed" if rehearsal else "reviewed" if reviewed else "draft"
|
||||
return {"level": level, "reviewed": reviewed, "clean_runs": len(clean), "rehearsal_runs": len(rehearsal),
|
||||
"environments": sorted(envs), "operators": sorted(ops)}
|
||||
|
||||
|
||||
def maturity(pack: Pack) -> dict:
|
||||
"""{'level', 'stale_from', 'digest', 'evidence'} for the pack's current revision."""
|
||||
runs = [R.load(pack, rid) for rid in R.list_runs(pack)]
|
||||
dig = digest(pack)
|
||||
now = _evidence(pack, dig, runs)
|
||||
stale_from = None
|
||||
if now["level"] == "draft":
|
||||
# a higher level reached at an earlier revision means the maturity has expired
|
||||
older = {r.data.get("pack_digest") for r in runs} | {rv.get("digest") for rv in _reviews(pack)}
|
||||
best = "draft"
|
||||
for d in older - {dig, None}:
|
||||
lv = _evidence(pack, d, runs)["level"]
|
||||
if LEVELS.index(lv) > LEVELS.index(best):
|
||||
best = lv
|
||||
if best != "draft":
|
||||
stale_from = best
|
||||
return {"level": "stale" if stale_from else now["level"], "stale_from": stale_from, "digest": dig, "evidence": now}
|
||||
22
rtut/pack.py
22
rtut/pack.py
|
|
@ -135,3 +135,25 @@ def render(command: str, values: dict[str, str], pack: Pack) -> str:
|
|||
return val
|
||||
|
||||
return PLACEHOLDER.sub(sub, command)
|
||||
|
||||
|
||||
REVIEW_FILE = "REVIEWS.yaml"
|
||||
|
||||
|
||||
def digest(pack: "Pack") -> str:
|
||||
"""Revision of a pack: hash of every file in its directory, except review records and local state.
|
||||
|
||||
`exercise_status:` lines are ignored, so recording an exercise never changes the revision.
|
||||
"""
|
||||
import hashlib
|
||||
h = hashlib.sha256()
|
||||
root = pack.path.resolve().parent
|
||||
for f in sorted(root.rglob("*")):
|
||||
rel = f.relative_to(root)
|
||||
if not f.is_file() or f.name == REVIEW_FILE or ".local" in rel.parts or "__pycache__" in rel.parts:
|
||||
continue
|
||||
data = f.read_bytes()
|
||||
if f.name == "pack.yaml" or f.name.startswith("pack."):
|
||||
data = b"\n".join(x for x in data.split(b"\n") if not x.startswith(b"exercise_status:"))
|
||||
h.update(str(rel).encode() + b"\0" + data + b"\0")
|
||||
return h.hexdigest()[:16]
|
||||
|
|
|
|||
244
rtut/project.py
Normal file
244
rtut/project.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Projects: runbooks that share an environment, with layered attributes, derived state and templates."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from . import maturity as _m # noqa: F401 (kept importable for callers)
|
||||
from . import run as R
|
||||
from .attrs import Doc, Registry, check_value, fold, normalize, set_layers
|
||||
from .pack import digest
|
||||
|
||||
PROJECT_STATES = ("planned", "applying", "applied", "stale")
|
||||
|
||||
|
||||
# ---- validation --------------------------------------------------------------------------------------
|
||||
|
||||
def validate(reg: Registry, doc: Doc) -> list[str]:
|
||||
"""Structural errors plus references that must resolve in the registry."""
|
||||
if not doc.ok:
|
||||
return list(doc.errors)
|
||||
d, errs = doc.data, []
|
||||
for s in d.get("attribute_sets", []):
|
||||
try:
|
||||
set_layers(reg, s)
|
||||
except ValueError as e:
|
||||
errs.append(str(e))
|
||||
targets = d.get("targets", {})
|
||||
for tid, t in targets.items():
|
||||
for s in t.get("attribute_sets", []):
|
||||
try:
|
||||
set_layers(reg, s)
|
||||
except ValueError as e:
|
||||
errs.append(f"target {tid}: {e}")
|
||||
packs = [m["pack"] for m in d["runbooks"]]
|
||||
keys = [(m["pack"], m.get("target")) for m in d["runbooks"]]
|
||||
if len(keys) != len(set(keys)):
|
||||
errs.append("a runbook appears twice for the same target")
|
||||
for m in d["runbooks"]:
|
||||
if m["pack"] not in reg.packs:
|
||||
errs.append(f"runbook {m['pack']} not found")
|
||||
elif not reg.packs[m["pack"]].ok:
|
||||
errs.append(f"runbook {m['pack']} is invalid")
|
||||
if m.get("target") and m["target"] not in targets:
|
||||
errs.append(f"runbook {m['pack']}: unknown target {m['target']}")
|
||||
for a in m.get("after", []):
|
||||
if a not in packs:
|
||||
errs.append(f"runbook {m['pack']}: after names {a}, which is not a member")
|
||||
# acyclic member order
|
||||
deps = {m["pack"]: set(m.get("after", [])) for m in d["runbooks"]}
|
||||
state: dict[str, int] = {}
|
||||
|
||||
def visit(n: str, path: list[str]) -> None:
|
||||
if state.get(n) == 1:
|
||||
errs.append("member order cycle: " + " -> ".join(path[path.index(n):] + [n])); return
|
||||
if state.get(n) == 2 or n not in deps:
|
||||
return
|
||||
state[n] = 1
|
||||
for x in sorted(deps[n]):
|
||||
visit(x, path + [n])
|
||||
state[n] = 2
|
||||
|
||||
for n in sorted(deps):
|
||||
visit(n, [])
|
||||
return sorted(set(errs))
|
||||
|
||||
|
||||
# ---- attribute resolution --------------------------------------------------------------------------
|
||||
|
||||
def _member(d: dict, pack_id: str, target: str | None = None) -> dict:
|
||||
for m in d["runbooks"]:
|
||||
if m["pack"] == pack_id and (target is None or m.get("target") == target):
|
||||
return m
|
||||
raise KeyError(pack_id)
|
||||
|
||||
|
||||
def layers_for(reg: Registry, d: dict, member: dict) -> list:
|
||||
layers: list = []
|
||||
for s in d.get("attribute_sets", []):
|
||||
layers += set_layers(reg, s)
|
||||
layers.append(("project", normalize(d.get("attributes"))))
|
||||
t = d.get("targets", {}).get(member.get("target") or "", None)
|
||||
if t:
|
||||
for s in t.get("attribute_sets", []):
|
||||
layers += set_layers(reg, s)
|
||||
layers.append((f"target:{member['target']}", normalize(t.get("attributes"))))
|
||||
layers.append((f"member:{member['pack']}", normalize(member.get("attributes"))))
|
||||
return layers
|
||||
|
||||
|
||||
def member_attributes(reg: Registry, d: dict, member: dict) -> dict:
|
||||
"""What the member runbook's inputs look like in this project.
|
||||
|
||||
{'rows': [{id, label, value, state, requirement, source, modified, error?}], 'unused': [names]}
|
||||
state: nil (no value), set, err (invalid for the runbook). requirement: required unless the attribute
|
||||
says optional or the runbook itself has a default.
|
||||
"""
|
||||
pack = reg.packs[member["pack"]]
|
||||
folded = fold(layers_for(reg, d, member))
|
||||
defs = [x for x in (pack.data.get("datapoints") or pack.data.get("parameters") or []) if not x.get("produced_by")]
|
||||
rows = []
|
||||
for x in defs:
|
||||
a = folded.get(x["id"], {"value": None, "requirement": None, "source": None, "modified": False})
|
||||
value = a["value"]
|
||||
row = {"id": x["id"], "label": x.get("label", x["id"]), "value": value, "source": a["source"],
|
||||
"modified": a["modified"], "requirement": a["requirement"] or ("optional" if "default" in x else "required")}
|
||||
if value is None:
|
||||
row["state"] = "nil"
|
||||
if "default" in x:
|
||||
row["default"] = x["default"]
|
||||
else:
|
||||
err = check_value(pack, x["id"], value)
|
||||
row["state"] = "err" if err else "set"
|
||||
if err:
|
||||
row["error"] = err
|
||||
rows.append(row)
|
||||
known = {x["id"] for x in defs}
|
||||
return {"rows": rows, "unused": sorted(n for n in folded if n not in known)}
|
||||
|
||||
|
||||
def missing_required(reg: Registry, d: dict) -> list[dict]:
|
||||
"""Attributes some member still needs: required, no value, no runbook default."""
|
||||
out = []
|
||||
for m in d["runbooks"]:
|
||||
if m["pack"] not in reg.packs:
|
||||
continue
|
||||
for r in member_attributes(reg, d, m)["rows"]:
|
||||
if r["state"] == "nil" and r["requirement"] == "required" and "default" not in r:
|
||||
out.append({"pack": m["pack"], "attribute": r["id"], "label": r["label"]})
|
||||
return out
|
||||
|
||||
|
||||
def member_values(reg: Registry, d: dict, member: dict) -> dict[str, str]:
|
||||
"""Values to hand to a run: every set attribute (invalid ones are left out and reported as err)."""
|
||||
return {r["id"]: (str(r["value"]).lower() if isinstance(r["value"], bool) else str(r["value"]))
|
||||
for r in member_attributes(reg, d, member)["rows"] if r["state"] == "set"}
|
||||
|
||||
|
||||
# ---- state ---------------------------------------------------------------------------------------------
|
||||
|
||||
def _latest_run(pack, project_id: str, target: str | None):
|
||||
best = None
|
||||
for rid in R.list_runs(pack):
|
||||
r = R.load(pack, rid)
|
||||
ctx = r.data.get("context", {})
|
||||
if ctx.get("project") == project_id and ctx.get("target") == target:
|
||||
if best is None or r.data["created"] >= best.data["created"]:
|
||||
best = r
|
||||
return best
|
||||
|
||||
|
||||
def member_status(reg: Registry, d: dict, member: dict) -> dict:
|
||||
"""not-applied | applying | applied | stale | failed, from the latest run at the current pack revision."""
|
||||
pack = reg.packs[member["pack"]]
|
||||
r = _latest_run(pack, d["id"], member.get("target"))
|
||||
if r is None:
|
||||
return {"pack": member["pack"], "target": member.get("target"), "status": "not-applied", "run": None}
|
||||
eff = R.effective_outcome(r)
|
||||
current = r.data.get("pack_digest") == digest(pack)
|
||||
if eff == "succeeded":
|
||||
status = "applied" if current else "stale"
|
||||
elif eff in ("failed", "abandoned"):
|
||||
status = "failed"
|
||||
else:
|
||||
status = "applying"
|
||||
return {"pack": member["pack"], "target": member.get("target"), "status": status, "run": r.data["run_id"],
|
||||
"deviations": len(r.data.get("deviations", [])), "current_revision": current}
|
||||
|
||||
|
||||
def state(reg: Registry, doc: Doc) -> dict:
|
||||
"""Project state: planned | applying | applied | stale, with per-member status and what can start next."""
|
||||
d = doc.data
|
||||
members = [member_status(reg, d, m) for m in d["runbooks"]]
|
||||
st = [m["status"] for m in members]
|
||||
if "stale" in st:
|
||||
project = "stale"
|
||||
elif all(x == "applied" for x in st):
|
||||
project = "applied"
|
||||
elif all(x == "not-applied" for x in st):
|
||||
project = "planned"
|
||||
else:
|
||||
project = "applying"
|
||||
applied = {m["pack"] for m in members if m["status"] == "applied"}
|
||||
nxt = [m["pack"] for m, spec in zip(members, d["runbooks"])
|
||||
if m["status"] in ("not-applied", "failed", "stale") and set(spec.get("after", [])) <= applied]
|
||||
return {"state": project, "members": members, "next": nxt, "needs": missing_required(reg, d)}
|
||||
|
||||
|
||||
# ---- runs from a project ------------------------------------------------------------------------------------
|
||||
|
||||
def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str | None = None, mode: str = "manual"):
|
||||
"""Create a run of a member runbook with the project's attributes as defaults and the project as context."""
|
||||
d = doc.data
|
||||
if d["spec"] != "project/v0.1":
|
||||
raise R.RunError("a template is not run; instantiate it into a project first")
|
||||
errs = validate(reg, doc)
|
||||
if errs:
|
||||
raise R.RunError(f"project {doc.id} is invalid: {errs[0]}")
|
||||
member = _member(d, pack_id, target)
|
||||
pack = reg.packs[pack_id]
|
||||
ctx = {"project": doc.id, "environment": d["environment"], "target": member.get("target")}
|
||||
values = member_values(reg, d, member)
|
||||
if pack.spec == "runbook-pack/v0.2":
|
||||
from . import run2
|
||||
return run2.create(pack, operator, values, mode=mode, context=ctx)
|
||||
if mode != "manual":
|
||||
raise R.RunError("v0.1 packs run in manual mode only")
|
||||
return R.create(pack, operator, values, ctx)
|
||||
|
||||
|
||||
# ---- 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."""
|
||||
t = reg.templates.get(template_id)
|
||||
if t is None or not t.ok:
|
||||
raise ValueError(f"no valid template {template_id}")
|
||||
if not re.fullmatch(r"[a-z0-9]+([.-][a-z0-9]+)*", project_id):
|
||||
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")}
|
||||
attrs = normalize(d.get("attributes"))
|
||||
for k, v in (values or {}).items():
|
||||
attrs.setdefault(k, {"value": None, "requirement": None})
|
||||
attrs[k]["value"] = v
|
||||
out_attrs = {}
|
||||
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 out_attrs:
|
||||
project["attributes"] = out_attrs
|
||||
home = Path(root) if root else t.path.parent.parent
|
||||
target = home / "projects" / project_id / "project.yaml"
|
||||
if target.exists():
|
||||
raise ValueError(f"{target} already exists")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(yaml.safe_dump(project, sort_keys=False))
|
||||
return target
|
||||
33
rtut/run.py
33
rtut/run.py
|
|
@ -11,7 +11,7 @@ from datetime import date, datetime, timezone
|
|||
from pathlib import Path
|
||||
|
||||
from .legacy import repo_root
|
||||
from .pack import PLACEHOLDER, SECRET_RE, Pack, render
|
||||
from .pack import PLACEHOLDER, SECRET_RE, Pack, digest, render
|
||||
|
||||
STATES = ("todo", "running", "done", "failed", "skipped")
|
||||
VERIFY_TIMEOUT = 60
|
||||
|
|
@ -22,7 +22,7 @@ class RunError(Exception):
|
|||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return datetime.now(timezone.utc).isoformat(timespec="microseconds") # sortable even within one second
|
||||
|
||||
|
||||
def _clean(value: str, what: str) -> str:
|
||||
|
|
@ -69,12 +69,23 @@ def _writable(run: Run) -> None:
|
|||
raise RunError(f"run is finished (outcome: {run.data['outcome']}); start a new run")
|
||||
|
||||
|
||||
def create(pack: Pack, operator: str, params: dict[str, str] | None = None) -> Run:
|
||||
ENVIRONMENTS = ("rehearsal", "staging", "production")
|
||||
|
||||
|
||||
def _context(ctx: dict | None) -> dict:
|
||||
"""Where a run applies: project id, target and environment class. Non-secret; all optional."""
|
||||
ctx = dict(ctx or {})
|
||||
if ctx.get("environment") and ctx["environment"] not in ENVIRONMENTS:
|
||||
raise RunError(f"environment must be one of {', '.join(ENVIRONMENTS)}")
|
||||
return {k: _clean(str(v), f"context {k}") for k, v in ctx.items() if v and k in ("project", "target", "environment")}
|
||||
|
||||
|
||||
def create(pack: Pack, operator: str, params: dict[str, str] | None = None, context: dict | None = None) -> Run:
|
||||
if not pack.ok or pack.data.get("engine") != "native":
|
||||
raise RunError("only valid native packs can be run")
|
||||
if pack.spec == "runbook-pack/v0.2":
|
||||
from . import run2
|
||||
return run2.create(pack, operator, params)
|
||||
return run2.create(pack, operator, params, context=context)
|
||||
if pack.spec != "runbook-pack/v0.1":
|
||||
raise RunError(f"{pack.spec} packs cannot be run")
|
||||
operator = _clean(operator.strip(), "operator")
|
||||
|
|
@ -102,6 +113,8 @@ def create(pack: Pack, operator: str, params: dict[str, str] | None = None) -> R
|
|||
"created": _now(),
|
||||
"params": resolved,
|
||||
"steps": {s["id"]: {"state": "todo"} for s in pack.data["steps"]},
|
||||
"pack_digest": digest(pack),
|
||||
"context": _context(context),
|
||||
}
|
||||
run = Run(pack, data)
|
||||
run.save()
|
||||
|
|
@ -116,8 +129,18 @@ def load(pack: Pack, run_id: str) -> Run:
|
|||
|
||||
|
||||
def list_runs(pack: Pack) -> list[str]:
|
||||
"""Run ids of this pack. All packs of a repository share one run folder, so filter by pack id."""
|
||||
d = runs_dir(pack)
|
||||
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
|
||||
if not d.exists():
|
||||
return []
|
||||
out = []
|
||||
for f in sorted(d.glob("*.json")):
|
||||
try:
|
||||
if json.loads(f.read_text()).get("pack_id") == pack.id:
|
||||
out.append(f.stem)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _order(run: Run, step_id: str) -> None:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from .flow import Flow
|
||||
from .pack import Pack, render
|
||||
from .run import Run, RunError, _clean, _now, runs_dir # noqa: F401 (re-exported for callers)
|
||||
from .pack import Pack, digest, render
|
||||
from .run import Run, RunError, _clean, _context, _now, runs_dir # noqa: F401 (re-exported for callers)
|
||||
|
||||
DEFAULT_PHRASE = "I understand this cannot be undone"
|
||||
VERIFY_TIMEOUT = 60
|
||||
|
|
@ -84,7 +84,7 @@ def _confirm_datapoints(run: Run, by: str, how: str) -> None:
|
|||
# ---- creation -------------------------------------------------------------------------------------
|
||||
|
||||
def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode: str = "manual",
|
||||
preauthorize: list[str] | None = None) -> Run:
|
||||
preauthorize: list[str] | None = None, context: dict | None = None) -> Run:
|
||||
if not pack.ok or pack.data.get("engine") != "native" or pack.spec != "runbook-pack/v0.2":
|
||||
raise RunError("only valid native runbook-pack/v0.2 packs can be run here")
|
||||
if mode not in ("manual", "auto-confirm"):
|
||||
|
|
@ -104,6 +104,7 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
|
|||
"spec": "runbook-run/v0.2", "run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
|
||||
"pack_id": pack.id, "pack_path": str(pack.path), "operator": operator, "created": _now(),
|
||||
"mode": mode, "params": {}, "deviations": [],
|
||||
"pack_digest": digest(pack), "context": _context(context),
|
||||
"auto_confirm": {"preauthorized": pre, "authorized_by": operator if pre else None},
|
||||
"datapoints": {i: {"state": "nil"} for i in fl.datapoints},
|
||||
"steps": {i: {"state": "todo"} for i in fl.steps},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue