diff --git a/CHANGELOG.md b/CHANGELOG.md index 7311cb5..a93e392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## The test record: an image move must carry its proof (2026-09-23 night, `09` §6.4 part 4 + the catalog half of part 6) + +**No `image:` line moved in this commit.** 21 templates gain an `update_ladder:` (backfill); scripts only otherwise. + +- **Format** (`scripts/ladder.py`): `update_ladder:` at the end of `.felhom.yml`, one JSON flow mapping + per line — `from`/`to` per service, `digest` per `to` ref, `verdict` (proven | unrecorded), `tested_at`, + `harness_version`, `evidence`, `memory_peak_pct`, `marks` {files_may_change, needs_person, + memory_tight}, optional `backfilled`. **Spiked live first:** controller v0.266.0 and v0.267.0 on scratch + guest 9202 synced, deployed, probed and badged navidrome with the block exactly as without it. +- **Gates** (rows 8 and 9 of `catalog_gates.py`, both in `--fast`): `check-test-record.py` (static, runs in + CI) and `check-test-record-move.py` (history; the registry is asked ONLY for refs a range moves — an + unreachable registry is INCONCLUSIVE, never a pass). 16 decoy cases in `test_gate_decoys.py` (both + directions); three red-proofs seen failing (bare move, a `failed` entry, a digest mismatch). +- **The writer**: `upgrade-test.py --write-ladder` (bench AND box `proven`, template at FROM, digests + resolved, memory peak as a percent) — `test_ladder_writer.py`. `--move =` builds an edge + from the template. Harness v3: the box walk's fixtures run on the bench (`upgrade_boxport.py`, + `upgrade_fixtures_box*.py` ported verbatim — R-462), and a bind-mount tree hash sets `files_may_change`. +- **`scripts/image_digest.py`** resolves a ref's digest (stdlib only); positive control: it equals the + `RepoDigests` Docker recorded for `privatebin/pdo:2.0.6` on 9202. +- **Backfill** (`ladder_backfill.py`): the 21 moves of 2026-09-22, each from the record its commit cited — + **21 proven, 0 unrecorded** (nextcloud's commit cited none; its record `nextcloud-engine-mariadb` was + named and the entry says so). romm carries its memory watch (M1, 80.9 %, `memory_tight`). Digests are + what the registry serves TODAY, and each entry says that. +- `test_catalog_gates.py`: its table test had been stale (asserted 5 gates while there were 7); now 9. + ## The upgrade harness watches memory after the readback — the RomM lesson (2026-09-23, R-635/R-462) **Test code only. No template changed; no `image:` line moved.** `scripts/upgrade-test.py` (harness diff --git a/CLAUDE.md b/CLAUDE.md index f76b9f5..51db2bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,14 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details `.githooks/pre-push` with the push range; decoys in `scripts/test_gate_decoys.py`. **It needs a parent commit**, and the CI runner fetches at `--depth 1` (the same gap as R-452 — not re-filed), so on a shallow clone the runner skips it out loud; the hook is where it bites. +- **An `image:` move needs its TEST RECORD (night 2026-09-23, `09` §3 decision 13).** `.felhom.yml` carries + `update_ladder:` — one JSON entry per line, one per tested step (`scripts/ladder.py` documents the + fields). **Written only by `scripts/upgrade-test.py --write-ladder`, never by hand**: it refuses unless + the bench AND the box walk both say `proven`, resolves each ref's digest, moves the compose and sets + `catalog_since`. Two gates: `check-test-record.py` (static — every ladder well-formed and its newest step + IS the compose; runs in CI too) and `check-test-record-move.py` (history + the registry for MOVED refs + only — a move must add a proven entry whose digests the registry still serves; the hook). The 21 moves + of 2026-09-22 carry backfilled entries citing their records (`ladder_backfill.py`, one-off). - **Taking an app out of circulation — use `lifecycle:`, never a directory move.** `.felhom.yml` gains an optional `lifecycle:` field: `available` (default; absent/empty means this), `hidden` (not offered for new installs, no explanation owed), `abandoned` (upstream stopped developing it — diff --git a/REPORT.md b/REPORT.md index 7105a51..ee8ee0e 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,25 +1,22 @@ -# REPORT — the upgrade harness watches memory (2026-09-23) +# REPORT — the test record and its gate (night 2026-09-23, Part B) -**Test code only.** No template was changed; no `image:` line moved; the diff touches exactly -`scripts/upgrade-test.py`, `scripts/upgrade_fixtures.py`, `CHANGELOG.md` and this file. +`09-update-architecture.md` §3 decision 13, §6.4 part 4 and the catalog half of part 6. Night record: +`felhom.eu/documentation/audits/DRILL-night-2026-09-23.md`; evidence `…/night-2026-09-23/B*`. -## What was done +## Not done, or changed +- The brief's "`check-image-resolvable.py` already resolves digests" is only half true: it asks `docker + manifest inspect` whether a ref EXISTS and discards the digest. The digest comes from the new + `image_digest.py`, whose answer equals Docker's `RepoDigests` on a box (positive control). +- The move gate uses the network in the hook — for moved refs only. Decided by CC unattended (it is the + only way a push-time "digest matches the registry now" can be asked); operator may reverse. +- Backfilled digests are TODAY's registry answer, not a measurement of the image that was tested. +- Step definitions for intermediate steps (`steps/.yml`, part 5) are not written — no app has two + steps yet. -The RomM lesson (R-635): a walk proves "the update applied and the data survived", not "the new -version runs". `upgrade-test.py` v2 adds a **memory watch** after a successful readback — `--soak` -seconds (default 600) of light load, sampling the kernel's `oom_kill` counter host-side, the peak -against the compose limit, and restarts. Kill or restart → `failed`; peak > 80 % → mark -`memory_tight`. New `Romm` fixture and edges `M1` / `M1old`. +## What shipped +Format + spike, two gates (16 decoys, 3 red-proofs), the writer (5 tests), harness v3 (box fixtures on +the bench; files_may_change), `image_digest.py`, the backfill (21 proven). -## Red-proof (scratch guest 9202, `/opt/upg`, removed afterwards) - -| edge | template | verdict | memory | -|---|---|---|---| -| **M1old** | as promoted (`15f9ebf`): 512M, 4 workers | **failed** | first OOM kill at **+76 s**, peak 100 % of 512 MiB, restarts 0 | -| **M1** | current: 768M, 2 workers | **proven** + mark `memory_tight` | 608.5 s under 11 429 requests (5 712 × 200, 5 717 × 401): **0 kernel OOM kills, 0 restarts**, peak 621 MiB = **81 %** of 768 MiB — the watch passes the fix and still flags the thin headroom R-635 left open | - -`C3` (the standing negative control) was not run: its `container_name: privatebin` collides with the -privatebin the controller runs on 9202. The M1old/M1 pair is this step's own control. - -Gates: `python3 scripts/catalog_gates.py --fast` → all OK. -Full session report: `felhom.eu/REPORT.md`; evidence `felhom.eu/documentation/audits/update-rulings-2026-09-23/`. +## Gates +`catalog_gates.py --fast` all OK; `test_gate_decoys.py` 80 cases OK; `test_ladder_writer.py` OK; +`test_catalog_gates.py` OK after this commit (its shallow-clone case needs the new scripts committed). diff --git a/REUSE.md b/REUSE.md index 28459a7..1096275 100644 --- a/REUSE.md +++ b/REUSE.md @@ -6,7 +6,13 @@ ## 1. Canonical helpers -None — this repo is templates/config, not code. See §2/§5. +Templates are config; the few script helpers other scripts must REUSE, never re-implement: + +- `scripts/ladder.py` — the test record (`update_ladder:` in `.felhom.yml`): `parse`, `check_entry`, + `images_in` (the per-service image reading every gate makes), `append_entry`. One JSON entry per line. +- `scripts/image_digest.py` — `resolve(ref)` → the digest the registry serves now (the one Docker + records in `RepoDigests`). stdlib only — the CI runner has no `requests`/PyYAML. +- `scripts/upgrade_boxport.py` — runs the box walk's fixtures (`upgrade_fixtures_box*.py`) on the bench. ## 2. Canonical patterns (copy structure from THE named file) @@ -55,6 +61,9 @@ None — this repo is templates/config, not code. See §2/§5. 3. Update `README.md` App Catalog + Variable-types tables (convention — every existing app is listed). 4. Skip `templates.json` / `generate-customer.sh` (legacy, §3). 5. Email-capable app: add `smtp_mapping` + matching `${VAR:-}` compose lines (§2 last row). +6. **Moving an existing app's `image:`** is not an edit: run `scripts/upgrade-test.py --move =` + on the bench, walk it on a scratch box, then `upgrade-test.py --write-ladder …` writes the compose move, + `catalog_since` and the ladder entry. `check-test-record-move.py` refuses a move without it (`09` decision 13). ## 6. Known inconsistencies (observed — NOT fixed) diff --git a/scripts/catalog_gates.py b/scripts/catalog_gates.py index 7379558..28c0fcc 100644 --- a/scripts/catalog_gates.py +++ b/scripts/catalog_gates.py @@ -19,6 +19,10 @@ Gates, in order (all must pass; **non-zero exit on any failure**): 7. probe-matches-compose static, instant, whole repo — the .felhom.yml health probe dials the port/path the app's own compose healthcheck dials (R-618). Runs in the hook: a wrong probe stops a WORKING app at the end of a successful update. + 8. test-record static, instant, whole repo — every update_ladder is well-formed, continuous, + and its newest step IS the compose's images (runs in CI too) + 9. test-record-move git history + the registry for MOVED refs only — an image move adds a PROVEN + ladder entry whose digests the registry still serves (hook; skipped on CI) 4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version (operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at @@ -93,6 +97,13 @@ GATES = [ # defect it catches does not merely mis-colour a badge, it makes a SUCCESSFUL update stop a # working app (the `verifying` phase waits on this probe), so it must bite at push time. ("probe-matches-compose", "check-probe-matches-compose.py", True, True, False), + # 2026-09-23 (`09` §3 decision 13, §6.4 part 4): THE TEST RECORD. The static half needs no + # history and no network, so it bites in CI too: a ladder must be well-formed and its newest step + # must BE the compose's images. The move half needs history (skipped out loud on CI's shallow + # clone, like engine-major) and asks the registry ONLY for refs that moved in the range: an image + # move must add a PROVEN entry whose digests the registry still serves. + ("test-record", "check-test-record.py", True, True, False), + ("test-record-move", "check-test-record-move.py", False, True, True), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} diff --git a/scripts/check-test-record-move.py b/scripts/check-test-record-move.py new file mode 100644 index 0000000..04d5e4d --- /dev/null +++ b/scripts/check-test-record-move.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check-test-record-move.py — catalog gate: an `image:` move must bring its own PROVEN test record. + + python3 scripts/check-test-record-move.py # diff origin/main..HEAD + python3 scripts/check-test-record-move.py --range .. # what .githooks/pre-push passes + python3 scripts/check-test-record-move.py --no-network ... # skip the registry comparison + # (tests only; says so) + python3 scripts/check-test-record-move.py --digests-from F.json # the "registry" is this file + # {ref: digest} (decoy tests; says so) + +`09-update-architecture.md` §3 decision 13: the catalog holds only tested steps, and an image move +with no test record is refused HERE, at push time. Night 2026-09-23 (§6.4 part 4). + +For every template whose per-service images differ between A and B, the `.felhom.yml` at B must +carry, in an `update_ladder:` line that was NOT there at A, an entry that + - is well-formed (ladder.check_entry) and NOT `backfilled` (a backfill cites an old record; a new + move needs a new test), + - has `verdict: "proven"`, + - has `from` equal to the images at A and `to` equal to the images at B, service by service, + - carries digests that the registry STILL serves for those refs right now (decision 17). A digest + that moved since the test means the image that was tested is not the image a box would pull; + - and, when the entry is marked `memory_tight`, the same range changes that app's memory limit + (`09` RomM follow-up: a version move re-checks the limit — this is that check as a gate). + +THE NETWORK, and why this "fast" gate uses it. The hook runs `--fast` gates only, and until tonight +fast meant "no network". This gate resolves ONLY the refs of templates whose images moved in the +range — zero requests on a push that moves nothing, a handful on a move. A registry that cannot be +asked is INCONCLUSIVE (exit 2), which the runner reports as never-a-pass: a move is refused until +the digest has been compared. Decided by CC unattended 2026-09-23 — operator may reverse. + +SHALLOW CLONES: needs two commits, so on CI's `--depth 1` clone it is skipped out loud by +catalog_gates.py; its static twin `check-test-record.py` still runs there and catches a compose +that no longer matches its ladder's head. Exit 0 clean · 1 convicted · 2 inconclusive. +""" +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ladder # noqa: E402 + +TEMPLATE_RE = re.compile(r"^templates/([^/]+)/docker-compose\.ya?ml$") +ZERO_SHA_RE = re.compile(r"^0{40}$") +MEM_RE = re.compile(r"^\s+(memory|mem_limit):", re.M) + + +def git(*args): + p = subprocess.run(["git"] + list(args), capture_output=True, text=True) + return p.returncode, p.stdout, p.stderr + + +def resolve_range(spec): + if not spec or ".." not in spec: + return None, None, "range must be .. (got %r)" % spec + a, b = spec.split("..", 1) + if ZERO_SHA_RE.match(a): + a = "origin/main" + for r in (a, b): + rc, _, err = git("rev-parse", "--verify", "-q", r + "^{commit}") + if rc != 0: + return None, None, "cannot resolve %r (%s)" % (r, err.strip() or "not a commit") + return a, b, "" + + +def show(rev, path): + rc, out, _ = git("show", "%s:%s" % (rev, path)) + return out if rc == 0 else None + + +def mem_lines(text): + return sorted(l.strip() for l in (text or "").splitlines() if MEM_RE.match(l)) + + +def default_resolver(ref): + import image_digest + return image_digest.resolve(ref) + + +def judge_app(app, a, b, resolver, network=True): + """(problems, inconclusive) for one template whose compose changed in A..B.""" + cpath, fpath = "templates/%s/docker-compose.yml" % app, "templates/%s/.felhom.yml" % app + before_c, after_c = show(a, cpath), show(b, cpath) + if after_c is None: + return [], [] # removed at B: nothing is offered + before = ladder.images_in(before_c) if before_c is not None else {} + after = ladder.images_in(after_c) + if before == after: + return [], [] # the compose changed, but no image moved + if before_c is None: + return [], [] # a NEW template: its first images are not a move (the app is tested before it is listed) + new_entries = [] + _e_a, raws_a, _ = ladder.parse(show(a, fpath) or "") + ents_b, raws_b, errs_b = ladder.parse(show(b, fpath) or "") + problems, inconclusive = [], [] + for err in errs_b: + problems.append(err) + old = set(raws_a) + for e, raw in zip(ents_b, raws_b): + if raw not in old: + new_entries.append(e) + moved = sorted(s for s in after if before.get(s) != after[s]) + if not new_entries: + problems.append("images moved (%s) but this range adds NO update_ladder entry — an image move " + "needs its test record (decision 13); run the harness and let it write the entry" + % ", ".join("%s: %s -> %s" % (s, before.get(s), after[s]) for s in moved)) + return problems, inconclusive + match = [e for e in new_entries if e.get("to") == after] + if not match: + problems.append("the new ladder entry names other refs than the compose now carries: compose %s" + % after) + return problems, inconclusive + e = match[-1] + for p in ladder.check_entry(e): + problems.append("new entry: " + p) + if e.get("backfilled") is not None: + problems.append("new entry is marked backfilled — a new move needs a new test, not an old record") + if e.get("verdict") != "proven": + problems.append("new entry's verdict is %r — only a PROVEN step may be published" % e.get("verdict")) + if e.get("from") != before: + problems.append("new entry's `from` %s is not the compose before the move %s" % (e.get("from"), before)) + if (e.get("marks") or {}).get("memory_tight"): + if mem_lines(before_c) == mem_lines(after_c) and mem_lines(show(a, fpath)) == mem_lines(show(b, fpath)): + problems.append("the entry is memory_tight (peak %s%%) and this range does not change the memory " + "limit — raise it and re-run the memory watch (RomM follow-up)" % e.get("memory_peak_pct")) + if problems: + return problems, inconclusive + if not network: + print(" %s: digest comparison SKIPPED (--no-network) — not a pass for a real push" % app) + return problems, inconclusive + for svc, ref in sorted(after.items()): + want = (e.get("digest") or {}).get(svc) + got, why = resolver(ref) + if got is None: + inconclusive.append("%s %s: the registry could not be asked (%s)" % (svc, ref, why)) + elif got != want: + problems.append("%s %s: the registry serves %s, the test record says %s — the image that was " + "tested is not the image a box would pull" % (svc, ref, got, want)) + return problems, inconclusive + + +def main(argv, resolver=None): + spec = "origin/main..HEAD" + network = "--no-network" not in argv + for i, arg in enumerate(argv): + if arg.startswith("--range="): + spec = arg[len("--range="):] + elif arg == "--range" and i + 1 < len(argv): + spec = argv[i + 1] + rc, shallow, _ = git("rev-parse", "--is-shallow-repository") + if rc == 0 and shallow.strip() == "true": + print("TEST-RECORD-MOVE GATE INCONCLUSIVE: this clone is SHALLOW — no parent commit to diff " + "(the R-452 gap). Enforced by the pre-push hook on the full clone; the static twin " + "check-test-record.py still ran.") + return 2 + a, b, why = resolve_range(spec) + if a is None: + print("TEST-RECORD-MOVE GATE INCONCLUSIVE: %s" % why) + return 2 + rc, names, err = git("diff", "--name-only", a, b, "--", "templates") + if rc != 0: + print("TEST-RECORD-MOVE GATE INCONCLUSIVE: git diff failed: %s" % err.strip()) + return 2 + apps = sorted({TEMPLATE_RE.match(n).group(1) for n in names.split("\n") if TEMPLATE_RE.match(n)}) + for i, arg in enumerate(argv): + if arg == "--digests-from" and i + 1 < len(argv): + import json + table = json.load(open(argv[i + 1])) + print(" registry answers come from %s, NOT the registry (decoy tests only)" % argv[i + 1]) + resolver = lambda ref, _t=table: ((_t[ref], None) if _t.get(ref) else (None, "not in the table")) + resolver = resolver or default_resolver + convicted, undecided = {}, {} + for app in apps: + p, inc = judge_app(app, a, b, resolver, network) + if p: + convicted[app] = p + if inc: + undecided[app] = inc + print("test-record-move gate — range %s..%s: %d compose file(s) changed" % (a, b, len(apps))) + for app, ps in convicted.items(): + for p in ps: + print("REFUSED %s: %s" % (app, p)) + for app, ps in undecided.items(): + for p in ps: + print("INCONCLUSIVE %s: %s" % (app, p)) + if convicted: + return 1 + if undecided: + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/check-test-record.py b/scripts/check-test-record.py new file mode 100644 index 0000000..9c700be --- /dev/null +++ b/scripts/check-test-record.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check-test-record.py — catalog gate: every ladder is well-formed and agrees with its compose. + + python3 scripts/check-test-record.py # every template + python3 scripts/check-test-record.py navidrome romm # only these + python3 scripts/check-test-record.py --root DIR ... # another checkout (decoy tests) + +`09-update-architecture.md` §3 decision 13 (the test decides, not the tag) and §6.4 part 4. This is +the STATIC half: no git history, no network — so it runs in the pre-push hook AND in CI, whose clone +is shallow (the R-452 gap that skips every history gate there). Its twin +`check-test-record-move.py` is the half that needs history: an image MOVE must add a proven entry. + +WHAT IT CHECKS, per template that carries `update_ladder:` (format and field rules: ladder.py): + 1. every line of the block is a one-line JSON entry, and every entry is well-formed + (verdict proven|unrecorded only; a sha256 digest per `to` service; the memory watch's peak on + any entry not backfilled; marks.memory_tight agrees with the peak); + 2. the ladder is CONTINUOUS — each entry's `from` is the previous entry's `to`; + 3. the NEWEST entry's `to` is EXACTLY the compose's current image per service. This is the fact + that makes the rule hold without history: a compose moved without a new entry no longer + matches its ladder's head, whoever pushed it and however. + +A template WITHOUT a ladder passes here — it has never been moved since the gate existed, and its +first move is refused by the twin unless that move brings the first entry. + +Exit 0 clean · 1 convicted · 2 inconclusive (nothing to read). +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ladder # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def check_app(app_dir): + """List of problem sentences for one template directory ([] = clean or no ladder).""" + fy = os.path.join(app_dir, ".felhom.yml") + comp = os.path.join(app_dir, "docker-compose.yml") + if not os.path.exists(fy) or not os.path.exists(comp): + return [] + entries, _raws, errors = ladder.parse(open(fy, encoding="utf-8").read()) + if not entries and not errors: + return [] + problems = list(errors) + for i, e in enumerate(entries): + for p in ladder.check_entry(e): + problems.append("entry %d: %s" % (i + 1, p)) + for i in range(1, len(entries)): + if entries[i].get("from") != entries[i - 1].get("to"): + problems.append("entry %d's `from` is not entry %d's `to` — the ladder has a gap" % (i + 1, i)) + if entries: + current = ladder.images_in(open(comp, encoding="utf-8").read()) + head = entries[-1].get("to") + if head != current: + diff = sorted(set(current.items()) ^ set((head or {}).items())) + problems.append("the compose's images are not the ladder's newest step — an image moved " + "without a test record, or the record names other refs: %s" % diff) + return problems + + +def main(argv): + root = ROOT + if "--root" in argv: + i = argv.index("--root") + root = argv[i + 1] + argv = argv[:i] + argv[i + 2:] + only = [a for a in argv if not a.startswith("-")] + tdir = os.path.join(root, "templates") + apps = sorted(only) if only else sorted(os.listdir(tdir)) + seen = with_ladder = 0 + convicted = [] + for app in apps: + d = os.path.join(tdir, app) + if not os.path.isdir(d): + print("test-record: no such template %r" % app) + return 2 + seen += 1 + text = open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read() if os.path.exists( + os.path.join(d, ".felhom.yml")) else "" + if "update_ladder:" in text: + with_ladder += 1 + probs = check_app(d) + if probs: + convicted.append(app) + for p in probs: + print("FAIL %s: %s" % (app, p)) + if seen == 0: + print("TEST-RECORD GATE INCONCLUSIVE: no template read") + return 2 + print("test-record gate — %d template(s) read, %d carry a ladder, %d convicted" + % (seen, with_ladder, len(convicted))) + return 1 if convicted else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/image_digest.py b/scripts/image_digest.py new file mode 100644 index 0000000..be8dd33 --- /dev/null +++ b/scripts/image_digest.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""image_digest.py — what digest does the registry serve for an image reference TODAY? + +`09` §3 decision 17 / §6.4 part 6: the catalog records each pin's digest at push time, so a box can +compare against it and pull that exact image. This is the one resolver both the harness (which writes +the digest into a ladder entry) and `check-test-record.py` (which compares it at push time) call, so +the two can never disagree about which digest a ref "is". + +The digest returned is the one Docker records in `RepoDigests` after a pull: the top-level manifest's +`Docker-Content-Digest` (an image INDEX for a multi-arch image, a single manifest otherwise), asked +for with every manifest media type accepted — the same content negotiation `docker pull` performs. + +Standard library only (urllib): the catalog CI runner carries python3 and git and nothing else, and +a resolver that needs `requests` is one that silently skips there. + + python3 scripts/image_digest.py postgres:16-alpine ghcr.io/diced/zipline:4.7.0 + +Exit 0 when every ref resolved; 2 when any could not be resolved (never 1 — this tool accuses +nothing, it only measures). +""" +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + +ACCEPT = ",".join([ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +]) +UA = "felhom-catalog-digest/1.0 (read-only)" + + +def split_ref(ref): + """'ghcr.io/a/b:1.2' -> ('ghcr.io', 'a/b', '1.2'). A digest suffix is dropped; Docker Hub + short names get `library/`.""" + ref = ref.split("@", 1)[0] + first = ref.split("/", 1)[0] + if "/" in ref and ("." in first or ":" in first or first == "localhost"): + host, rest = ref.split("/", 1) + else: + host, rest = "registry-1.docker.io", ref + if "/" not in rest: + rest = "library/" + rest + if ":" in rest.rsplit("/", 1)[-1]: + repo, tag = rest.rsplit(":", 1) + else: + repo, tag = rest, "latest" + if host == "docker.io": + host = "registry-1.docker.io" + return host, repo, tag + + +def _bearer(www_auth): + """Anonymous token from a `WWW-Authenticate: Bearer realm=…,service=…,scope=…` challenge.""" + parts = {} + for p in www_auth[len("Bearer "):].split(","): + if "=" in p: + k, v = p.split("=", 1) + parts[k.strip()] = v.strip().strip('"') + q = {k: parts[k] for k in ("service", "scope") if k in parts} + url = parts["realm"] + ("?" + urllib.parse.urlencode(q) if q else "") + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=30) as r: + j = json.load(r) + return j.get("token") or j.get("access_token") + + +def resolve(ref, timeout=30): + """(digest, None) or (None, why). Read-only: one HEAD, one token fetch at most.""" + host, repo, tag = split_ref(ref) + url = "https://%s/v2/%s/manifests/%s" % (host, repo, tag) + headers = {"Accept": ACCEPT, "User-Agent": UA} + for attempt in (1, 2): + req = urllib.request.Request(url, headers=headers, method="HEAD") + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + d = r.headers.get("Docker-Content-Digest") + if d and d.startswith("sha256:") and len(d) == 71: + return d, None + return None, "no Docker-Content-Digest header (HTTP %s)" % r.status + except urllib.error.HTTPError as e: + if e.code == 401 and attempt == 1 and "Bearer" in (e.headers.get("WWW-Authenticate") or ""): + try: + tok = _bearer(e.headers["WWW-Authenticate"]) + except Exception as te: # noqa: BLE001 — any failure here is "could not resolve" + return None, "token fetch failed: %s" % te + headers["Authorization"] = "Bearer " + tok + continue + return None, "HTTP %d" % e.code + except Exception as e: # noqa: BLE001 + return None, "%s: %s" % (type(e).__name__, e) + return None, "unauthorised after a token" + + +def main(argv): + worst = 0 + for ref in argv: + d, why = resolve(ref) + print("%s\t%s" % (ref, d or ("UNRESOLVED: " + why))) + if not d: + worst = 2 + return worst + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/ladder.py b/scripts/ladder.py new file mode 100644 index 0000000..35415c1 --- /dev/null +++ b/scripts/ladder.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""ladder.py — the test record (`update_ladder:`) in `.felhom.yml`: read it, check it, write it. + +`09-update-architecture.md` §3 decision 13 — *the test decides, not the tag*: the catalog holds only +tested steps, and an image move with no test record is refused at push time. §6.4 part 4 is the +build; part 5 (the box climbing the ladder) and the digest half of part 6 on the box are NOT this. + +THE FORMAT, chosen for the two readers it has (spiked live 2026-09-23 on controller v0.266.0 and +v0.267.0 — the controller ignores the unknown top-level key and deploys, probes and badges as before): + + update_ladder: + - {"from": {...}, "to": {...}, "digest": {...}, "verdict": "proven", ...} + +ONE JSON OBJECT PER LINE. JSON is a subset of YAML's flow style, so the controller's YAML parser +reads it; and the catalog CI runner has NO PyYAML (it carries python3 and git only), so the gate +reads it with `json.loads` — no parser that can be missing, no degraded mode. A line under +`update_ladder:` that is not exactly ` - {json}` is a conviction, never a skip. + +AN ENTRY (all keys required unless marked): + from, to {service: image ref} for EVERY service with an image: line, before / after + digest {service: "sha256:<64 hex>"} for every service in `to` — what the registry + served for that ref when the entry was written (decision 17) + verdict "proven" | "unrecorded" (backfill only: a live move with no record found) + tested_at RFC 3339, or null for "unrecorded" + harness_version int (2 = the memory watch), or null for "unrecorded" + evidence path of the verdict record(s), relative to the workspace root + memory_peak_pct the memory watch's worst container peak in % of its limit; null only on a + backfilled entry (harness v1 had no watch) + marks {"files_may_change": bool, "needs_person": null | "", "memory_tight": bool} + backfilled (optional) "YYYY-MM-DD" — written by the backfill from an EXISTING record, + never by a new test; a new move may not carry it + +Every path that reads or writes the format is here, so the gate and the writer cannot disagree. +""" +import json +import re + +LADDER_KEY_RE = re.compile(r"^update_ladder:\s*$") +ENTRY_RE = re.compile(r"^ - (\{.*\})\s*$") +SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") +IMAGE_RE = re.compile(r"^\s+image:\s*[\"']?([^\s\"'#]+)") +DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +VERDICTS = ("proven", "unrecorded") +MEMORY_TIGHT_PCT = 80.0 + + +def images_in(compose_text): + """{service: image} — per service, from that service's OWN `image:` line (the same reading + check-engine-major.py and check-catalog-since.py make).""" + out, cur = {}, None + for line in compose_text.splitlines(): + m = SERVICE_RE.match(line) + if m: + cur = m.group(1) + continue + mi = IMAGE_RE.match(line) + if mi and cur and cur not in out: + out[cur] = mi.group(1) + return out + + +def parse(felhom_text): + """(entries, raw_lines, errors). No ladder → ([], [], []). A malformed line is an ERROR.""" + lines = felhom_text.splitlines() + start = None + for i, l in enumerate(lines): + if LADDER_KEY_RE.match(l): + if start is not None: + return [], [], ["update_ladder: appears twice"] + start = i + if start is None: + return [], [], [] + entries, raws, errors = [], [], [] + for l in lines[start + 1:]: + if not l.strip() or l.lstrip().startswith("#"): + continue + if not l.startswith(" "): + break # the next top-level key: the block has ended + m = ENTRY_RE.match(l) + if not m: + errors.append("not a one-line JSON entry under update_ladder: %r" % l[:120]) + continue + try: + e = json.loads(m.group(1)) + except ValueError as ex: + errors.append("entry is not valid JSON (%s): %r" % (ex, l[:120])) + continue + if not isinstance(e, dict): + errors.append("entry is not an object: %r" % l[:120]) + continue + entries.append(e) + raws.append(m.group(1)) + if not entries and not errors: + errors.append("update_ladder: is present but holds no entry") + return entries, raws, errors + + +def check_entry(e): + """Problems with ONE entry's shape, as sentences. Empty list = well-formed.""" + p = [] + for k in ("from", "to", "digest", "verdict", "tested_at", "harness_version", "evidence", + "memory_peak_pct", "marks"): + if k not in e: + p.append("missing key %r" % k) + if p: + return p + for k in ("from", "to", "digest"): + if not isinstance(e[k], dict) or not e[k]: + p.append("%r must be a non-empty {service: value} object" % k) + if p: + return p + v = e["verdict"] + if v not in VERDICTS: + p.append("verdict %r is not allowed in a ladder (only %s — a failed or inconclusive test is " + "evidence, never a step a box may take)" % (v, "/".join(VERDICTS))) + backfilled = e.get("backfilled") + if backfilled is not None and not (isinstance(backfilled, str) and DATE_RE.match(backfilled)): + p.append("backfilled must be a YYYY-MM-DD date") + if v == "unrecorded" and backfilled is None: + p.append("verdict 'unrecorded' exists only for the backfill of a move made before the gate") + for svc, ref in e["to"].items(): + d = e["digest"].get(svc) + if not (isinstance(d, str) and DIGEST_RE.match(d)): + p.append("no sha256 digest for service %r (%s)" % (svc, ref)) + for svc in e["digest"]: + if svc not in e["to"]: + p.append("digest names a service %r that `to` does not" % svc) + marks = e["marks"] + if not isinstance(marks, dict) or set(marks) != {"files_may_change", "needs_person", "memory_tight"}: + p.append("marks must be exactly {files_may_change, needs_person, memory_tight}") + marks = {} + if v == "proven": + if not (isinstance(e["tested_at"], str) and TS_RE.match(e["tested_at"])): + p.append("a proven entry needs tested_at as an RFC 3339 time") + if not (isinstance(e["evidence"], str) and e["evidence"].strip()): + p.append("a proven entry must cite its evidence") + peak = e["memory_peak_pct"] + if backfilled is None: + if not isinstance(e["harness_version"], int) or e["harness_version"] < 2: + p.append("a new proven entry needs harness_version >= 2 (the memory watch)") + if not isinstance(peak, (int, float)) or isinstance(peak, bool): + p.append("a new proven entry needs memory_peak_pct from the memory watch") + if isinstance(peak, (int, float)) and not isinstance(peak, bool) and marks: + tight = peak > MEMORY_TIGHT_PCT + if bool(marks.get("memory_tight")) != tight: + p.append("marks.memory_tight=%s disagrees with memory_peak_pct=%s (tight above %d%%)" + % (marks.get("memory_tight"), peak, MEMORY_TIGHT_PCT)) + return p + + +def entry_line(e): + """The one line the writer emits for an entry — key order fixed so diffs stay readable.""" + order = ["from", "to", "digest", "verdict", "tested_at", "harness_version", "evidence", + "box_evidence", "memory_peak_pct", "marks", "backfilled", "note"] + ordered = {k: e[k] for k in order if k in e} + for k in e: + if k not in ordered: + ordered[k] = e[k] + return " - " + json.dumps(ordered, ensure_ascii=False, separators=(", ", ": ")) + + +LADDER_HEADER = ( + "\n# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings,\n" + "# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand;\n" + "# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused.\n" + "update_ladder:\n") + + +def append_entry(felhom_text, e): + """Return felhom_text with `e` appended as the ladder's newest line (creating the block at the + END of the file when absent — it is a top-level key and nothing may follow it inside the block).""" + line = entry_line(e) + lines = felhom_text.splitlines() + start = None + for i, l in enumerate(lines): + if LADDER_KEY_RE.match(l): + start = i + if start is None: + body = felhom_text.rstrip("\n") + "\n" + LADDER_HEADER + line + "\n" + return body + end = start + 1 + while end < len(lines) and (not lines[end].strip() or lines[end].startswith(" ") + or lines[end].lstrip().startswith("#")): + end += 1 + # insert after the last entry line of the block + last = start + for j in range(start + 1, end): + if ENTRY_RE.match(lines[j]): + last = j + lines.insert(last + 1, line) + return "\n".join(lines) + "\n" diff --git a/scripts/ladder_backfill.py b/scripts/ladder_backfill.py new file mode 100644 index 0000000..01e3773 --- /dev/null +++ b/scripts/ladder_backfill.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ladder_backfill.py — ONE-OFF (night 2026-09-23): the first ladder entry for every image move that +went live BEFORE the test-record gate existed, written from the verdict record that move cited. + + python3 scripts/ladder_backfill.py --workspace /mnt/5_hdd/felhom.eu/git [--write] ... + +For each commit: the app is the one template whose images it moved; `from`/`to` are the per-service +images at ~1 and ; the evidence is the `Evidence:` path in the commit message. The +entry is `proven` ONLY when that record exists, says `proven`, and names the same `to` refs; +otherwise it is written `unrecorded` and NAMED — never invented. Every entry carries +`backfilled: ` and the digest the registry serves for its refs TODAY (decision 17 has no +earlier measurement to cite; the entry says so in `note`). An `extra` record may be given per app +(`--extra app=path`) for a later measurement of the SAME step — romm's memory watch (M1) is one. A +commit that cited no record may be given one (`--evidence app=path`); it is used only if its verdict +and refs match, and the entry says the backfill named it. + +It refuses to write an app whose compose no longer stands at the move's `to` (a later move would +need its own entry first) — the static gate would convict that ladder anyway. +""" +import datetime +import json +import os +import re +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import image_digest # noqa: E402 +import ladder # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def git(*a): + return subprocess.run(["git", "-C", ROOT] + list(a), capture_output=True, text=True) + + +def main(argv): + ws = argv[argv.index("--workspace") + 1] + write = "--write" in argv + extra, cite = {}, {} + for i, a in enumerate(argv): + if a == "--extra": + k, _, v = argv[i + 1].partition("=") + extra[k] = v + if a == "--evidence": + k, _, v = argv[i + 1].partition("=") + cite[k] = v + commits = [a for i, a in enumerate(argv) if re.match(r"^[0-9a-f]{7,40}$", a)] + today = datetime.date.today().isoformat() + rows, rc = [], 0 + for c in commits: + files = [f for f in git("show", "--name-only", "--format=", c).stdout.split() + if re.match(r"^templates/[^/]+/docker-compose\.yml$", f)] + if len(files) != 1: + print("SKIP %s: moves %d templates, expected exactly one" % (c, len(files))) + rc = 1 + continue + app = files[0].split("/")[1] + frm = ladder.images_in(git("show", "%s~1:%s" % (c, files[0])).stdout) + to = ladder.images_in(git("show", "%s:%s" % (c, files[0])).stdout) + cur = ladder.images_in(open(os.path.join(ROOT, files[0]), encoding="utf-8").read()) + if cur != to: + print("REFUSE %s (%s): the compose has moved since (%s) — not backfilled" % (app, c, cur)) + rc = 1 + continue + body = git("show", "-s", "--format=%B", c).stdout + m = re.search(r"Evidence:\s*(\S+verdict\.json)", body) + ev = m.group(1) if m else None + cited_here = False + if ev is None and app in cite: + ev, cited_here = cite[app], True + verdict, why, tested_at = "unrecorded", None, None + if ev and os.path.exists(os.path.join(ws, ev)): + rec = json.load(open(os.path.join(ws, ev))) + rto = {k: v for k, v in (rec.get("to") or {}).items() if k in to} + if rec.get("verdict") == "proven" and all(to.get(k) == v for k, v in rto.items()) and rto: + verdict, tested_at = "proven", rec.get("measured_at") + else: + why = "the cited record says verdict=%r to=%r" % (rec.get("verdict"), rec.get("to")) + else: + why = "no verdict record found (commit cites %r)" % ev + digests = {} + for svc, ref in sorted(to.items()): + d, err = image_digest.resolve(ref) + if not d: + print("INCONCLUSIVE %s: %s %s: %s" % (app, svc, ref, err)) + return 2 + digests[svc] = d + if tested_at and not ladder.TS_RE.match(tested_at): + tested_at = tested_at.split(".")[0].replace("+00:00", "") + "Z" + e = {"from": frm, "to": to, "digest": digests, "verdict": verdict, "tested_at": tested_at, + "harness_version": 1 if verdict == "proven" else None, "evidence": ev, + "memory_peak_pct": None, + "marks": {"files_may_change": False, "needs_person": None, "memory_tight": False}, + "backfilled": today, + "note": "backfilled from catalog commit %s; box walk only (harness v1, no memory watch); " + "digest = what the registry served on %s, not a measurement of the tested image" + % (c, today)} + if why: + e["note"] += "; UNRECORDED because " + why + if cited_here: + e["note"] += ("; the commit cited no record — this one was named by the backfill because its " + "from/to refs are the commit's and the commit describes the same walk") + if app in extra: + x = json.load(open(os.path.join(ws, extra[app]))) + peaks = [p.get("peak_pct") for p in ((x.get("memory") or {}).get("containers") or {}).values() + if isinstance(p.get("peak_pct"), (int, float))] + if x.get("verdict") == "proven" and peaks and x.get("to", {}).get(next(iter(x["to"]))) == \ + to.get(next(iter(x["to"]))): + pk = round(max(peaks) * 100, 1) + e["memory_peak_pct"] = pk + e["marks"]["memory_tight"] = pk > ladder.MEMORY_TIGHT_PCT + e["note"] += "; memory watch from %s (bench, harness v%s): peak %s%%" % ( + extra[app], x.get("harness_version"), pk) + probs = ladder.check_entry(e) + if probs: + print("REFUSE %s: entry not well-formed: %s" % (app, probs)) + rc = 1 + continue + rows.append((app, c, verdict, ev, why)) + if write: + p = os.path.join(ROOT, "templates", app, ".felhom.yml") + text = open(p, encoding="utf-8").read() + if "update_ladder:" in text: + print("REFUSE %s: already carries a ladder" % app) + rc = 1 + continue + open(p, "w", encoding="utf-8").write(ladder.append_entry(text, e)) + for app, c, v, ev, why in rows: + print("%-16s %s %-10s %s%s" % (app, c, v, ev or "-", (" (" + why + ")") if why else "")) + print("%d backfilled (%d proven, %d unrecorded)%s" % ( + len(rows), sum(1 for r in rows if r[2] == "proven"), sum(1 for r in rows if r[2] != "proven"), + "" if write else " — DRY RUN, nothing written")) + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_catalog_gates.py b/scripts/test_catalog_gates.py index 0bd4df2..9ccaff8 100644 --- a/scripts/test_catalog_gates.py +++ b/scripts/test_catalog_gates.py @@ -58,10 +58,14 @@ class CatalogGatesFastTest(unittest.TestCase): spec = importlib.util.spec_from_file_location("catalog_gates_under_test", ENTRY) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) - self.assertEqual(len(mod.GATES), 5) - self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins", "engine-major", "catalog-since"]) - # two gates need git history; they are the ones the CI half cannot run (R-452's shallow gap) - self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major", "catalog-since"]) + # STALE UNTIL 2026-09-23: this read 5 gates and 3 fast ones while the table had grown to 7 + # (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test + # record's two halves; the slow pair stays out of --fast. + self.assertEqual(len(mod.GATES), 9) + self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"]) + self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too + # the gates that need git history are the ones the CI half cannot run (R-452's shallow gap) + self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major", "catalog-since", "test-record-move"]) def test_engine_major_ran_under_fast(self): """The 2026-09-13 gate is fast (git reads only) and must be IN --fast, or the hook that diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 012705b..b1457db 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -51,6 +51,8 @@ COVERS = { "probe-matches-compose": "the probe TARGET resolves by exact name, explicit `container`, or a UNIQUE prefix - an ambiguity is refused, not guessed (R-630); the DEGRADED no-PyYAML mode CI actually runs; the port/path moved in a COMMENT, in traefik's loadbalancer label, in " "`ports:`/`expose:`, or on a NON-probed service - none of which is where " "the app listens; vs a real probe port/path that the app does not answer (R-618)", + "test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)", + "test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)", "copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)", } @@ -280,6 +282,124 @@ def swap_image(service, frm, to): return _fn + +# ── test record (09 §3 decision 13) ──────────────────────────────────────────────────────────── +TR_D1 = "sha256:" + "a" * 64 +TR_D2 = "sha256:" + "b" * 64 + + +def tr_entry(frm, to, digest, verdict="proven", peak=41.0, tight=False, **extra): + import json as _j + e = {"from": frm, "to": to, "digest": digest, "verdict": verdict, + "tested_at": "2026-09-23T22:00:00Z", "harness_version": 2, + "evidence": "felhom.eu/documentation/audits/night-2026-09-23/apps/x/", "memory_peak_pct": peak, + "marks": {"files_may_change": False, "needs_person": None, "memory_tight": tight}} + e.update(extra) + return " - " + _j.dumps(e) + + +def tr_append(line, header=True): + def _fn(t): + block = ("\nupdate_ladder:\n" if header else "") + line + "\n" + return t.rstrip("\n") + "\n" + block + return _fn + + +def case_tr_move(name, clone, edits, expect_rc, must_contain=(), table=None): + import json as _j + tf = os.path.join(clone, "..", os.path.basename(clone) + "-digests.json") + _j.dump(table or {}, open(tf, "w")) + global ran + ran += 1 + base = sh(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip() + try: + for relpath, fn in edits: + edit(clone, relpath, fn) + commit(clone, name) + r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-test-record-move.py"), + "--range", "HEAD~1..HEAD", "--digests-from", tf], cwd=clone) + out = r.stdout + r.stderr + ok = r.returncode == expect_rc and all(m in out for m in must_contain) + print(" %s %-52s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc)) + if not ok: + fails.append("%s: rc=%d expected %d; missing %s\n%s" % ( + name, r.returncode, expect_rc, [m for m in must_contain if m not in out], out[-900:])) + finally: + sh(["git", "reset", "-q", "--hard", base], cwd=clone) + os.remove(tf) + + +def case_tr_static(name, clone, edits, expect_rc, must_contain=(), apps=("navidrome",)): + global ran + ran += 1 + try: + for relpath, fn in edits: + edit(clone, relpath, fn) + r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-test-record.py"), + "--root", clone] + list(apps), cwd=clone) + out = r.stdout + r.stderr + ok = r.returncode == expect_rc and all(m in out for m in must_contain) + print(" %s %-52s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc)) + if not ok: + fails.append("%s: rc=%d expected %d; missing %s\n%s" % ( + name, r.returncode, expect_rc, [m for m in must_contain if m not in out], out[-900:])) + finally: + reset(clone) + + +def test_record_cases(clone): + NC, NF = "templates/navidrome/docker-compose.yml", "templates/navidrome/.felhom.yml" + old, new = "deluan/navidrome:0.64.0", "deluan/navidrome:0.64.1" + frm, to = {"navidrome": old}, {"navidrome": new} + move = (NC, swap_image("navidrome", old, new)) + good = tr_entry(frm, to, {"navidrome": TR_D1}) + table = {new: TR_D1} + print("-- test-record-move: an image move needs its own proven record") + case_tr_move("FACT: a bare image move, no entry", clone, [move], 1, + ("adds NO update_ladder entry",), table) + case_tr_move("GENUINE: a proven entry whose digest the registry serves", clone, + [move, (NF, tr_append(good))], 0, ("0.64.1" if False else "test-record-move gate",), table) + case_tr_move("FACT: the entry's verdict is failed", clone, + [move, (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, verdict="failed")))], 1, + ("not allowed in a ladder",), table) + case_tr_move("FACT: the registry now serves another digest", clone, + [move, (NF, tr_append(good))], 1, ("the registry serves",), {new: TR_D2}) + case_tr_move("INCONCLUSIVE: the registry cannot be asked", clone, + [move, (NF, tr_append(good))], 2, ("could not be asked",), {}) + case_tr_move("DECOY: the entry only in a COMMENT under update_ladder", clone, + [move, (NF, lambda t: t.rstrip("\n") + "\nupdate_ladder:\n # " + good.strip() + "\n")], 1, + ("holds no entry",), table) + case_tr_move("DECOY: the entry only in README.md", clone, + [move, ("README.md", lambda t: t + "\n" + good + "\n")], 1, + ("adds NO update_ladder entry",), table) + case_tr_move("FACT: a new move carrying a BACKFILLED entry", clone, + [move, (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, backfilled="2026-09-23")))], 1, + ("marked backfilled",), table) + case_tr_move("FACT: memory_tight and the limit did not move", clone, + [move, (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, peak=86.0, tight=True)))], 1, + ("memory_tight",), table) + case_tr_move("GENUINE: memory_tight WITH the limit raised", clone, + [move, (NC, lambda t: t.replace("memory: 256M", "memory: 384M")), + (NF, tr_append(tr_entry(frm, to, {"navidrome": TR_D1}, peak=86.0, tight=True)))], 0, + (), table) + case_tr_move("DECOY: a ref moves in a compose COMMENT only", clone, + [(NC, lambda t: t + "\n# was: deluan/navidrome:0.63.2\n")], 0, (), table) + print("-- test-record (static): the newest step IS the compose") + case_tr_static("GENUINE: a ladder whose head is the compose", clone, + [(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1})))], 0) + case_tr_static("FACT: the compose moved past the ladder's head", clone, + [(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1}))), + move], 1, ("not the ladder's newest step",)) + case_tr_static("FACT: a line that is not one JSON entry", clone, + [(NF, lambda t: t + "\nupdate_ladder:\n - from: x\n")], 1, ("not a one-line JSON entry",)) + case_tr_static("FACT: a gap between steps", clone, + [(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.62.0"}, {"navidrome": "deluan/navidrome:0.63.0"}, {"navidrome": TR_D1}) + + "\n" + tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1})))], + 1, ("the ladder has a gap",)) + case_tr_static("FACT: a failed verdict sits in the ladder", clone, + [(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1}, verdict="failed")))], + 1, ("not allowed in a ladder",)) + def main(): gate = os.path.join(ROOT, "scripts", "check-engine-major.py") if not os.path.isfile(gate): @@ -659,6 +779,8 @@ i18n: lambda t: t.replace(" container: paperless-webserver\n", ""))], expect_rc=1, must_contain=("FAIL paperless-ngx",), apps=("paperless-ngx",)) + test_record_cases(clone) + finally: shutil.rmtree(clone, ignore_errors=True) diff --git a/scripts/test_ladder_writer.py b/scripts/test_ladder_writer.py new file mode 100644 index 0000000..89d65dd --- /dev/null +++ b/scripts/test_ladder_writer.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""test_ladder_writer.py — the ONLY ladder writer (`upgrade-test.py --write-ladder`) writes what the +gate accepts, and refuses what it must. No network (the digest resolver is replaced), no Docker. + + python3 scripts/test_ladder_writer.py +""" +import importlib.util +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, HERE) +import image_digest # noqa: E402 +import ladder # noqa: E402 + +spec = importlib.util.spec_from_file_location("upgrade_test_mod", os.path.join(HERE, "upgrade-test.py")) +ut = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ut) + +D = "sha256:" + "c" * 64 + + +def bench(verdict="proven", peak=0.41, marks=(), frm="0.64.0", to="0.64.1"): + return {"harness_version": 3, "app": "navidrome", "verdict": verdict, + "from": {"navidrome": "deluan/navidrome:" + frm}, "to": {"navidrome": "deluan/navidrome:" + to}, + "measured_at": "2026-09-23T22:00:00Z", "marks": list(marks), + "memory": {"containers": {"navidrome": {"peak_pct": peak}}}} + + +class WriterTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="ladder-writer-") + shutil.copytree(os.path.join(ROOT, "templates", "navidrome"), + os.path.join(self.tmp, "templates", "navidrome")) + self.fy = os.path.join(self.tmp, "templates", "navidrome", ".felhom.yml") + # start from a template WITHOUT a ladder, at 0.64.0 (the live pin tonight) + text = open(self.fy).read() + if "update_ladder:" in text: + text = text[:text.index("\n# update_ladder")] + "\n" + open(self.fy, "w").write(text) + self._orig = image_digest.resolve + image_digest.resolve = lambda ref: (D, None) + + def tearDown(self): + image_digest.resolve = self._orig + shutil.rmtree(self.tmp, ignore_errors=True) + + def run_writer(self, b, box_verdict="proven"): + bp, xp = os.path.join(self.tmp, "b.json"), os.path.join(self.tmp, "x.json") + json.dump(b, open(bp, "w")) + json.dump({"verdict": box_verdict, "to": b["to"]}, open(xp, "w")) + buf = io.StringIO() + with redirect_stdout(buf): + rc = ut.write_ladder([bp, "--box", xp, "--catalog", self.tmp, "--evidence", "ev/x/"]) + return rc, buf.getvalue() + + def test_writes_what_the_gate_accepts(self): + rc, out = self.run_writer(bench()) + self.assertEqual(rc, 0, out) + entries, _, errs = ladder.parse(open(self.fy).read()) + self.assertEqual(errs, []) + self.assertEqual(entries[-1]["to"], {"navidrome": "deluan/navidrome:0.64.1"}) + self.assertEqual(entries[-1]["memory_peak_pct"], 41.0) # a PERCENT, from the watch's fraction + self.assertEqual(entries[-1]["digest"], {"navidrome": D}) + comp = open(os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml")).read() + self.assertEqual(ladder.images_in(comp), {"navidrome": "deluan/navidrome:0.64.1"}) + import subprocess + r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp, + "navidrome"], capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_refuses_a_failed_bench(self): + rc, out = self.run_writer(bench(verdict="failed")) + self.assertEqual(rc, 1) + self.assertNotIn("update_ladder:", open(self.fy).read()) + + def test_refuses_a_failed_box(self): + rc, out = self.run_writer(bench(), box_verdict="inconclusive") + self.assertEqual(rc, 1) + self.assertNotIn("update_ladder:", open(self.fy).read()) + + def test_refuses_when_the_template_is_not_at_from(self): + rc, out = self.run_writer(bench(frm="0.63.2")) + self.assertEqual(rc, 1) + self.assertIn("the template is at", out) + + def test_marks_follow_the_measurement(self): + rc, out = self.run_writer(bench(peak=0.86, marks=["memory_tight", "files_may_change"])) + self.assertEqual(rc, 0, out) + e = ladder.parse(open(self.fy).read())[0][-1] + self.assertEqual(e["marks"], {"files_may_change": True, "needs_person": None, "memory_tight": True}) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/upgrade-test.py b/scripts/upgrade-test.py index 929a7a0..bda7c55 100755 --- a/scripts/upgrade-test.py +++ b/scripts/upgrade-test.py @@ -36,6 +36,9 @@ felhom.eu/documentation/architecture/09-update-architecture.md §4: once a migra image refuses to start on the migrated data, so there is no rollback to speak of. Usage: python3 upgrade-test.py [--soak SECONDS] [ …] (see EDGES) + python3 upgrade-test.py [--soak SECONDS] --move = [...] (FROM = the template) + python3 upgrade-test.py --write-ladder --box \ + --catalog --evidence [--box-evidence ] (the ONLY ladder writer) python3 upgrade-test.py --list --soak: how long the memory watch runs after a successful readback (default 600; 0 = off) Layout: templates under /opt/upg/templates, evidence under /opt/upg/evidence @@ -44,18 +47,25 @@ import importlib.util, json, os, re, shutil, subprocess, sys, time from datetime import datetime, timezone from pathlib import Path -HARNESS_VERSION = 2 # 2: the memory watch after the readback (R-635, 2026-09-23) +HARNESS_VERSION = 3 # 2: the memory watch (R-635); 3: box fixtures on the bench + files_may_change (2026-09-23 night) ROOT = Path("/opt/upg") TEMPLATES = ROOT / "templates" EVIDENCE = ROOT / "evidence" -_spec = importlib.util.spec_from_file_location("cvp", str(ROOT / "check-volume-persistence.py")) -cvp = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(cvp) +# On the bench the helpers sit beside this file in /opt/upg; the ladder WRITER (`--write-ladder`) runs +# on DooPlex against a catalog checkout and needs none of them, so a missing one is only fatal to a run. +cvp = fx = boxport = None +if (ROOT / "check-volume-persistence.py").exists(): + sys.path.insert(0, str(ROOT)) + _spec = importlib.util.spec_from_file_location("cvp", str(ROOT / "check-volume-persistence.py")) + cvp = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(cvp) -_fspec = importlib.util.spec_from_file_location("fx", str(ROOT / "upgrade_fixtures.py")) -fx = importlib.util.module_from_spec(_fspec) -_fspec.loader.exec_module(fx) + _fspec = importlib.util.spec_from_file_location("fx", str(ROOT / "upgrade_fixtures.py")) + fx = importlib.util.module_from_spec(_fspec) + _fspec.loader.exec_module(fx) + if (ROOT / "upgrade_boxport.py").exists(): + import upgrade_boxport as boxport # noqa: E402 — the box walk's fixtures, on the bench (R-462) # --- the edges --------------------------------------------------------------------------------- @@ -340,8 +350,15 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P """ import threading, urllib.error, urllib.request paths = LOAD_PATHS.get(app, ["/"]) - target = container_ip(getattr(fx.FIXTURES.get(app), "container", app)) - port = getattr(fx.FIXTURES.get(app), "port", 80) + native = fx.FIXTURES.get(app) + cname, port = getattr(native, "container", app), getattr(native, "port", 80) + if native is None and boxport is not None: + # a box-fixture app: load the container traefik routes `/` to, at its own port + rts = boxport.routes((workdir / "docker-compose.yml").read_text()) + root = [r for r in rts if not r[0]] or rts + if root: + cname, port = root[0][1], root[0][2] + target = container_ip(cname) stop = threading.Event() hits = {"n": 0, "codes": {}} lock = threading.Lock() @@ -431,6 +448,43 @@ def migration_lines(project: str, workdir: Path, since_iso: str, limit=6): # --- one edge ---------------------------------------------------------------------------------- +def bind_tree_hash(project: str, workdir: Path) -> dict: + """{bind source dir: sha256 over (relpath, size, content sha)} for every BIND mount of the project's + containers — the household's own files (a named volume holds the app's state, which a migration is + SUPPOSED to rewrite). Files above 64 MiB are hashed by size and mtime only, and the result says so.""" + import hashlib + r = compose(workdir, project, "ps", "-aq", timeout=120) + srcs = set() + for cid in (r.stdout or "").split(): + info = cvp._inspect(cid) or {} + for m in info.get("Mounts") or []: + if m.get("Type") == "bind" and os.path.isdir(m.get("Source") or "") \ + and not (m.get("Source") or "").startswith(("/var/run", "/run", "/etc", "/proc", "/sys")): + srcs.add(m["Source"]) + out = {} + for src in sorted(srcs): + h = hashlib.sha256() + for dp, dn, fn in os.walk(src): + dn.sort() + for f in sorted(fn): + fp = os.path.join(dp, f) + try: + st = os.lstat(fp) + except OSError: + continue + h.update(os.path.relpath(fp, src).encode() + b"\0" + str(st.st_size).encode()) + if st.st_size <= 64 * 1024 * 1024 and os.path.isfile(fp) and not os.path.islink(fp): + try: + with open(fp, "rb") as fh: + h.update(hashlib.sha256(fh.read()).digest()) + except OSError: + h.update(b"unreadable") + else: + h.update(str(int(st.st_mtime)).encode()) + out[src] = h.hexdigest() + return out + + def run_edge(edge_id: str) -> dict: e = EDGES[edge_id] app = e["app"] @@ -481,6 +535,10 @@ def run_edge(edge_id: str) -> dict: # --- 2/3. seed + C1 --- fixture = fx.FIXTURES.get(app) + if fixture is None and boxport is not None: + fixture = boxport.get(app, compose_text, env) + if fixture is not None: + say(f"fixture: the BOX walk's own ({type(fixture.box).__name__}), through upgrade_boxport") if fixture is None: rec["abort_detail"] = "no fixture" say("no fixture for this app — inconclusive") @@ -488,7 +546,8 @@ def run_edge(edge_id: str) -> dict: seeded = fixture.seed(container_ip, say) if seeded is None: rec["verdict"] = "inconclusive" - rec["abort_detail"] = "no non-browser seed route" + rec["abort_detail"] = "no non-browser seed route" + ( + f" — tried: {fixture.tried}" if getattr(fixture, "tried", None) else "") say("INCONCLUSIVE — no non-browser seed route. Nothing was planted by hand.") return rec rec["seed_read_before"] = bool(fixture.verify(container_ip, seeded, say)) @@ -498,6 +557,9 @@ def run_edge(edge_id: str) -> dict: say("C1 FAILED — a fixture that cannot prove itself first proves nothing after") return rec + files_before = bind_tree_hash(project, workdir) + (ev / "files-before.json").write_text(json.dumps(files_before, indent=2)) + # --- 4. TO --- swap_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") say(f"{edge_id}: swapping to TO {e['to']}") @@ -535,10 +597,19 @@ def run_edge(edge_id: str) -> dict: say(f"RESULT (seed reads back AFTER): {rec['seed_read_after']}") rec["verdict"] = "proven" if (ok2 and rec["seed_read_after"]) else "failed" + # --- 5a. did the update rewrite the household's FILES? (decision 13's `files may change`) --- + files_after = bind_tree_hash(project, workdir) + (ev / "files-after.json").write_text(json.dumps(files_after, indent=2)) + rec["files_changed"] = sorted(k for k in set(files_before) | set(files_after) + if files_before.get(k) != files_after.get(k)) + if rec["files_changed"]: + rec["marks"] = sorted(set(rec["marks"]) | {"files_may_change"}) + say(f"files_may_change: the bind-mounted tree changed under {rec['files_changed']}") + # --- 5b. the MEMORY WATCH — only for an edge that just read back; a failed one is decided --- if rec["verdict"] == "proven" and SOAK_SECONDS > 0: mem, killed, marks = memory_watch(app, project, workdir, SOAK_SECONDS, say, ev) - rec["memory"], rec["marks"] = mem, marks + rec["memory"], rec["marks"] = mem, sorted(set(rec["marks"]) | set(marks)) if killed: rec["verdict"] = "failed" say("VERDICT -> failed: the new version was OOM-killed or restarted under light load") @@ -578,11 +649,122 @@ def run_edge(edge_id: str) -> dict: SOAK_SECONDS = 600 +def template_images(app: str, template_dir: Path) -> dict: + """{service: image} of a catalog template — the per-service reading every gate makes.""" + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import ladder + return ladder.images_in((template_dir / app / "docker-compose.yml").read_text()) + + +def add_move_edge(app: str, moves: list) -> str: + """`--move svc=ref …` → an edge FROM the template as it stands TO the same with those + services moved. The FROM side is read, never typed, so it cannot disagree with the catalog.""" + frm = template_images(app, TEMPLATES) + to = dict(frm) + for mv in moves: + svc, _, ref = mv.partition("=") + if svc not in frm or not ref: + raise SystemExit(f"--move: {mv!r} — service must be one of {sorted(frm)}") + to[svc] = ref + if to == frm: + raise SystemExit("--move: nothing moves") + eid = f"MV-{app}" + EDGES[eid] = dict(app=app, note="night 2026-09-23 within-a-major move: " + ", ".join(moves), + frm=frm, to=to) + return eid + + +def write_ladder(argv) -> int: + """`--write-ladder --box --catalog --evidence + [--box-evidence ]` — THE ONLY WRITER of a ladder entry (`09` §6.4 part 4; never by hand). + + It refuses unless BOTH venues say `proven` and the template still stands at the bench's FROM; it + resolves every TO ref's digest from the registry now; then it moves the compose's image lines, sets + `catalog_since` to today, and appends the entry. The commit is the operator's (or the session's).""" + import datetime as _dt + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import ladder, image_digest + + def arg(name, default=None): + return argv[argv.index(name) + 1] if name in argv else default + bench = json.loads(Path(argv[0]).read_text()) + box = json.loads(Path(arg("--box")).read_text()) + cat = Path(arg("--catalog")) + app = bench["app"] + if bench.get("verdict") != "proven" or box.get("verdict") != "proven": + print(f"REFUSED {app}: bench verdict {bench.get('verdict')!r}, box verdict {box.get('verdict')!r} " + "— only a step proven on BOTH venues is written") + return 1 + if (bench.get("harness_version") or 0) < 2 or not bench.get("memory"): + print(f"REFUSED {app}: the bench verdict carries no memory watch (harness v2)") + return 1 + tdir = cat / "templates" / app + comp_p, fy_p = tdir / "docker-compose.yml", tdir / ".felhom.yml" + comp = comp_p.read_text() + cur = ladder.images_in(comp) + if cur != bench["from"]: + print(f"REFUSED {app}: the template is at {cur}, the bench tested FROM {bench['from']}") + return 1 + if box.get("to") and {k: v for k, v in box["to"].items() if k in bench["to"]} != \ + {k: v for k, v in bench["to"].items() if k in box["to"]}: + print(f"REFUSED {app}: the box walked TO {box.get('to')}, the bench tested TO {bench['to']}") + return 1 + digests = {} + for svc, ref in sorted(bench["to"].items()): + d, why = image_digest.resolve(ref) + if not d: + print(f"INCONCLUSIVE {app}: {svc} {ref}: {why}") + return 2 + digests[svc] = d + peaks = [c.get("peak_pct") for c in (bench["memory"].get("containers") or {}).values() + if isinstance(c.get("peak_pct"), (int, float))] + # the watch records peak_pct as a FRACTION of the limit (0.81); the ladder carries PERCENT + peak = round(max(peaks) * 100, 1) if peaks else None + if peak is None: + print(f"REFUSED {app}: the memory watch recorded no peak") + return 1 + marks = set(bench.get("marks") or []) + entry = {"from": bench["from"], "to": bench["to"], "digest": digests, "verdict": "proven", + "tested_at": bench["measured_at"], "harness_version": bench["harness_version"], + "evidence": arg("--evidence"), "box_evidence": arg("--box-evidence"), + "memory_peak_pct": peak, + "marks": {"files_may_change": "files_may_change" in marks, + "needs_person": None, "memory_tight": peak > ladder.MEMORY_TIGHT_PCT}} + probs = ladder.check_entry(entry) + if probs: + print(f"REFUSED {app}: the entry would not be well-formed: {probs}") + return 1 + # move the compose, per service, on that service's own image: line + out, svc = [], None + for line in comp.splitlines(): + m = ladder.SERVICE_RE.match(line) + if m: + svc = m.group(1) + mi = re.match(r"^(\s+image:\s*)(\S+)\s*$", line) + if mi and svc in bench["to"] and mi.group(2) == bench["from"][svc]: + line = mi.group(1) + bench["to"][svc] + out.append(line) + comp_p.write_text("\n".join(out) + "\n") + if ladder.images_in(comp_p.read_text()) != bench["to"]: + comp_p.write_text(comp) + print(f"REFUSED {app}: the compose could not be moved line by line — restored") + return 1 + fy = fy_p.read_text() + fy = re.sub(r'^catalog_since:.*$', 'catalog_since: "%s"' % _dt.date.today().isoformat(), fy, count=1, flags=re.M) + fy_p.write_text(ladder.append_entry(fy, entry)) + print(f"WROTE {app}: {bench['from']} -> {bench['to']} peak {peak}% marks {entry['marks']}") + return 0 + + def main(argv): global SOAK_SECONDS + if argv and argv[0] == "--write-ladder": + return write_ladder(argv[1:]) if argv and argv[0] == "--soak": SOAK_SECONDS = int(argv[1]) argv = argv[2:] + if argv and argv[0] == "--move": + argv = [add_move_edge(argv[1], argv[2:])] if not argv or argv[0] == "--list": for k, v in EDGES.items(): print(f"{k:5s} {v['app']:12s} {v['note']}") diff --git a/scripts/upgrade_boxport.py b/scripts/upgrade_boxport.py new file mode 100644 index 0000000..2e0891b --- /dev/null +++ b/scripts/upgrade_boxport.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +"""upgrade_boxport.py — run the BOX walk's seed/verify fixtures on the test bench (R-462, 2026-09-23). + +The box walk (guest 9202, through the controller) and the bench (`upgrade-test.py`, raw compose, no +controller) used to carry two separate fixture sets: 20+ apps box-side, 8 bench-side. R-462 measured +that FIXTURES are the cost of widening the test, so the second set is not rewritten — the box +fixtures (`upgrade_fixtures_box*.py`, ported verbatim) run here through `Venue`, a stand-in for the +four things they use from walk.py: + + app_curl(sub, path, …) → the app's OWN HTTP interface: the container that serves the path, at the + port the template's traefik labels name, with the Host header the app was + configured for. There is no traefik on the bench; the app is the same. + wait_app(sub, path, …) → the same, polled. + guest(script) → a local bash on the bench (fixtures use it for `docker exec `, + the app's own CLI inside its own container — R-156's allowed route). + sh(args) / GENERATED → as in walk.py; GENERATED holds the deploy secrets this run generated. + +THE RULE IS UNCHANGED (R-156): nothing is planted in a volume; every seed goes in through the app. +An app whose fixture returns None is `inconclusive`, with what was tried. +""" +import os +import re +import subprocess +import time + +import upgrade_fixtures_box as _box +import upgrade_fixtures_box28 as _box28 + +ROUTE_RULE_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.rule[=:]\s*[\"']?(.+?)[\"']?\s*$") +ROUTE_SVC_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.service[=:]\s*[\"']?([A-Za-z0-9_-]+)") +LB_PORT_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.port[=:]\s*[\"']?(\d+)") +PATH_RE = re.compile(r"PathPrefix\(`([^`]+)`\)") +SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") +CNAME_RE = re.compile(r"^\s+container_name:\s*[\"']?([^\s\"']+)") + + +def routes(compose_text): + """[(path_prefixes, container, port)] for every compose service traefik routes to.""" + out, cur, cname, labels = [], None, {}, {} + for line in compose_text.splitlines(): + m = SERVICE_RE.match(line) + if m: + cur = m.group(1) + continue + if cur is None: + continue + mc = CNAME_RE.match(line) + if mc: + cname[cur] = mc.group(1) + if "traefik." in line: + labels.setdefault(cur, []).append(line.strip().lstrip("- ").strip()) + for svc, ls in labels.items(): + rules, rsvc, ports = {}, {}, {} + for l in ls: + for rx, d in ((ROUTE_RULE_RE, rules), (ROUTE_SVC_RE, rsvc), (LB_PORT_RE, ports)): + mm = rx.search(l) + if mm: + d[mm.group(1)] = mm.group(2) + if not ports: + continue + port = int(next(iter(ports.values()))) + prefixes = [] + for r, rule in rules.items(): + prefixes += PATH_RE.findall(rule) + out.append((prefixes, cname.get(svc, svc), port)) + return out + + +class Venue: + """walk.py's interface, on the bench.""" + + def __init__(self, compose_text, env, ipfn): + self.routes = routes(compose_text) + self.env = env + self.ipfn = ipfn + self.DOMAIN = env.get("DOMAIN", "gate.invalid") + self.GENERATED = {} + + def _target(self, path): + best, blen = None, -1 + for prefixes, container, port in self.routes: + if not prefixes and blen < 0: + best, blen = (container, port), 0 + for p in prefixes: + if path.startswith(p) and len(p) > blen: + best, blen = (container, port), len(p) + return best + + def sh(self, args, timeout=300, inp=None): + try: + return subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=inp) + except (subprocess.TimeoutExpired, OSError) as e: + return subprocess.CompletedProcess(args, 124, "", str(e)) + + def guest(self, script, timeout=600): + return self.sh(["bash", "-c", script], timeout=timeout).stdout or "" + + def app_curl(self, sub, path, *extra, method=None, data=None, timeout=45): + t = self._target(path) + if not t: + return 7, "000", "no routed container in the template" + ip = self.ipfn(t[0]) + if not ip: + return 7, "000", "container %s has no IP" % t[0] + host = "%s.%s" % (self.env.get("SUBDOMAIN", sub), self.DOMAIN) + args = ["curl", "-sSk", "--max-time", str(timeout), "-H", "Host: " + host, + "-H", "X-Forwarded-Proto: https", "-H", "X-Forwarded-Host: " + host, + "-w", "\n%{http_code}"] + if method: + args += ["-X", method] + if data is not None: + args += ["--data-binary", "@-"] + args += list(extra) + ["http://%s:%d%s" % (ip, t[1], path)] + r = self.sh(args, timeout=timeout + 30, inp=data) + body, _, code = (r.stdout or "").rpartition("\n") + return r.returncode, code.strip(), body + + def wait_app(self, sub, path="/", want=("200", "302", "303", "401", "403"), tries=60, delay=5): + for _ in range(tries): + rc, code, _ = self.app_curl(sub, path, timeout=15) + if rc == 0 and code in want: + return True + time.sleep(delay) + return False + + +class BoxFixture: + """A box fixture in the bench's shape: seed(ipfn, say) / verify(ipfn, seeded, say).""" + + def __init__(self, app, box, compose_text, env): + self.app, self.box, self.compose_text, self.env = app, box, compose_text, env + self.tried = getattr(box, "tried", None) + + def _venue(self, ipfn): + v = Venue(self.compose_text, self.env, ipfn) + v.GENERATED[self.app] = dict(self.env) + return v + + def seed(self, ipfn, say): + v = self._venue(ipfn) + sub = getattr(self.box, "sub", self.app) + out = self.box.seed(v, sub, say) + self.tried = getattr(self.box, "tried", self.tried) + return out + + def verify(self, ipfn, seeded, say): + v = self._venue(ipfn) + return bool(self.box.verify(v, getattr(self.box, "sub", self.app), seeded, say)) + + +def get(app, compose_text, env): + """The ported box fixture for `app`, wrapped for the bench, or None.""" + box = _box.FIXTURES.get(app) or _box28.FIXTURES28.get(app) + if box is None: + return None + return BoxFixture(app, box, compose_text, env) + + +def available(): + return sorted(set(_box.FIXTURES) | set(_box28.FIXTURES28)) diff --git a/scripts/upgrade_fixtures_box.py b/scripts/upgrade_fixtures_box.py new file mode 100644 index 0000000..2a2dbe8 --- /dev/null +++ b/scripts/upgrade_fixtures_box.py @@ -0,0 +1,1011 @@ +# PORTED 2026-09-23 (night shift, R-462) VERBATIM from felhom.eu/documentation/audits/update-night-2026-09-21/ +# fixtures.py (as carried forward in night-2026-09-23/). The box walk and the test bench now read the +# SAME seed/verify code; upgrade_boxport.py adapts it to the bench. Edit here, not in the audit copy. +#!/usr/bin/env python3 +"""Box-side seed/verify fixtures for walk.py, guest 9202. + +THE ONE RULE (R-156), carried verbatim from `app-catalog-felhom.eu/scripts/upgrade_fixtures.py`: +*nothing is ever seeded into a volume by hand.* Every seed here goes in through the app's OWN +interface — its HTTP API through the household's real front door (traefik, `Host: .`), +or its own CLI running inside its own container. A raw SQL INSERT or a planted file is never used. + +If an app has no non-browser route, its fixture returns None and the edge is recorded +`inconclusive — no non-browser seed route`, WITH WHAT WAS TRIED. That is a result, not a gap. + +Each fixture: + seed(w, sub, say) -> an opaque token, or None + verify(w, sub, tok, say) -> True / False +verify() must ask the APP, never the filesystem: a migration is supposed to rewrite files. +Where a fixture can prove itself (a negative control that must read as absent) it does so on EVERY +call, so a readback that has broken into always saying "found" fails instead of passing everything. +""" +import base64, json, re, secrets, time + + +def _gx(w, container, *cmd, timeout=240): + """Run a command inside the app's OWN container on 9202 (its own CLI, not our SQL).""" + import shlex + line = " ".join(shlex.quote(c) for c in cmd) + return w.guest(f"docker exec {container} {line} 2>&1", timeout=timeout) + + +# ============================================================================================= +class PrivateBin: + """PrivateBin's own JSON API. A paste is a POST and reading it back is a GET — an + application-level round trip. File-backed, no database: this single seed IS the file half.""" + sub = "paste" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200",)): + return None + marker = "upg-" + secrets.token_hex(8) + ct = base64.b64encode(marker.encode()).decode() + body = json.dumps({ + "v": 2, + "adata": [[base64.b64encode(secrets.token_bytes(16)).decode(), + base64.b64encode(secrets.token_bytes(8)).decode(), + 100000, 256, 128, "aes", "gcm", "none"], "plaintext", 0, 0], + "ct": ct, "meta": {"expire": "never"}}) + rc, code, out = w.app_curl(sub, "/", "-H", "X-Requested-With: JSONHttpRequest", + "-H", "Content-Type: application/json", + data=body, method="POST") + try: + j = json.loads(out) + except Exception: + say(f" privatebin: POST returned non-JSON (http {code}): {out[:200]}") + return None + if j.get("status") != 0 or not j.get("id"): + say(f" privatebin: POST refused: {out[:250]}") + return None + say(f" privatebin: seeded paste id={j['id']}") + return {"id": j["id"], "marker": ct} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200",), tries=36): + return False + # negative control, every call: a paste id that cannot exist must NOT read back + rc, code, out = w.app_curl(sub, "/?pasteid=" + secrets.token_hex(8), + "-H", "X-Requested-With: JSONHttpRequest") + if t["marker"] in out: + say(" privatebin: READBACK UNUSABLE — a paste id that cannot exist returned the marker") + return False + rc, code, out = w.app_curl(sub, "/?pasteid=" + t["id"], + "-H", "X-Requested-With: JSONHttpRequest") + got = code == "200" and t["marker"] in out + say(f" privatebin: readback http={code} marker_present={got}") + return got + + +# ============================================================================================= +class Docmost: + """Docmost's own REST API: create the first workspace+user, then prove the account survives by + asking the app to AUTHENTICATE it. Login is version-stable across the API churn.""" + sub = "docs" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302", "404")): + return None + email = f"drill-{secrets.token_hex(4)}@gate.invalid" + pw = "Drill-" + secrets.token_hex(10) + body = json.dumps({"workspaceName": "drill", "name": "drill", "email": email, "password": pw}) + rc, code, out = w.app_curl(sub, "/api/auth/setup", "-H", "Content-Type: application/json", + data=body, method="POST") + say(f" docmost: /api/auth/setup http={code} rc={rc}") + if code not in ("200", "201"): + say(f" docmost: setup refused: {out[:250]}") + return None + return {"email": email, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302", "404"), tries=36): + return False + # negative control: a password that was never set must NOT authenticate + bad = json.dumps({"email": t["email"], "password": "definitely-" + secrets.token_hex(8)}) + rc, code, _ = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json", + data=bad, method="POST") + if code in ("200", "201"): + say(" docmost: READBACK UNUSABLE — a wrong password authenticated") + return False + body = json.dumps({"email": t["email"], "password": t["pw"]}) + rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json", + data=body, method="POST") + ok = code in ("200", "201") + say(f" docmost: login as the seeded user http={code} ok={ok}") + if not ok: + say(f" docmost: login body {out[:200]}") + return ok + + +# ============================================================================================= +class BookStack: + """BookStack mints no API token without a browser, so BOTH halves go through `php artisan` — + BookStack's OWN CLI, inside its own container, against its own User model. + + The exit code carries no information here (`bookstack:reset-mfa` exits 1 for a user it FOUND + and for one it did not), so the discriminator is the OUTPUT: the positive sentence required and + the not-found sentence required absent. The negative control runs on every verify. + + LIMITATION (R-460): this seeds the DATABASE half only. The FILE half needs the API token the + app cannot mint headlessly — so a bookstack edge is at best HALF-proven here. + """ + sub = "wiki" + + def _artisan(self, w, *args): + for path in ("/app/www/artisan", "/var/www/html/artisan"): + out = _gx(w, "bookstack", "php", path, *args) + if "Could not open input file" not in out: + return " ".join(out.split()) + return " ".join(out.split()) + + def _lookup(self, w, email): + out = self._artisan(w, "bookstack:reset-mfa", f"--email={email}") + found = f"Email: {email}" in out + missing = "could not be found" in out + if found == missing: + return None, out + return found, out + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/login", want=("200",), tries=72): + return None + email = f"drill-{secrets.token_hex(4)}@gate.invalid" + pw = "Drill-" + secrets.token_hex(10) + out = self._artisan(w, "bookstack:create-admin", f"--email={email}", + f"--name=drill-{secrets.token_hex(3)}", f"--password={pw}") + say(f" bookstack: artisan create-admin :: {out[:140]}") + if "successfully created" not in out: + return None + return {"email": email, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/login", want=("200",), tries=72): + say(" bookstack: the app never served /login") + return False + absent, _ = self._lookup(w, f"nobody-{secrets.token_hex(6)}@gate.invalid") + if absent is not False: + say(f" bookstack: READBACK UNUSABLE — an email that cannot exist did not read absent ({absent})") + return False + found, out = self._lookup(w, t["email"]) + say(f" bookstack: readback of the seeded account found={found} :: {out[:140]}") + return found is True + + +# ============================================================================================= +class Gitea: + """Gitea's own admin CLI creates the first user; its own REST API (basic auth) then creates a + repository and reads it back. Both are the app's own interfaces.""" + sub = "git" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302")): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + out = _gx(w, "gitea", "su", "git", "-c", + f"gitea admin user create --username {user} --password {pw} " + f"--email {user}@gate.invalid --admin --must-change-password=false") + say(f" gitea: admin user create :: {' '.join(out.split())[:140]}") + if "has been successfully created" not in out and "successfully created" not in out: + return None + repo = "drillrepo" + secrets.token_hex(3) + rc, code, body = w.app_curl(sub, "/api/v1/user/repos", "-u", f"{user}:{pw}", + "-H", "Content-Type: application/json", + data=json.dumps({"name": repo, "private": True}), method="POST") + say(f" gitea: create repo http={code}") + if code not in ("201", "200"): + say(f" gitea: repo refused {body[:200]}") + return None + return {"user": user, "pw": pw, "repo": repo} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=36): + return False + rc, code, _ = w.app_curl(sub, f"/api/v1/repos/{t['user']}/nope{secrets.token_hex(4)}", + "-u", f"{t['user']}:{t['pw']}") + if code == "200": + say(" gitea: READBACK UNUSABLE — a repo that cannot exist returned 200") + return False + rc, code, body = w.app_curl(sub, f"/api/v1/repos/{t['user']}/{t['repo']}", + "-u", f"{t['user']}:{t['pw']}") + ok = code == "200" and t["repo"] in body + say(f" gitea: readback of the seeded repo http={code} ok={ok}") + return ok + + +# ============================================================================================= +class Navidrome: + """Navidrome's own REST API: create the first admin through /auth/createAdmin, then prove the + account survives by logging in through the same door.""" + sub = "music" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302")): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + rc, code, out = w.app_curl(sub, "/auth/createAdmin", "-H", "Content-Type: application/json", + data=json.dumps({"username": user, "password": pw}), method="POST") + say(f" navidrome: createAdmin http={code}") + if code not in ("200", "201"): + say(f" navidrome: refused {out[:200]}") + return None + return {"user": user, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=36): + return False + bad = json.dumps({"username": t["user"], "password": "wrong-" + secrets.token_hex(6)}) + rc, code, _ = w.app_curl(sub, "/auth/login", "-H", "Content-Type: application/json", + data=bad, method="POST") + if code in ("200", "201"): + say(" navidrome: READBACK UNUSABLE — a wrong password authenticated") + return False + body = json.dumps({"username": t["user"], "password": t["pw"]}) + rc, code, out = w.app_curl(sub, "/auth/login", "-H", "Content-Type: application/json", + data=body, method="POST") + ok = code in ("200", "201") + say(f" navidrome: login as the seeded user http={code} ok={ok}") + return ok + + +# ============================================================================================= +class Vaultwarden: + """Vaultwarden's own account API: register an account, then prove it survives by asking the app + to issue a token for it (its own login endpoint, the household's own route).""" + sub = "vault" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/alive", want=("200",)): + return None + email = f"drill-{secrets.token_hex(4)}@gate.invalid" + # Vaultwarden stores an already-hashed master key; the value is opaque to the server. + key = base64.b64encode(secrets.token_bytes(32)).decode() + body = json.dumps({"email": email, "name": "drill", "masterPasswordHash": key, + "key": "0." + base64.b64encode(secrets.token_bytes(48)).decode(), + "kdf": 0, "kdfIterations": 600000}) + rc, code, out = w.app_curl(sub, "/api/accounts/register", + "-H", "Content-Type: application/json", + data=body, method="POST") + say(f" vaultwarden: register http={code}") + if code not in ("200", "204"): + say(f" vaultwarden: refused {out[:250]}") + return None + return {"email": email, "key": key} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/alive", want=("200",), tries=36): + return False + def login(pwhash): + return w.app_curl(sub, "/identity/connect/token", + "-H", "Content-Type: application/x-www-form-urlencoded", + data=("grant_type=password&scope=api%20offline_access" + f"&client_id=web&deviceType=9&deviceIdentifier=drill" + f"&deviceName=drill&username={t['email']}&password={pwhash}"), + method="POST") + rc, code, _ = login(base64.b64encode(secrets.token_bytes(32)).decode()) + if code == "200": + say(" vaultwarden: READBACK UNUSABLE — a wrong master key authenticated") + return False + rc, code, out = login(t["key"].replace("+", "%2B").replace("=", "%3D").replace("/", "%2F")) + ok = code == "200" and "access_token" in out + say(f" vaultwarden: token for the seeded account http={code} ok={ok}") + if not ok: + say(f" vaultwarden: body {out[:200]}") + return ok + + +# ============================================================================================= +class Django: + """A Django app's OWN management CLI, inside its own container, against its own User model. + + Same category as BookStack's `php artisan`: the app's own code and its own ORM, never a raw SQL + INSERT and never a planted file (R-156). `createsuperuser --noinput` is Django's own documented + non-interactive route, and the readback asks the SAME ORM whether the account exists. + + THE FIXTURE PROVES ITSELF ON EVERY CALL: each verify() also asks for a username that cannot + exist and requires the answer False. A readback that has broken into always saying True + therefore fails instead of passing everything. + + LIMITATION, recorded rather than papered over: this seeds the DATABASE half only. An app whose + data is also FILES (adventurelog's images) has a file half this fixture does not touch. + """ + + def __init__(self, container, sub, ready_path="/", ready=("200", "302", "301", "404"), + python="python", workdir=None): + # `python` and `workdir` are per-app because the image decides them: adventurelog's + # interpreter is on PATH, tandoor ships a VENV and the bare `python` cannot import Django + # at all ("Couldn't import Django. Are you sure it's installed…"). Measured, not guessed. + self.container = container + self.sub = sub + self.ready_path = ready_path + self.ready = ready + self.python = python + self.workdir = workdir + + def _wd(self): + return f"-w {self.workdir} " if self.workdir else "" + + def _manage(self, w, code): + # -c is passed to `manage.py shell`; the app's own shell, its own ORM. + return w.guest( + f"docker exec {self._wd()}{self.container} {self.python} manage.py shell " + f"-c {json.dumps(code)} 2>&1", timeout=300) + + def _exists(self, w, username): + # ONE LINE, semicolon-separated. A `\n` inside a double-quoted shell argument reaches + # python as a literal backslash-n and is a SyntaxError — which is exactly how the first + # adventurelog run read as `inconclusive`. The fixture refused to guess, which is right, + # but the instrument was the thing that was broken. + out = self._manage(w, ( + "from django.contrib.auth import get_user_model; " + f"print('DRILL_ANSWER=' + str(get_user_model().objects.filter(username={username!r}).exists()))" + )) + m = re.search(r"DRILL_ANSWER=(True|False)", out) + return (m.group(1) == "True") if m else None, " ".join(out.split())[-300:] + + def seed(self, w, sub, say): + if not w.wait_app(sub, self.ready_path, want=self.ready, tries=90): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + out = w.guest( + f"docker exec -e DJANGO_SUPERUSER_PASSWORD={pw} {self._wd()}{self.container} " + f"{self.python} manage.py createsuperuser --noinput " + f"--username {user} --email {user}@gate.invalid 2>&1", timeout=300) + say(f" {self.container}: createsuperuser :: {' '.join(out.split())[:160]}") + got, detail = self._exists(w, user) + if got is not True: + say(f" {self.container}: the account did not appear in the app's own ORM :: {detail[:200]}") + return None + say(f" {self.container}: seeded superuser {user}") + return {"user": user, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, self.ready_path, want=self.ready, tries=90): + say(f" {self.container}: the app never served {self.ready_path}") + return False + absent, detail = self._exists(w, "nobody" + secrets.token_hex(6)) + if absent is not False: + say(f" {self.container}: READBACK UNUSABLE — a username that cannot exist did not " + f"read as absent ({absent}) :: {detail[:200]}") + return False + found, detail = self._exists(w, t["user"]) + say(f" {self.container}: readback of the seeded account found={found}") + if found is not True: + say(f" {self.container}: :: {detail[:250]}") + return found is True + + +# ============================================================================================= +class Nextcloud: + """Nextcloud's OWN admin CLI, `occ`, inside its own container: its own code, its own user + backend. Not a SQL INSERT and not a planted file (R-156). + + `occ user:info` is the readback, and it PROVES ITSELF on every call: a uid that cannot exist + must answer "user not found". A readback that has broken into always succeeding therefore + fails instead of passing everything. + + This is the app chosen for the MariaDB engine-major edge (`09` §3 decision 5, R-469 lifted): + the app image does NOT move, only the `mariadb:` sidecar, so the edge carries exactly one + migration and a failure is readable. + """ + sub = "cloud" + + def _occ(self, w, *args, timeout=420): + import shlex + line = " ".join(shlex.quote(a) for a in args) + return w.guest(f"docker exec -u www-data nextcloud php occ {line} 2>&1", timeout=timeout) + + def _info(self, w, uid): + out = self._occ(w, "user:info", uid) + flat = " ".join(out.split()) + if "user not found" in flat.lower() or "could not be found" in flat.lower(): + return False, flat + if f"user_id: {uid}" in flat or f"- user_id: {uid}" in flat or f"user_id: {uid}" in out: + return True, flat + return None, flat + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/status.php", want=("200",), tries=120): + return None + uid = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + out = w.guest( + f"docker exec -u www-data -e OC_PASS={pw} nextcloud php occ user:add " + f"--password-from-env --display-name={uid} {uid} 2>&1", timeout=420) + say(f" nextcloud: occ user:add :: {' '.join(out.split())[:160]}") + got, flat = self._info(w, uid) + if got is not True: + say(f" nextcloud: the account did not appear via occ user:info :: {flat[:220]}") + return None + say(f" nextcloud: seeded user {uid}") + return {"uid": uid, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/status.php", want=("200",), tries=120): + say(" nextcloud: the app never served /status.php") + return False + absent, flat = self._info(w, "nobody" + secrets.token_hex(6)) + if absent is not False: + say(f" nextcloud: READBACK UNUSABLE — a uid that cannot exist did not read absent " + f"({absent}) :: {flat[:200]}") + return False + found, flat = self._info(w, t["uid"]) + say(f" nextcloud: readback of the seeded user found={found}") + if found is not True: + say(f" nextcloud: :: {flat[:250]}") + return found is True + + +# ============================================================================================= +class Grafana: + """Grafana's own HTTP API as the admin the DEPLOY created. The password is the one the + controller showed the household — read from the app's own `app.yaml`, not invented — and the + data (a folder) goes in and comes back through the app's own REST API.""" + sub = "grafana" + + def _auth(self, w, name="grafana"): + # app.yaml stores this ENCRYPTED (`ENC:…`), so it cannot be read back off the box — which + # is correct, and is why the harness uses the value IT generated for the deploy. + pw = (w.GENERATED.get(name) or {}).get("GF_SECURITY_ADMIN_PASSWORD") or "admin" + return f"admin:{pw}" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/health", want=("200",), tries=72): + return None + au = self._auth(w) + title = "drill-" + secrets.token_hex(5) + rc, code, body = w.app_curl(sub, "/api/folders", "-u", au, + "-H", "Content-Type: application/json", + data=json.dumps({"title": title}), method="POST") + say(f" grafana: create folder http={code}") + if code not in ("200", "201"): + say(f" grafana: refused {body[:220]}") + return None + try: + uid = json.loads(body)["uid"] + except Exception: + say(f" grafana: no uid in {body[:200]}") + return None + return {"uid": uid, "title": title} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/api/health", want=("200",), tries=72): + return False + au = self._auth(w) + rc, code, _ = w.app_curl(sub, "/api/folders/nope" + secrets.token_hex(5), "-u", au) + if code == "200": + say(" grafana: READBACK UNUSABLE — a folder uid that cannot exist returned 200") + return False + rc, code, body = w.app_curl(sub, f"/api/folders/{t['uid']}", "-u", au) + ok = code == "200" and t["title"] in body + say(f" grafana: readback of the seeded folder http={code} ok={ok}") + return ok + + +# ============================================================================================= +class AudiobookShelf: + """audiobookshelf's own /init endpoint creates the first root account; its own /login proves + the account survived. Both are the app's own API.""" + sub = "audiobooks" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/status", want=("200",), tries=72): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + rc, code, body = w.app_curl(sub, "/init", "-H", "Content-Type: application/json", + data=json.dumps({"newRoot": {"username": user, "password": pw}}), + method="POST") + say(f" audiobookshelf: /init http={code}") + if code not in ("200", "204"): + say(f" audiobookshelf: refused {body[:220]}") + return None + return {"user": user, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/status", want=("200",), tries=72): + return False + bad = json.dumps({"username": t["user"], "password": "wrong-" + secrets.token_hex(6)}) + rc, code, _ = w.app_curl(sub, "/login", "-H", "Content-Type: application/json", + data=bad, method="POST") + if code == "200": + say(" audiobookshelf: READBACK UNUSABLE — a wrong password authenticated") + return False + rc, code, body = w.app_curl(sub, "/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": t["user"], "password": t["pw"]}), + method="POST") + ok = code == "200" and t["user"] in body + say(f" audiobookshelf: login as the seeded root http={code} ok={ok}") + return ok + + +# ============================================================================================= +class ActualBudget: + """Actual's own bootstrap API sets the server password; its own login proves it survived.""" + sub = "budget" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=72): + return None + pw = "Drill-" + secrets.token_hex(10) + rc, code, body = w.app_curl(sub, "/account/bootstrap", + "-H", "Content-Type: application/json", + data=json.dumps({"password": pw}), method="POST") + say(f" actualbudget: /account/bootstrap http={code} :: {body[:140]}") + if code not in ("200", "201") or '"status":"ok"' not in body: + return None + return {"pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=72): + return False + def login(p): + return w.app_curl(sub, "/account/login", "-H", "Content-Type: application/json", + data=json.dumps({"loginMethod": "password", "password": p}), + method="POST") + rc, code, body = login("wrong-" + secrets.token_hex(6)) + if '"status":"ok"' in body: + say(" actualbudget: READBACK UNUSABLE — a wrong password authenticated") + return False + rc, code, body = login(t["pw"]) + ok = '"status":"ok"' in body + say(f" actualbudget: login with the seeded password http={code} ok={ok}") + if not ok: + say(f" actualbudget: body {body[:200]}") + return ok + + +# ============================================================================================= +class Mealie: + """Mealie ships a documented first-run admin. We log in as it through the app's own OAuth-style + token endpoint, create a recipe through the app's own API, and read the recipe back.""" + sub = "recipes" + + def _token(self, w, sub, pw="MyPassword"): + rc, code, body = w.app_curl( + sub, "/api/auth/token", "-H", "Content-Type: application/x-www-form-urlencoded", + data=f"username=changeme%40example.com&password={pw}", method="POST") + if code != "200": + return None, f"http={code} {body[:200]}" + try: + return json.loads(body)["access_token"], "" + except Exception: + return None, body[:200] + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/app/about", want=("200",), tries=90): + return None + tok, why = self._token(w, sub) + if not tok: + say(f" mealie: could not authenticate as the first-run admin :: {why}") + return None + name = "drill-" + secrets.token_hex(5) + rc, code, body = w.app_curl(sub, "/api/recipes", "-H", f"Authorization: Bearer {tok}", + "-H", "Content-Type: application/json", + data=json.dumps({"name": name}), method="POST") + say(f" mealie: create recipe http={code}") + if code not in ("200", "201"): + say(f" mealie: refused {body[:220]}") + return None + slug = body.strip().strip('"') + return {"slug": slug, "name": name} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/api/app/about", want=("200",), tries=90): + return False + tok, why = self._token(w, sub) + if not tok: + say(f" mealie: could not authenticate after the update :: {why}") + return False + rc, code, _ = w.app_curl(sub, "/api/recipes/nope" + secrets.token_hex(5), + "-H", f"Authorization: Bearer {tok}") + if code == "200": + say(" mealie: READBACK UNUSABLE — a slug that cannot exist returned 200") + return False + rc, code, body = w.app_curl(sub, f"/api/recipes/{t['slug']}", + "-H", f"Authorization: Bearer {tok}") + ok = code == "200" and t["name"] in body + say(f" mealie: readback of the seeded recipe http={code} ok={ok}") + return ok + + +# ============================================================================================= +class N8n: + """n8n's own owner-setup API creates the first account; its own login proves it survived.""" + sub = "auto" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/healthz", want=("200",), tries=90): + return None + email = f"drill-{secrets.token_hex(4)}@gate.invalid" + pw = "Drill" + secrets.token_hex(8) + "1" + rc, code, body = w.app_curl(sub, "/rest/owner/setup", "-H", "Content-Type: application/json", + data=json.dumps({"email": email, "firstName": "drill", + "lastName": "drill", "password": pw}), + method="POST") + say(f" n8n: /rest/owner/setup http={code}") + if code not in ("200", "201"): + say(f" n8n: refused {body[:220]}") + return None + return {"email": email, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/healthz", want=("200",), tries=90): + return False + def login(p): + return w.app_curl(sub, "/rest/login", "-H", "Content-Type: application/json", + data=json.dumps({"emailOrLdapLoginId": t["email"], "password": p}), + method="POST") + rc, code, _ = login("wrong-" + secrets.token_hex(6)) + if code == "200": + say(" n8n: READBACK UNUSABLE — a wrong password authenticated") + return False + rc, code, body = login(t["pw"]) + ok = code == "200" and t["email"] in body + say(f" n8n: login as the seeded owner http={code} ok={ok}") + return ok + + +# ============================================================================================= +class Zipline: + """Zipline's own setup/login API. Zipline 4 creates the first user through its own endpoint.""" + sub = "img" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/healthcheck", want=("200",), tries=90): + if not w.wait_app(sub, "/", want=("200", "302", "307"), tries=30): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + for path in ("/api/auth/register", "/api/auth/setup"): + rc, code, body = w.app_curl(sub, path, "-H", "Content-Type: application/json", + data=json.dumps({"username": user, "password": pw}), + method="POST") + say(f" zipline: {path} http={code} :: {body[:160]}") + if code in ("200", "201"): + return {"user": user, "pw": pw} + say(" zipline: neither register nor setup accepted a first user") + return None + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302", "307"), tries=60): + return False + def login(p): + return w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": t["user"], "password": p}), + method="POST") + rc, code, _ = login("wrong-" + secrets.token_hex(6)) + if code == "200": + say(" zipline: READBACK UNUSABLE — a wrong password authenticated") + return False + rc, code, body = login(t["pw"]) + ok = code == "200" + say(f" zipline: login as the seeded user http={code} ok={ok}") + return ok + + +# ============================================================================================= +class Vikunja: + """Vikunja's own REST API: register a user, log in, create a project, read the project back. + Four calls, all the app's own front door.""" + sub = "tasks" + + def _token(self, w, sub, t, pw=None): + rc, code, body = w.app_curl(sub, "/api/v1/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": t["user"], + "password": pw or t["pw"]}), method="POST") + if code != "200": + return None, f"http={code} {body[:160]}" + try: + return json.loads(body)["token"], "" + except Exception: + return None, body[:160] + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/v1/info", want=("200",), tries=72): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + rc, code, body = w.app_curl(sub, "/api/v1/register", "-H", "Content-Type: application/json", + data=json.dumps({"username": user, "password": pw, + "email": f"{user}@gate.invalid"}), + method="POST") + say(f" vikunja: register http={code}") + if code not in ("200", "201"): + say(f" vikunja: refused {body[:220]}") + return None + t = {"user": user, "pw": pw} + tok, why = self._token(w, sub, t) + if not tok: + say(f" vikunja: could not log in after registering :: {why}") + return None + title = "drill-" + secrets.token_hex(5) + # Vikunja CREATES with PUT, not POST — a POST answers `405 Method Not Allowed`, which + # reads like a broken fixture and is really the wrong verb. Measured 2026-09-21. + rc, code, body = w.app_curl(sub, "/api/v1/projects", "-H", f"Authorization: Bearer {tok}", + "-H", "Content-Type: application/json", + data=json.dumps({"title": title}), method="PUT") + say(f" vikunja: create project http={code}") + if code not in ("200", "201"): + say(f" vikunja: project refused {body[:220]}") + return None + t["title"] = title + t["pid"] = json.loads(body).get("id") + return t + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/api/v1/info", want=("200",), tries=72): + return False + bad, why = self._token(w, sub, t, pw="wrong-" + secrets.token_hex(6)) + if bad: + say(" vikunja: READBACK UNUSABLE — a wrong password authenticated") + return False + tok, why = self._token(w, sub, t) + if not tok: + say(f" vikunja: the seeded account no longer authenticates :: {why}") + return False + rc, code, body = w.app_curl(sub, f"/api/v1/projects/{t['pid']}", + "-H", f"Authorization: Bearer {tok}") + ok = code == "200" and t["title"] in body + say(f" vikunja: readback of the seeded project http={code} ok={ok}") + return ok + + +# ============================================================================================= +class OpenGist: + """Opengist's own sign-up and sign-in FORMS. + + Two things had to be measured. Its sign-up is CSRF-protected: a bare POST answers 500 with an + HTML page, which reads like a broken app and is really a missing token — fetch the form, keep + its cookie, send its `_csrf` back. And its REST API refuses the account's own password + (`401 {"message":"Bad crendentials"}`) because it wants a token the app will not mint without a + browser. So the SEEDED DATA is the account itself and the READBACK is a real sign-in, which is + the same shape the docmost and navidrome fixtures use. + + LIMITATION, recorded rather than papered over: this is the DATABASE half. A gist's CONTENT is + not seeded, because that needs the API token above. + """ + sub = "gist" + + def _form(self, w, sub, path, jar, fields): + rc, code, html = w.app_curl(sub, path, "-b", jar, "-c", jar) + m = re.search(r'name="_csrf"[^>]*value="([^"]+)"', html or "") + if not m: + return None, f"no _csrf on {path} (http={code})" + body = "&".join([f"_csrf={m.group(1)}"] + [f"{k}={v}" for k, v in fields.items()]) + rc, code, out = w.app_curl(sub, path, "-b", jar, "-c", jar, + "-H", "Content-Type: application/x-www-form-urlencoded", + data=body, method="POST") + return code, out + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=72): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + jar = f"/tmp/og-{secrets.token_hex(4)}.jar" + code, out = self._form(w, sub, "/register", jar, {"username": user, "password": pw}) + say(f" opengist: /register (with its own _csrf) http={code}") + if code not in ("200", "302", "303"): + say(f" opengist: refused {str(out)[:200]}") + return None + return {"user": user, "pw": pw} + + def verify(self, w, sub, t, say): + # Wait for the LOGIN FORM, not for the root page. Measured 2026-09-21: immediately after a + # successful update the root answers while /login does not yet carry its `_csrf`, so the + # sign-in silently fails and the app looks like it lost the account. It had not. + if not w.wait_app(sub, "/login", want=("200",), tries=72): + say(" opengist: /login never came back after the update") + return False + for _ in range(24): + rc, code, html = w.app_curl(sub, "/login") + if code == "200" and '_csrf' in (html or ""): + break + time.sleep(5) + jar = f"/tmp/og-{secrets.token_hex(4)}.jar" + code, _ = self._form(w, sub, "/login", jar, + {"username": t["user"], "password": "wrong-" + secrets.token_hex(5)}) + rc, c2, home = w.app_curl(sub, "/", "-b", jar) + if t["user"] in (home or ""): + say(" opengist: READBACK UNUSABLE — a wrong password signed in") + return False + jar2 = f"/tmp/og-{secrets.token_hex(4)}.jar" + code, _ = self._form(w, sub, "/login", jar2, {"username": t["user"], "password": t["pw"]}) + rc, c2, home = w.app_curl(sub, "/", "-b", jar2) + ok = t["user"] in (home or "") + say(f" opengist: sign-in as the seeded account http={code} name_on_page={ok}") + return ok + + +# ============================================================================================= +class Papra: + """Papra's own e-mail sign-up and sign-in endpoints.""" + sub = "papra" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/health", want=("200",), tries=72): + if not w.wait_app(sub, "/", want=("200", "302"), tries=30): + return None + email = f"drill-{secrets.token_hex(4)}@gate.invalid" + pw = "Drill-" + secrets.token_hex(10) + rc, code, body = w.app_curl(sub, "/api/auth/sign-up/email", + "-H", "Content-Type: application/json", + data=json.dumps({"email": email, "password": pw, + "name": "drill"}), method="POST") + say(f" papra: sign-up http={code}") + if code not in ("200", "201"): + say(f" papra: refused {body[:220]}") + return None + return {"email": email, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=72): + return False + def signin(p): + return w.app_curl(sub, "/api/auth/sign-in/email", + "-H", "Content-Type: application/json", + data=json.dumps({"email": t["email"], "password": p}), method="POST") + rc, code, _ = signin("wrong-" + secrets.token_hex(6)) + if code == "200": + say(" papra: READBACK UNUSABLE — a wrong password authenticated") + return False + rc, code, body = signin(t["pw"]) + ok = code == "200" + say(f" papra: sign-in as the seeded account http={code} ok={ok}") + return ok + + +# ============================================================================================= +class HomeAssistant: + """Home Assistant's own onboarding API creates the owner account and hands back a code the + same API exchanges for a token. Both are the app's own documented non-browser route.""" + sub = "ha" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=120): + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + rc, code, body = w.app_curl(sub, "/api/onboarding/users", + "-H", "Content-Type: application/json", + data=json.dumps({"client_id": f"https://{sub}.felhom.invalid/", + "name": "drill", "username": user, + "password": pw, "language": "en"}), + method="POST") + say(f" home-assistant: /api/onboarding/users http={code}") + if code not in ("200", "201"): + say(f" home-assistant: refused {body[:220]}") + return None + return {"user": user, "pw": pw} + + def _login(self, w, sub, user, pw): + """The app's own login flow: start it, then answer it. A 200 with a step_id of + `mfa`/`init` means the credentials were REFUSED; only `create_entry` is a pass.""" + rc, code, body = w.app_curl(sub, "/auth/login_flow", + "-H", "Content-Type: application/json", + data=json.dumps({"client_id": f"https://{sub}.felhom.invalid/", + "handler": ["homeassistant", None], + "redirect_uri": f"https://{sub}.felhom.invalid/", + "type": "authorize"}), method="POST") + if code not in ("200", "201"): + return None, f"flow start http={code} {body[:160]}" + try: + fid = json.loads(body)["flow_id"] + except Exception: + return None, body[:160] + rc, code, body = w.app_curl(sub, f"/auth/login_flow/{fid}", + "-H", "Content-Type: application/json", + data=json.dumps({"client_id": f"https://{sub}.felhom.invalid/", + "username": user, "password": pw}), + method="POST") + try: + j = json.loads(body) + except Exception: + return None, body[:160] + return (j.get("result") if j.get("type") == "create_entry" else None), body[:200] + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=120): + return False + bad, why = self._login(w, sub, t["user"], "wrong-" + secrets.token_hex(6)) + if bad: + say(" home-assistant: READBACK UNUSABLE — a wrong password authenticated") + return False + good, why = self._login(w, sub, t["user"], t["pw"]) + ok = bool(good) + say(f" home-assistant: login as the seeded owner ok={ok}") + if not ok: + say(f" home-assistant: {why}") + return ok + + +# ============================================================================================= +class Romm: + """RomM's own user API, driven the way RomM's own front end drives it. + + Three things had to be measured rather than guessed, and each one answered a 403 or a 422 that + looked like a different fault: RomM sets a **`romm_csrftoken` cookie** on any GET and requires + it back in an **`x-csrftoken` header** (a bare POST is `403 CSRF token verification failed`, + which reads like an auth problem); the fields go in the **JSON body**, not the query string (a + query-string POST is `422 Field required` for every field it was just given); and `email` is + required alongside username, password and role. + + On a fresh install with no admin the first `POST /api/users` is accepted unauthenticated; + afterwards it is not — which is what makes the readback (`POST /api/login` as that user) a real + authentication rather than a repeat of the seed. + + LIMITATION: this is the DATABASE half. RomM's other half is the ROM library on the drive, which + this does not populate. + """ + sub = "arcade" + + def _csrf(self, w, sub): + jar = f"/tmp/romm-{secrets.token_hex(4)}.jar" + w.app_curl(sub, "/api/heartbeat", "-c", jar) + out = w.sh(["bash", "-lc", f"grep -i csrf {jar} | awk '{{print $7}}'"]).stdout or "" + return jar, out.strip() + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/heartbeat", want=("200",), tries=120): + if not w.wait_app(sub, "/", want=("200", "302"), tries=30): + return None + jar, tok = self._csrf(w, sub) + if not tok: + say(" romm: no romm_csrftoken cookie was set on /api/heartbeat") + return None + user = "drill" + secrets.token_hex(3) + pw = "Drill-" + secrets.token_hex(10) + rc, code, body = w.app_curl( + sub, "/api/users", "-b", jar, "-H", f"x-csrftoken: {tok}", + "-H", "Content-Type: application/json", + data=json.dumps({"username": user, "email": f"{user}@gate.invalid", + "password": pw, "role": "admin"}), method="POST") + say(f" romm: POST /api/users http={code}") + if code not in ("200", "201"): + say(f" romm: refused {body[:220]}") + return None + return {"user": user, "pw": pw} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/api/heartbeat", want=("200",), tries=120): + return False + jar, tok = self._csrf(w, sub) + rc, code, _ = w.app_curl(sub, "/api/login", "-b", jar, "-H", f"x-csrftoken: {tok}", + "-u", f"{t['user']}:wrong-{secrets.token_hex(5)}", method="POST") + if code == "200": + say(" romm: READBACK UNUSABLE — a wrong password authenticated") + return False + rc, code, body = w.app_curl(sub, "/api/login", "-b", jar, "-H", f"x-csrftoken: {tok}", + "-u", f"{t['user']}:{t['pw']}", method="POST") + ok = code == "200" + say(f" romm: login as the seeded user http={code} ok={ok}") + if not ok: + say(f" romm: body {body[:200]}") + return ok + + +FIXTURES = { + "home-assistant": HomeAssistant(), + "romm": Romm(), + "vikunja": Vikunja(), + "opengist": OpenGist(), + "papra": Papra(), + "mealie": Mealie(), + "n8n": N8n(), + "zipline": Zipline(), + "grafana": Grafana(), + "audiobookshelf": AudiobookShelf(), + "actualbudget": ActualBudget(), + "nextcloud": Nextcloud(), + "adventurelog": Django("adventurelog", "travel", "/admin/login/"), + "tandoor": Django("tandoor", "recipes", "/accounts/login/", + python="/opt/recipes/venv/bin/python", workdir="/opt/recipes"), + "privatebin": PrivateBin(), + "docmost": Docmost(), + "bookstack": BookStack(), + "gitea": Gitea(), + "navidrome": Navidrome(), + "vaultwarden": Vaultwarden(), +} diff --git a/scripts/upgrade_fixtures_box28.py b/scripts/upgrade_fixtures_box28.py new file mode 100644 index 0000000..832a896 --- /dev/null +++ b/scripts/upgrade_fixtures_box28.py @@ -0,0 +1,414 @@ +# PORTED 2026-09-23 (night shift, R-462) VERBATIM from felhom.eu/documentation/audits/the-28-2026-09-22/ +# fixtures28.py. See upgrade_fixtures_box.py. +#!/usr/bin/env python3 +"""fixtures28.py — seed/verify for the twenty-eight, same rule as `fixtures.py` (R-156). + +*Nothing is ever seeded into a volume by hand.* Every seed goes in through the app's OWN interface: +its HTTP API through the household's real front door, or its own CLI inside its own container. A raw +SQL INSERT or a planted file is never used. + +An app with no non-browser route returns None from `seed()` and carries a `tried` string naming +what was attempted. That is a RESULT — `inconclusive` — not a gap to be papered over. + +Every `verify()` that can prove itself does so on the same call: it also asks for something that +MUST be absent, so a readback that has broken into always answering "found" fails instead of +passing everything. +""" +import json, re, secrets + + +def _gx(w, container, *cmd, timeout=240): + import shlex + return w.guest(f"docker exec {container} " + " ".join(shlex.quote(c) for c in cmd) + + " 2>&1", timeout=timeout) + + +# ── the *arr family: their own v3 API, key read from their own config ──────────────────────────── +class _Arr: + """radarr / sonarr. The API key is minted by the app into its own config.xml; reading it is + how a household's own client authenticates, and the tag endpoints are ordinary app data.""" + api = "v3" + + def _key(self, w): + out = w.guest(f"docker exec {self.name} cat /config/config.xml 2>/dev/null") + m = re.search(r"([0-9a-f]+)", out or "") + return m.group(1) if m else None + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302", "401")): + return None + k = self._key(w) + if not k: + self.tried = "read ApiKey from the app's own /config/config.xml — not present yet" + say(f" {self.name}: no ApiKey in config.xml yet") + return None + label = "drill" + secrets.token_hex(4) + rc, code, out = w.app_curl(sub, f"/api/{self.api}/tag", "-H", f"X-Api-Key: {k}", + "-H", "Content-Type: application/json", + data=json.dumps({"label": label}), method="POST") + if code not in ("200", "201", "202"): + self.tried = f"POST /api/{self.api}/tag with the app's own key -> {code}" + say(f" {self.name}: POST tag -> {code} {out[:150]}") + return None + say(f" {self.name}: seeded tag {label}") + return {"label": label, "key": k} + + def verify(self, w, sub, t, say): + k = self._key(w) or t["key"] + rc, code, out = w.app_curl(sub, f"/api/{self.api}/tag", "-H", f"X-Api-Key: {k}") + found = t["label"] in (out or "") + # negative control, EVERY call: a label that cannot exist must read as absent + absent = ("drillnope" + secrets.token_hex(6)) not in (out or "") + if not absent: + say(f" {self.name}: READBACK UNUSABLE — an impossible label read as present") + return None + say(f" {self.name}: readback found={found} (http {code}, control passed)") + return found + + +class Radarr(_Arr): + name = "radarr"; sub = "radarr"; route = "its own /api/v3/tag with the app's own ApiKey" + + +class Sonarr(_Arr): + name = "sonarr"; sub = "sonarr"; route = "its own /api/v3/tag with the app's own ApiKey" + + +# ── kimai — its own console, the route the app documents ───────────────────────────────────────── +class Kimai: + sub = "kimai"; route = "its own `bin/console kimai:user:create`" + + def seed(self, w, sub, say): + u = "drill" + secrets.token_hex(4) + out = _gx(w, "kimai", "/opt/kimai/bin/console", "kimai:user:create", u, + f"{u}@example.invalid", "ROLE_USER", "Drill-" + secrets.token_hex(6) + "!aA") + if "success" not in (out or "").lower() and "created" not in (out or "").lower(): + self.tried = "its own `bin/console kimai:user:create` -> " + (out or "")[:200] + say(f" kimai: console create said: {(out or '')[:200]}") + return None + say(f" kimai: seeded user {u}") + return {"user": u} + + def verify(self, w, sub, t, say): + out = _gx(w, "kimai", "/opt/kimai/bin/console", "kimai:user:list") or "" + found = t["user"] in out + absent = ("nope" + secrets.token_hex(6)) not in out + if not absent: + say(" kimai: READBACK UNUSABLE — an impossible user read as present") + return None + say(f" kimai: readback found={found} (control passed)") + return found + + +# ── gramps-web — its own CLI ───────────────────────────────────────────────────────────────────── +class GrampsWeb: + sub = "gramps"; route = "its own `python3 -m gramps_webapi user add`" + + def seed(self, w, sub, say): + u = "drill" + secrets.token_hex(4) + out = _gx(w, "gramps-web", "python3", "-m", "gramps_webapi", "--config", + "/app/config/config.cfg", "user", "add", u, "Drill-" + secrets.token_hex(6)) + if "error" in (out or "").lower() or "traceback" in (out or "").lower(): + self.tried = "its own `gramps_webapi user add` -> " + (out or "")[:200] + say(f" gramps-web: {(out or '')[:200]}") + return None + say(f" gramps-web: seeded user {u}") + return {"user": u} + + def verify(self, w, sub, t, say): + out = _gx(w, "gramps-web", "python3", "-m", "gramps_webapi", "--config", + "/app/config/config.cfg", "user", "list") or "" + found = t["user"] in out + absent = ("nope" + secrets.token_hex(6)) not in out + if not absent: + say(" gramps-web: READBACK UNUSABLE") + return None + say(f" gramps-web: readback found={found} (control passed)") + return found + + +# ── homebox — its own registration + item API ──────────────────────────────────────────────────── +class Homebox: + sub = "homebox"; route = "its own /api/v1/users/register + /api/v1/locations" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302")): + return None + u = "drill" + secrets.token_hex(4) + "@example.invalid" + pw = "Drill-" + secrets.token_hex(8) + "!aA" + rc, code, out = w.app_curl(sub, "/api/v1/users/register", "-H", "Content-Type: application/json", + data=json.dumps({"name": "drill", "email": u, "password": pw}), + method="POST") + if code not in ("200", "201", "204"): + self.tried = f"POST /api/v1/users/register -> {code} {out[:150]}" + say(f" homebox: register -> {code} {out[:150]}") + return None + rc, code, out = w.app_curl(sub, "/api/v1/users/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": u, "password": pw}), method="POST") + try: + tokv = json.loads(out)["token"] + except Exception: + self.tried = f"POST /api/v1/users/login -> {code} {out[:150]}" + say(f" homebox: login -> {code} {out[:150]}") + return None + name = "drillloc" + secrets.token_hex(4) + rc, code, out = w.app_curl(sub, "/api/v1/locations", "-H", f"Authorization: {tokv}", + "-H", "Content-Type: application/json", + data=json.dumps({"name": name, "description": "drill"}), + method="POST") + if code not in ("200", "201"): + self.tried = f"POST /api/v1/locations -> {code} {out[:150]}" + say(f" homebox: create location -> {code} {out[:150]}") + return None + say(f" homebox: seeded location {name}") + return {"name": name, "tok": tokv, "u": u, "pw": pw} + + def verify(self, w, sub, t, say): + rc, code, out = w.app_curl(sub, "/api/v1/users/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": t["u"], "password": t["pw"]}), + method="POST") + try: + tokv = json.loads(out)["token"] + except Exception: + tokv = t["tok"] + rc, code, out = w.app_curl(sub, "/api/v1/locations", "-H", f"Authorization: {tokv}") + found = t["name"] in (out or "") + absent = ("nope" + secrets.token_hex(6)) not in (out or "") + if not absent: + say(" homebox: READBACK UNUSABLE") + return None + say(f" homebox: readback found={found} (http {code}, control passed)") + return found + + +FIXTURES28 = { + "radarr": Radarr(), "sonarr": Sonarr(), "kimai": Kimai(), + "gramps-web": GrampsWeb(), "homebox": Homebox(), +} + + +# ── apps whose front door is a SIGN-UP or SETUP call ───────────────────────────────────────────── +def _neg(w, sub, path, hdr, say, name): + """The negative control every verify() runs: something that CANNOT exist must read absent.""" + rc, code, out = w.app_curl(sub, path, *hdr) + return ("nope" + secrets.token_hex(6)) not in (out or ""), out, code + + +class Termix: + sub = "termix"; route = "its own /users/create sign-up, then /users/me" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302")): + return None + u = "drill" + secrets.token_hex(4) + pw = "Drill-" + secrets.token_hex(8) + "!aA" + for p in ("/users/create", "/api/users/create", "/users/register"): + rc, code, out = w.app_curl(sub, p, "-H", "Content-Type: application/json", + data=json.dumps({"username": u, "password": pw}), + method="POST") + if code in ("200", "201"): + say(f" termix: seeded user {u} via {p}") + return {"u": u, "pw": pw, "path": p} + self.tried = "POST /users/create, /api/users/create, /users/register — none accepted" + say(f" termix: no sign-up route accepted (last {code} {out[:120]})") + return None + + def verify(self, w, sub, t, say): + rc, code, out = w.app_curl(sub, "/users/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": t["u"], "password": t["pw"]}), + method="POST") + found = code in ("200", "201") and ("token" in (out or "") or t["u"] in (out or "")) + rc2, code2, out2 = w.app_curl(sub, "/users/login", "-H", "Content-Type: application/json", + data=json.dumps({"username": "nope" + secrets.token_hex(6), + "password": t["pw"]}), method="POST") + if code2 in ("200", "201"): + say(" termix: READBACK UNUSABLE — an impossible user logged in") + return None + say(f" termix: readback found={found} (http {code}, control refused as it must)") + return found + + +class Ghost: + sub = "blog"; route = "its own /ghost/api/admin/authentication/setup/" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "301", "302")): + return None + title = "Drill-" + secrets.token_hex(6) + u = "drill" + secrets.token_hex(4) + "@example.invalid" + pw = "Drill-" + secrets.token_hex(8) + "aA1" + body = json.dumps({"setup": [{"name": "Drill", "email": u, "password": pw, + "blogTitle": title}]}) + rc, code, out = w.app_curl(sub, "/ghost/api/admin/authentication/setup/", + "-H", "Content-Type: application/json", + "-H", "Accept-Version: v5.0", data=body, method="POST") + if code not in ("200", "201"): + self.tried = f"POST /ghost/api/admin/authentication/setup/ -> {code} {out[:150]}" + say(f" ghost: setup -> {code} {out[:160]}") + return None + say(f" ghost: seeded site title {title}") + return {"title": title, "u": u} + + def verify(self, w, sub, t, say): + rc, code, out = w.app_curl(sub, "/", "-L") + found = t["title"] in (out or "") + absent = ("Drill-nope" + secrets.token_hex(6)) not in (out or "") + if not absent: + say(" ghost: READBACK UNUSABLE") + return None + say(f" ghost: readback found={found} (http {code}, control passed)") + return found + + +class Komga: + sub = "komga"; route = "its own POST /api/v1/claim, then GET /api/v1/users/me" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302", "401")): + return None + u = "drill" + secrets.token_hex(4) + "@example.invalid" + pw = "Drill-" + secrets.token_hex(8) + rc, code, out = w.app_curl(sub, "/api/v1/claim", "-H", f"X-Komga-Email: {u}", + "-H", f"X-Komga-Password: {pw}", method="POST") + if code not in ("200", "201"): + self.tried = f"POST /api/v1/claim -> {code} {out[:150]}" + say(f" komga: claim -> {code} {out[:150]}") + return None + say(f" komga: claimed the server as {u}") + return {"u": u, "pw": pw} + + def verify(self, w, sub, t, say): + import base64 as _b + a = _b.b64encode(f"{t['u']}:{t['pw']}".encode()).decode() + rc, code, out = w.app_curl(sub, "/api/v1/users/me", "-H", f"Authorization: Basic {a}") + found = code == "200" and t["u"] in (out or "") + bad = _b.b64encode(f"nope{secrets.token_hex(6)}:{t['pw']}".encode()).decode() + rc2, code2, _ = w.app_curl(sub, "/api/v1/users/me", "-H", f"Authorization: Basic {bad}") + if code2 == "200": + say(" komga: READBACK UNUSABLE — an impossible user authenticated") + return None + say(f" komga: readback found={found} (http {code}, control refused {code2})") + return found + + +class Immich: + sub = "photos"; route = "its own /api/auth/admin-sign-up, then an album" + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=90): + return None + u = "drill" + secrets.token_hex(4) + "@example.invalid" + pw = "Drill-" + secrets.token_hex(8) + rc, code, out = w.app_curl(sub, "/api/auth/admin-sign-up", "-H", "Content-Type: application/json", + data=json.dumps({"email": u, "password": pw, "name": "Drill"}), + method="POST") + if code not in ("200", "201"): + self.tried = f"POST /api/auth/admin-sign-up -> {code} {out[:150]}" + say(f" immich: sign-up -> {code} {out[:160]}") + return None + rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json", + data=json.dumps({"email": u, "password": pw}), method="POST") + try: + at = json.loads(out)["accessToken"] + except Exception: + self.tried = f"POST /api/auth/login -> {code} {out[:150]}" + return None + name = "drillalbum" + secrets.token_hex(4) + rc, code, out = w.app_curl(sub, "/api/albums", "-H", f"Authorization: Bearer {at}", + "-H", "Content-Type: application/json", + data=json.dumps({"albumName": name}), method="POST") + if code not in ("200", "201"): + self.tried = f"POST /api/albums -> {code} {out[:150]}" + say(f" immich: album -> {code} {out[:150]}") + return None + say(f" immich: seeded album {name}") + return {"name": name, "u": u, "pw": pw} + + def verify(self, w, sub, t, say): + rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json", + data=json.dumps({"email": t["u"], "password": t["pw"]}), + method="POST") + try: + at = json.loads(out)["accessToken"] + except Exception: + say(f" immich: could not log back in (http {code})") + return False + rc, code, out = w.app_curl(sub, "/api/albums", "-H", f"Authorization: Bearer {at}") + found = t["name"] in (out or "") + absent = ("nope" + secrets.token_hex(6)) not in (out or "") + if not absent: + say(" immich: READBACK UNUSABLE") + return None + say(f" immich: readback found={found} (http {code}, control passed)") + return found + + +class _MediaServer: + """jellyfin / emby — the startup wizard IS the front door on a fresh install.""" + def seed(self, w, sub, say): + if not w.wait_app(sub, "/", want=("200", "302"), tries=90): + return None + u = "drill" + secrets.token_hex(4) + pw = "Drill-" + secrets.token_hex(8) + rc, code, out = w.app_curl(sub, "/Startup/User", "-H", "Content-Type: application/json", + data=json.dumps({"Name": u, "Password": pw}), method="POST") + if code not in ("200", "204"): + self.tried = f"POST /Startup/User -> {code} {out[:150]}" + say(f" {self.name}: /Startup/User -> {code} {out[:150]}") + return None + w.app_curl(sub, "/Startup/Complete", method="POST") + say(f" {self.name}: seeded first user {u}") + return {"u": u} + + def verify(self, w, sub, t, say): + rc, code, out = w.app_curl(sub, "/Users/Public") + found = t["u"] in (out or "") + absent = ("nope" + secrets.token_hex(6)) not in (out or "") + if not absent: + say(f" {self.name}: READBACK UNUSABLE") + return None + say(f" {self.name}: readback found={found} (http {code}, control passed)") + return found + + +class Jellyfin(_MediaServer): + name = "jellyfin"; sub = "jellyfin"; route = "its own /Startup/User wizard, then /Users/Public" + + +class Emby(_MediaServer): + name = "emby"; sub = "emby"; route = "its own /Startup/User wizard, then /Users/Public" + + +class NoRoute: + """An app whose only way in is a browser. The fixture RUNS, states what it tried, and returns + None. `inconclusive` with the attempts named is a result; a blank is not.""" + def __init__(self, name, sub, tried): + self.name, self.sub, self.tried = name, sub, tried + self.route = "none — " + tried + + def seed(self, w, sub, say): + w.wait_app(sub, "/", want=("200", "301", "302", "401", "403"), tries=30) + say(f" {self.name}: no non-browser seed route — {self.tried}") + return None + + def verify(self, w, sub, t, say): + return False + + +FIXTURES28.update({ + "termix": Termix(), "ghost": Ghost(), "komga": Komga(), "immich": Immich(), + "jellyfin": Jellyfin(), "emby": Emby(), + "code-server": NoRoute("code-server", "code", "its front door is a browser IDE behind one " + "password; it exposes no data API, and writing a file with docker exec " + "would not be the front door (R-156)"), + "onlyoffice": NoRoute("onlyoffice", "office", "a stateless document server: it holds no " + "household data of its own, so there is nothing to seed"), + "homepage": NoRoute("homepage", "home", "a dashboard rendered from config files in the " + "template; it stores no household data"), + "plex": NoRoute("plex", "plex", "the first-run claim needs a token minted at plex.tv by a " + "real Plex account; no account exists for this venue"), + "outline": NoRoute("outline", "outline", "sign-in requires an external identity provider " + "(OIDC/Slack/Google); no local sign-up route exists"), + "rallly": NoRoute("rallly", "rallly", "sign-in is an e-mail magic link; this venue has no " + "mailbox the harness can read"), +}) diff --git a/templates/actualbudget/.felhom.yml b/templates/actualbudget/.felhom.yml index c958fd6..6915ee4 100644 --- a/templates/actualbudget/.felhom.yml +++ b/templates/actualbudget/.felhom.yml @@ -90,3 +90,9 @@ i18n: - env_var: SUBDOMAIN label: 'Subdomain' description: 'The subdomain this app answers on' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"actualbudget": "actualbudget/actual-server:26.7.0"}, "to": {"actualbudget": "actualbudget/actual-server:26.9.0"}, "digest": {"actualbudget": "sha256:552beab3dec8c93d46b8b9245612d63c3f123b8a45063a474f53e229b17621d3"}, "verdict": "proven", "tested_at": "2026-09-21T18:37:49.287537+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/actualbudget/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 2060032; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/audiobookshelf/.felhom.yml b/templates/audiobookshelf/.felhom.yml index 2e05b78..81cc898 100644 --- a/templates/audiobookshelf/.felhom.yml +++ b/templates/audiobookshelf/.felhom.yml @@ -119,3 +119,9 @@ i18n: label: 'Audiobook library path' description: 'The path to the external hard drive' placeholder: '/mnt/felhom-drives/hdd_1' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"audiobookshelf": "ghcr.io/advplyr/audiobookshelf:2.35.1"}, "to": {"audiobookshelf": "ghcr.io/advplyr/audiobookshelf:2.36.1"}, "digest": {"audiobookshelf": "sha256:3528a93b6442ffe54bd46771bbbab7c97084e1101071586d9dc2254f30bb4358"}, "verdict": "proven", "tested_at": "2026-09-21T18:44:43.824636+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/audiobookshelf/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 6525b8e; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/bookstack/.felhom.yml b/templates/bookstack/.felhom.yml index ba75a75..1ee90f8 100644 --- a/templates/bookstack/.felhom.yml +++ b/templates/bookstack/.felhom.yml @@ -106,3 +106,9 @@ i18n: label: 'Database password' - env_var: APP_KEY label: 'Application key' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"bookstack": "lscr.io/linuxserver/bookstack:26.05.2", "bookstack-db": "mariadb:12.3"}, "to": {"bookstack": "lscr.io/linuxserver/bookstack:26.05.5", "bookstack-db": "mariadb:12.3"}, "digest": {"bookstack": "sha256:189c796273469115cf810e53f450e15b93184018e4728cd65fcaef8aa93584bc", "bookstack-db": "sha256:805c8e104bd563d5bfa24fadd3f31cd419ea859cb5277f32b5dbf2db714f9ed1"}, "verdict": "proven", "tested_at": "2026-09-21T18:25:39.453490+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/bookstack/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit ac4828f; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/docmost/.felhom.yml b/templates/docmost/.felhom.yml index 68d73e7..49c54ba 100644 --- a/templates/docmost/.felhom.yml +++ b/templates/docmost/.felhom.yml @@ -113,3 +113,9 @@ i18n: label: 'Database password' - env_var: APP_SECRET label: 'Application encryption key' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"docmost": "docmost/docmost:0.95.0", "docmost-postgres": "postgres:16-alpine", "docmost-redis": "redis:7-alpine"}, "to": {"docmost": "docmost/docmost:0.96.0", "docmost-postgres": "postgres:16-alpine", "docmost-redis": "redis:7-alpine"}, "digest": {"docmost": "sha256:b56947fcfd08aab8fae12a377e1792784786adbf8b96e4281f14ef4fc072685a", "docmost-postgres": "sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea", "docmost-redis": "sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499"}, "verdict": "proven", "tested_at": "2026-09-21T18:21:18.018992+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/docmost/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 6d8cd87; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/emby/.felhom.yml b/templates/emby/.felhom.yml index 324ee0b..090b030 100644 --- a/templates/emby/.felhom.yml +++ b/templates/emby/.felhom.yml @@ -117,3 +117,9 @@ i18n: label: 'Media library path' description: 'The path to the external hard drive' placeholder: '/mnt/felhom-drives/hdd_1' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"emby": "emby/embyserver:4.10.0.20"}, "to": {"emby": "emby/embyserver:4.11.0.1"}, "digest": {"emby": "sha256:bcc54978db53e333c5b693948447df0ed9dfa7c798a751a9402ff4da4909a6e2"}, "verdict": "proven", "tested_at": "2026-09-22T12:07:44.952377+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/the-28-2026-09-22/apps/emby/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 7a6797b; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/ghost/.felhom.yml b/templates/ghost/.felhom.yml index 0d86de4..55d6686 100644 --- a/templates/ghost/.felhom.yml +++ b/templates/ghost/.felhom.yml @@ -92,3 +92,9 @@ i18n: - env_var: SUBDOMAIN label: 'Subdomain' description: 'The subdomain this app answers on' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"ghost": "ghost:6.53.0-alpine"}, "to": {"ghost": "ghost:6.64.0-alpine"}, "digest": {"ghost": "sha256:47ecbe856dd06dcd20cb9410e1c4d532fc085eba776cfabc203609c7d15d8320"}, "verdict": "proven", "tested_at": "2026-09-22T13:11:12.069214+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/the-28-2026-09-22/apps/ghost/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit acbfafa; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/grafana/.felhom.yml b/templates/grafana/.felhom.yml index fcbda23..2211788 100644 --- a/templates/grafana/.felhom.yml +++ b/templates/grafana/.felhom.yml @@ -105,3 +105,9 @@ i18n: - env_var: GF_SECURITY_ADMIN_PASSWORD label: 'Admin password' description: 'For the first sign-in. You can change it in the app afterwards.' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"grafana": "grafana/grafana:13.1.0"}, "to": {"grafana": "grafana/grafana:13.2.2"}, "digest": {"grafana": "sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0"}, "verdict": "proven", "tested_at": "2026-09-21T19:02:25.646053+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/grafana/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 068f445; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/home-assistant/.felhom.yml b/templates/home-assistant/.felhom.yml index 8db4c8d..a504e6d 100644 --- a/templates/home-assistant/.felhom.yml +++ b/templates/home-assistant/.felhom.yml @@ -95,3 +95,9 @@ i18n: - env_var: SUBDOMAIN label: 'Subdomain' description: 'The subdomain this app answers on' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"home-assistant": "ghcr.io/home-assistant/home-assistant:2026.7.2"}, "to": {"home-assistant": "ghcr.io/home-assistant/home-assistant:2026.9.3"}, "digest": {"home-assistant": "sha256:d8922685169707fd91e8b9729902d975f06157d005e422874d201e0261dda196"}, "verdict": "proven", "tested_at": "2026-09-21T19:10:14.843396+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/home-assistant/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 4405f12; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/immich/.felhom.yml b/templates/immich/.felhom.yml index c42dfc0..e572209 100644 --- a/templates/immich/.felhom.yml +++ b/templates/immich/.felhom.yml @@ -142,3 +142,9 @@ i18n: label: 'Data storage path' description: 'The path to the external hard drive where the photos and videos are kept' placeholder: '/mnt/felhom-drives/hdd_1' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"immich-server": "ghcr.io/immich-app/immich-server:v3.0.3", "immich-machine-learning": "ghcr.io/immich-app/immich-machine-learning:v3.0.3", "immich-postgres": "ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0", "immich-redis": "redis:7-alpine"}, "to": {"immich-server": "ghcr.io/immich-app/immich-server:v3.2.2", "immich-machine-learning": "ghcr.io/immich-app/immich-machine-learning:v3.0.3", "immich-postgres": "ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0", "immich-redis": "redis:7-alpine"}, "digest": {"immich-machine-learning": "sha256:d76fe88b69282c09a97eac4f82dafa82cfd77bce274bc742591cde974f87dacb", "immich-postgres": "sha256:1a078b237c1d9b420b0ee59147386b4aa60d3a07a8e6a402fc84a57e41b043a4", "immich-redis": "sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499", "immich-server": "sha256:79cc1623323d5894922686d8743b4780181428f98eecbfb58ce12c41ef02d1ea"}, "verdict": "proven", "tested_at": "2026-09-22T12:12:19.159912+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/the-28-2026-09-22/apps/immich/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 12c1270; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/mealie/.felhom.yml b/templates/mealie/.felhom.yml index 1b9df7c..3b82ef3 100644 --- a/templates/mealie/.felhom.yml +++ b/templates/mealie/.felhom.yml @@ -109,3 +109,9 @@ i18n: - env_var: SUBDOMAIN label: 'Subdomain' description: 'The subdomain this app answers on' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"mealie": "ghcr.io/mealie-recipes/mealie:v3.20.1"}, "to": {"mealie": "ghcr.io/mealie-recipes/mealie:v3.27.0"}, "digest": {"mealie": "sha256:ba24b88462380fb59a6c7d04c6d9e607e0b6b04e31306592181186bcdab1952b"}, "verdict": "proven", "tested_at": "2026-09-21T20:20:09.681676+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/mealie/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 008348b; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/n8n/.felhom.yml b/templates/n8n/.felhom.yml index b152a90..67fb824 100644 --- a/templates/n8n/.felhom.yml +++ b/templates/n8n/.felhom.yml @@ -101,3 +101,9 @@ i18n: description: 'The subdomain this app answers on' - env_var: N8N_ENCRYPTION_KEY label: 'Encryption key' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"n8n": "n8nio/n8n:2.31.3"}, "to": {"n8n": "n8nio/n8n:2.40.5"}, "digest": {"n8n": "sha256:9f693fd5565539efd5e75ad168526c8041a6af516d9e50bc4d9cb1c9c5031523"}, "verdict": "proven", "tested_at": "2026-09-21T18:55:28.852396+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/n8n/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 4132e35; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/navidrome/.felhom.yml b/templates/navidrome/.felhom.yml index 70d6c05..75f2018 100644 --- a/templates/navidrome/.felhom.yml +++ b/templates/navidrome/.felhom.yml @@ -115,3 +115,9 @@ i18n: label: 'Music collection path' description: 'The path to the external hard drive where the music files are kept' placeholder: '/mnt/felhom-drives/hdd_1' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"navidrome": "deluan/navidrome:0.63.2"}, "to": {"navidrome": "deluan/navidrome:0.64.0"}, "digest": {"navidrome": "sha256:a384948b81bd1529986c5960169e7fc4fa00f46bde6bd517971a4c36671db2af"}, "verdict": "proven", "tested_at": "2026-09-21T18:42:51.148860+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/navidrome/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 7708a04; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/nextcloud/.felhom.yml b/templates/nextcloud/.felhom.yml index f6ce82d..61e3ea0 100644 --- a/templates/nextcloud/.felhom.yml +++ b/templates/nextcloud/.felhom.yml @@ -169,3 +169,9 @@ i18n: - env_var: NEXTCLOUD_ADMIN_PASSWORD label: 'Admin password' description: 'For the first sign-in. You can change it in the app afterwards.' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"nextcloud": "nextcloud:34.0.1-apache", "nextcloud-db": "mariadb:11.6", "nextcloud-redis": "redis:7-alpine"}, "to": {"nextcloud": "nextcloud:34.0.1-apache", "nextcloud-db": "mariadb:12.3", "nextcloud-redis": "redis:7-alpine"}, "digest": {"nextcloud": "sha256:b52f7bc0e496f227b0e85e3b88571a42c68b6245ccde29d577e733227715dcf5", "nextcloud-db": "sha256:805c8e104bd563d5bfa24fadd3f31cd419ea859cb5277f32b5dbf2db714f9ed1", "nextcloud-redis": "sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499"}, "verdict": "proven", "tested_at": "2026-09-21T19:37:10.235635+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/nextcloud-engine-mariadb/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 39374d5; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image; the commit cited no record — this one was named by the backfill because its from/to refs are the commit's and the commit describes the same walk"} diff --git a/templates/papra/.felhom.yml b/templates/papra/.felhom.yml index 301a7de..014e180 100644 --- a/templates/papra/.felhom.yml +++ b/templates/papra/.felhom.yml @@ -94,3 +94,9 @@ i18n: label: 'Subdomain' - env_var: AUTH_SECRET label: 'Session signing key' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"papra": "ghcr.io/papra-hq/papra:26.6.1-rootless"}, "to": {"papra": "ghcr.io/papra-hq/papra:26.6.2-rootless"}, "digest": {"papra": "sha256:a281cb44176dbe5323e0f7ea2d6fd34d58914a3a8525c36437a086d1d7c4fef8"}, "verdict": "proven", "tested_at": "2026-09-21T19:05:47.060201+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/papra/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit e96887e; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/privatebin/.felhom.yml b/templates/privatebin/.felhom.yml index 2dbbb6f..6ccf5a4 100644 --- a/templates/privatebin/.felhom.yml +++ b/templates/privatebin/.felhom.yml @@ -90,3 +90,9 @@ i18n: - env_var: SUBDOMAIN label: "Subdomain" description: "The subdomain this app answers on" + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"privatebin": "privatebin/pdo:2.0.5"}, "to": {"privatebin": "privatebin/pdo:2.0.6"}, "digest": {"privatebin": "sha256:4c141b2326f8b353598ce9ce7507a9cfecf2dad5c60a39fea903d430e296d8f5"}, "verdict": "proven", "tested_at": "2026-09-21T18:19:14.044130+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/privatebin/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit f547f16; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/radarr/.felhom.yml b/templates/radarr/.felhom.yml index f70cbb0..d29d339 100644 --- a/templates/radarr/.felhom.yml +++ b/templates/radarr/.felhom.yml @@ -119,3 +119,9 @@ i18n: label: 'Media library path' description: 'The path to the external hard drive' placeholder: '/mnt/felhom-drives/hdd_1' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"radarr": "lscr.io/linuxserver/radarr:6.3.0"}, "to": {"radarr": "lscr.io/linuxserver/radarr:6.4.4"}, "digest": {"radarr": "sha256:adb6c09d6b729ea5e642c99cea35af72702ef476bf4763f153299ac5db9f0b4f"}, "verdict": "proven", "tested_at": "2026-09-22T11:56:00.319285+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/the-28-2026-09-22/apps/radarr/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit b7b0479; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/romm/.felhom.yml b/templates/romm/.felhom.yml index cf4f22f..85906c4 100644 --- a/templates/romm/.felhom.yml +++ b/templates/romm/.felhom.yml @@ -234,3 +234,9 @@ i18n: - env_var: MOBYGAMES_API_KEY label: "MobyGames API Key" help_text: 'Sign up on MobyGames, then ask for a key on the API page. It gives detailed game information and credits.' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"romm": "rommapp/romm:5.0.0", "romm-db": "mariadb:11.4", "romm-redis": "redis:7-alpine"}, "to": {"romm": "rommapp/romm:5.3.0", "romm-db": "mariadb:11.4", "romm-redis": "redis:7-alpine"}, "digest": {"romm": "sha256:dc586cb3a2c7316fcffb3dc273171b1964523f0f409e989295d26d2199df1d4e", "romm-db": "sha256:70cc072b29b4a89ae07abb2d4da2c64678a7f2dfe092751bb51c87d67dc1338b", "romm-redis": "sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499"}, "verdict": "proven", "tested_at": "2026-09-21T19:27:03.627376+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/romm/verdict.json", "memory_peak_pct": 80.9, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": true}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 15f9ebf; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image; memory watch from felhom.eu/documentation/audits/update-rulings-2026-09-23/harness/evidence/M1/verdict.json (bench, harness v2): peak 80.9%"} diff --git a/templates/sonarr/.felhom.yml b/templates/sonarr/.felhom.yml index 481a012..2dea239 100644 --- a/templates/sonarr/.felhom.yml +++ b/templates/sonarr/.felhom.yml @@ -119,3 +119,9 @@ i18n: label: 'Media library path' description: 'The path to the external hard drive' placeholder: '/mnt/felhom-drives/hdd_1' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"sonarr": "lscr.io/linuxserver/sonarr:4.0.19"}, "to": {"sonarr": "lscr.io/linuxserver/sonarr:4.0.20"}, "digest": {"sonarr": "sha256:a5c1a5fecbef946927ab90ad68df319ac5fe644057e5fc18cd993f01ac07b2b2"}, "verdict": "proven", "tested_at": "2026-09-22T11:58:13.323688+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/the-28-2026-09-22/apps/sonarr/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 0b283d2; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/tandoor/.felhom.yml b/templates/tandoor/.felhom.yml index 212b038..bd43158 100644 --- a/templates/tandoor/.felhom.yml +++ b/templates/tandoor/.felhom.yml @@ -108,3 +108,9 @@ i18n: label: 'Database password' - env_var: SECRET_KEY label: 'Encryption key' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"tandoor": "ghcr.io/tandoorrecipes/recipes:2.6.13", "tandoor-postgres": "postgres:16-alpine"}, "to": {"tandoor": "ghcr.io/tandoorrecipes/recipes:2.6.15", "tandoor-postgres": "postgres:16-alpine"}, "digest": {"tandoor": "sha256:2e759dd1478a2ed119ee474e28522079fb1cfa50b3fd25cba89f6b9a67abad72", "tandoor-postgres": "sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea"}, "verdict": "proven", "tested_at": "2026-09-22T08:52:42.724401+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/tandoor/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit c3807c7; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/termix/.felhom.yml b/templates/termix/.felhom.yml index f868637..0659bf1 100644 --- a/templates/termix/.felhom.yml +++ b/templates/termix/.felhom.yml @@ -78,3 +78,9 @@ i18n: - env_var: SUBDOMAIN label: 'Subdomain' description: 'The subdomain this app answers on' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"termix": "ghcr.io/lukegus/termix:2.5.0"}, "to": {"termix": "ghcr.io/lukegus/termix:2.8.0"}, "digest": {"termix": "sha256:25e8a0eb39f45c9ac5e8e7615fd84a0380018ea012317bc665b86458d900b4b9"}, "verdict": "proven", "tested_at": "2026-09-22T11:35:36.476009+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/the-28-2026-09-22/apps/termix/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 8898b1d; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"} diff --git a/templates/vikunja/.felhom.yml b/templates/vikunja/.felhom.yml index 13f888b..78c7fc8 100644 --- a/templates/vikunja/.felhom.yml +++ b/templates/vikunja/.felhom.yml @@ -99,3 +99,9 @@ i18n: description: 'The subdomain this app answers on' - env_var: VIKUNJA_SERVICE_JWTSECRET label: 'JWT encryption key' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"vikunja": "vikunja/vikunja:2.3.0"}, "to": {"vikunja": "vikunja/vikunja:2.6.0"}, "digest": {"vikunja": "sha256:417ada6f94e81f0267aa2f007d0a811fc82d38dd2aa58351e3ea520ca01c2ea5"}, "verdict": "proven", "tested_at": "2026-09-21T19:25:26.344387+00:00", "harness_version": 1, "evidence": "felhom.eu/documentation/audits/update-night-2026-09-21/apps/vikunja/verdict.json", "memory_peak_pct": null, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "backfilled": "2026-09-23", "note": "backfilled from catalog commit 22f598b; box walk only (harness v1, no memory watch); digest = what the registry served on 2026-09-23, not a measurement of the tested image"}