6a3ead9ebe
gates / gates (push) Successful in 2s
- A RE-TEST entry: from == to, digest = the registry's new digest, digest_from = the tested one, box_evidence. ladder.check_entry refuses one with no new digest, no digest_from or no box proof; check-test-record rule 2b ties digest_from to the previous entry's digest; check-test-record-move now judges re-tests too (they change .felhom.yml only — the gate looked at compose moves alone) and refuses a digest the registry no longer serves. Decoys: 8 cases in test_gate_decoys.py, seen red with the rules switched off. - upgrade-test.py --retest <app> [svc]: FROM the ladder head's tested digest TO the registry's current one, the full method; --write-ladder writes a re-test entry (plain refs + digest_from), refusing without the box venue or when the registry moved again. Writer tests, red-proofed. - scripts/retest-floating.py — ONE command: --dry-run lists, --engines-only is the ruled start; bench, box (retest_box.py on 9202 via the drill catalog), writer, gates, one commit per app. box_walk.py moves the box client into the catalog. Run today: nothing to re-test on the database/redis lines. - End to end on 9202 (drill): docmost at the OLD redis digest, the re-test, "run tonight's chain now" -> the leg pressed it, the new digest runs, read back, badge current. - Also: upgrade-test.py BENCH_ENV_OVERRIDES (R-739, wanderer's DB address on the bench, recorded per verdict); test_gate_decoys.py read kimai's tag and date from the clone (red on main since kimai moved). Evidence: felhom.eu/documentation/audits/night-rulings-2026-09-30/A/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
139 lines
7.2 KiB
Python
139 lines
7.2 KiB
Python
#!/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`;
|
|
2b. a RE-TEST (`from` == `to`, `09` §3 decision 52) starts FROM the previous entry's digest
|
|
(`digest_from`), and is never the first entry;
|
|
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.
|
|
4. every entry but the newest carries its OWN definition at `steps/<step_key(to)>.yml`, whose
|
|
images per service are EXACTLY that entry's `to` (`09` §6.4 part 5: the box pins that file, one
|
|
step per press). The name alone is not the fact: the file's own `image:` lines are read.
|
|
4b. ... and its own `.felhom.yml` at `steps/<step_key(to)>.felhom.yml` (R-664), with a healthcheck
|
|
when the template has one.
|
|
|
|
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))
|
|
# rule 2b (decision 52): a RE-TEST (from == to) was tested FROM the previous entry's digest — never from some
|
|
# other build of the tag — and is never the first entry (there is nothing to re-test).
|
|
for i, e in enumerate(entries):
|
|
if e.get("from") != e.get("to") or not isinstance(e.get("digest_from"), dict):
|
|
continue
|
|
if i == 0:
|
|
problems.append("entry 1 is a re-test (from == to) — a re-test needs an earlier entry to re-test")
|
|
continue
|
|
prev = entries[i - 1].get("digest") or {}
|
|
bad = sorted(s for s, d in e["digest_from"].items() if prev.get(s) != d)
|
|
if bad:
|
|
problems.append("entry %d is a re-test FROM %s, but entry %d tested %s — a re-test starts from the "
|
|
"previous entry's digest" % (i + 1, {s: e["digest_from"][s][:19] for s in bad}, i,
|
|
{s: str(prev.get(s))[:19] for s in bad}))
|
|
for i, e in enumerate(entries[:-1]):
|
|
to = e.get("to")
|
|
if not isinstance(to, dict) or not to:
|
|
continue # already convicted by check_entry
|
|
sp = os.path.join(app_dir, ladder.step_file(to))
|
|
if not os.path.isfile(sp):
|
|
problems.append("entry %d of %d has no definition at %s — the box climbs one step at a time "
|
|
"and needs this step's own compose file" % (i + 1, len(entries), ladder.step_file(to)))
|
|
continue
|
|
got = ladder.images_in(open(sp, encoding="utf-8").read())
|
|
if got != to:
|
|
problems.append("%s names %s, but entry %d's `to` is %s" % (ladder.step_file(to), got, i + 1, to))
|
|
# rule 4b (R-664, 2026-09-24): the step's own .felhom.yml — the box judges the step with it.
|
|
smp = os.path.join(app_dir, ladder.step_meta_file(to))
|
|
if not os.path.isfile(smp):
|
|
problems.append("entry %d of %d has no %s — the box would judge the step with the head's probe (R-664)"
|
|
% (i + 1, len(entries), ladder.step_meta_file(to)))
|
|
elif "healthcheck:" not in open(smp, encoding="utf-8").read() and "healthcheck:" in open(os.path.join(app_dir, ".felhom.yml"), encoding="utf-8").read():
|
|
problems.append("%s carries no healthcheck: while the template does — not a real step file" % ladder.step_meta_file(to))
|
|
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:]))
|