Files
admin 6a3ead9ebe
gates / gates (push) Successful in 2s
Same-tag security fixes as tested steps (09 decision 52, R-740): re-test entries, their gates, the monthly command
- 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
2026-09-30 23:06:09 +02:00

243 lines
12 KiB
Python

#!/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$")
FELHOM_RE = re.compile(r"^templates/([^/]+)/\.felhom\.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 judge_retests(app, a, b, resolver, network=True):
"""(problems, inconclusive) for the RE-TEST entries (`from` == `to`, decision 52) this range adds to one
template. A re-test moves no image line, so judge_app never looks at it; it changes `.felhom.yml` only. Each new
re-test must be well-formed, proven, not backfilled, the ladder's NEWEST entry naming the compose's refs, and its
digest must be what the registry serves for those refs NOW (the image a box would pull)."""
cpath, fpath = "templates/%s/docker-compose.yml" % app, "templates/%s/.felhom.yml" % app
after_c = show(b, cpath)
if after_c is None:
return [], []
after = ladder.images_in(after_c)
_e, raws_a, _ = ladder.parse(show(a, fpath) or "")
ents_b, raws_b, errs_b = ladder.parse(show(b, fpath) or "")
old = set(raws_a)
new = [(i, e) for i, (e, raw) in enumerate(zip(ents_b, raws_b)) if raw not in old and e.get("from") == e.get("to")]
problems, inconclusive = [], []
for i, e in new:
for q in ladder.check_entry(e):
problems.append("new re-test: " + q)
if e.get("backfilled") is not None or e.get("verdict") != "proven":
problems.append("new re-test is not a proven new test (verdict %r, backfilled %r)" % (e.get("verdict"), e.get("backfilled")))
if i != len(ents_b) - 1 or e.get("to") != after:
problems.append("new re-test is not the ladder's newest entry naming the compose's refs %s" % after)
if problems or not new:
return problems, inconclusive
if not network:
print(" %s: re-test digest comparison SKIPPED (--no-network) — not a pass for a real push" % app)
return problems, inconclusive
e = new[-1][1]
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("re-test %s %s: the registry serves %s, the re-test says %s — the re-tested image 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
# decision 52: re-tests change .felhom.yml only — judged separately, for every template whose .felhom.yml changed
for app in sorted({FELHOM_RE.match(n).group(1) for n in names.split("\n") if FELHOM_RE.match(n)}):
p, inc = judge_retests(app, a, b, resolver, network)
if p:
convicted.setdefault(app, []).extend(p)
if inc:
undecided.setdefault(app, []).extend(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:]))