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
243 lines
12 KiB
Python
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:]))
|