test record: an image move must carry its proof (09 decision 13, part 4)
gates / gates (push) Successful in 1s

update_ladder: in .felhom.yml, one JSON entry per line (spiked live on
controller v0.266.0 and v0.267.0 first). Two gates: check-test-record.py
(static, CI too) and check-test-record-move.py (history + registry for
moved refs only). 16 decoys, 3 red-proofs. The ONLY writer is
upgrade-test.py --write-ladder (bench AND box proven, digests resolved).
Harness v3: box fixtures on the bench, files_may_change.
Backfill: the 21 moves of 2026-09-22, 21 proven from their records.
No image: line moved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 20:52:32 +02:00
parent cfcfe52784
commit 6db08a5eb3
38 changed files with 2944 additions and 37 deletions
+25
View File
@@ -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 <app> <svc>=<ref>` 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
+8
View File
@@ -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 —
+18 -21
View File
@@ -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/<to>.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).
+10 -1
View File
@@ -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 <app> <svc>=<ref>`
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)
+11
View File
@@ -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"}
+195
View File
@@ -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 <A>..<B> # 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 <A>..<B> (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:]))
+98
View File
@@ -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:]))
+109
View File
@@ -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:]))
+193
View File
@@ -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 | "<why>", "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"
+140
View File
@@ -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] <commit> ...
For each commit: the app is the one template whose images it moved; `from`/`to` are the per-service
images at <commit>~1 and <commit>; 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: <today>` 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:]))
+8 -4
View File
@@ -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
+122
View File
@@ -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)
+103
View File
@@ -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)
+193 -11
View File
@@ -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] <edge-id> [<edge-id> …] (see EDGES)
python3 upgrade-test.py [--soak SECONDS] --move <app> <svc>=<ref> [...] (FROM = the template)
python3 upgrade-test.py --write-ladder <verdict.json> --box <box verdict.json> \
--catalog <checkout> --evidence <rel> [--box-evidence <rel>] (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 <app> 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 <bench verdict.json> --box <box verdict.json> --catalog <checkout> --evidence <rel>
[--box-evidence <rel>]` — 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']}")
+160
View File
@@ -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 <app> <cli>`,
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))
File diff suppressed because it is too large Load Diff
+414
View File
@@ -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"<ApiKey>([0-9a-f]+)</ApiKey>", 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"),
})
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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%"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}
+6
View File
@@ -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"}