test record: an image move must carry its proof (09 decision 13, part 4)
gates / gates (push) Successful in 1s
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:
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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"
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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']}")
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
})
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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%"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user