engine-major gate: no database-engine pin crosses a MAJOR until Slice 4 (R-448) ships
The rule (CLAUDE.md, operator ruling 2026-09-13): until the Update button takes a verified backup as its precondition, no template may move a mariadb:/postgres: image across a major version. Four MariaDB and eleven PostgreSQL services; the gate finds them by image name, not by a list. scripts/check-engine-major.py — fast (git reads only), diffs each changed template's per-service image: line between the two ends of the push range, refuses a major move naming the rule and its expiry (R-448). Fourth row of catalog_gates.py; .githooks/pre-push now hands the push range through as --range=<remote sha>..<local sha>. HONEST LIMIT: it needs a parent commit and CI fetches at --depth 1 (the R-452 gap, not re-filed), so on a shallow clone the runner SKIPS it out loud instead of reddening every CI push. The hook, which has the full clone, is where it bites. Red-proof (scripts/test_gate_decoys.py, 7 cases, all seen to judge correctly): mariadb 11.6->12.3 REFUSED, postgres 16->17 REFUSED, mariadb:lts INCONCLUSIVE; 11.6->11.8 PASSES; the major moving only in a comment / kimai's serverVersion env / README / the app's own image PASSES. COVERS literal registered for felhom.eu's decoy_coverage_gate (which now reads 1 covered, 3 exempt, 0 unaccounted). test_catalog_gates.py pins the four-gate table and the announced shallow-clone skip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -5,14 +5,21 @@
|
||||
python3 scripts/catalog_gates.py # every AVAILABLE app, all three gates
|
||||
python3 scripts/catalog_gates.py papra wishlist # only these app dirs (the normal case)
|
||||
python3 scripts/catalog_gates.py --all # include hidden/abandoned apps too
|
||||
python3 scripts/catalog_gates.py --fast # gate 1 only — no network, no containers;
|
||||
# this is what .githooks/pre-push runs
|
||||
python3 scripts/catalog_gates.py --fast # static gates only — no network, no
|
||||
# containers; this is what .githooks/pre-push runs
|
||||
python3 scripts/catalog_gates.py --fast --range=<A>..<B> # the hook passes the push range
|
||||
# through to the gate that diffs commits
|
||||
|
||||
Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
|
||||
1. image-pins static, instant, whole repo — no :latest / untagged / floating alias
|
||||
2. image-resolvable network — every pinned tag still EXISTS upstream
|
||||
3. volume-persistence RUNTIME — the folder a template preserves is the folder the app writes to
|
||||
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
|
||||
--depth 1 — the R-452 gap) it is SKIPPED and the skip is printed, because a
|
||||
gate that reddens every CI push gets bypassed within a week.
|
||||
|
||||
WHY THIS FILE EXISTS (operator ruling, 2026-08-02 — R-161).
|
||||
|
||||
@@ -51,7 +58,11 @@ import sys
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRIPTS = os.path.join(ROOT, "scripts")
|
||||
|
||||
# (label, filename, accepts_app_scope, fast)
|
||||
# (label, filename, accepts_app_scope, fast, takes_range)
|
||||
#
|
||||
# `takes_range` = the gate diffs two commits and is handed `--range=<A>..<B>` when the runner was
|
||||
# given one (the pre-push hook computes it from the refs git feeds it). Without a range the gate
|
||||
# defaults to origin/main..HEAD. It cannot run on a shallow clone — see the skip in main().
|
||||
#
|
||||
# `fast` = touches NO network and NO container runtime, so it is safe to run on every push.
|
||||
# image-resolvable talks to registries and volume-persistence deploys containers for minutes per
|
||||
@@ -60,9 +71,10 @@ SCRIPTS = os.path.join(ROOT, "scripts")
|
||||
# catalog campaign, before a publish train that vouches the catalog, whenever a template's
|
||||
# volumes: block or image tag changes) — on a scratch host, never a customer box.
|
||||
GATES = [
|
||||
("image-pins", "check-image-pins.py", False, True),
|
||||
("image-resolvable", "check-image-resolvable.py", True, False),
|
||||
("volume-persistence", "check-volume-persistence.py", True, False),
|
||||
("image-pins", "check-image-pins.py", False, True, False),
|
||||
("image-resolvable", "check-image-resolvable.py", True, False, False),
|
||||
("volume-persistence", "check-volume-persistence.py", True, False, False),
|
||||
("engine-major", "check-engine-major.py", False, True, True),
|
||||
]
|
||||
|
||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||
@@ -81,11 +93,22 @@ def run_gate(label, script, args):
|
||||
return subprocess.call([sys.executable, path] + args, cwd=ROOT)
|
||||
|
||||
|
||||
def is_shallow():
|
||||
p = subprocess.run(["git", "rev-parse", "--is-shallow-repository"], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode == 0 and p.stdout.strip() == "true"
|
||||
|
||||
|
||||
def main(argv):
|
||||
include_hidden = "--all" in argv
|
||||
fast = "--fast" in argv
|
||||
rng = ""
|
||||
for a in argv:
|
||||
if a.startswith("--range="):
|
||||
rng = a[len("--range="):]
|
||||
apps = [a for a in argv if not a.startswith("-")]
|
||||
unknown = [a for a in argv if a.startswith("-") and a not in ("--all", "--fast")]
|
||||
unknown = [a for a in argv if a.startswith("-") and a not in ("--all", "--fast")
|
||||
and not a.startswith("--range=")]
|
||||
if unknown:
|
||||
print("unknown option(s): %s" % " ".join(unknown))
|
||||
print(__doc__.strip().splitlines()[0])
|
||||
@@ -99,6 +122,20 @@ def main(argv):
|
||||
|
||||
selected = [g for g in GATES if g[3] or not fast]
|
||||
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
|
||||
# engine-major needs a parent commit. The CI runner fetches at --depth 1 (the R-452 gap), so on
|
||||
# a shallow clone it is skipped OUT LOUD rather than convicting every push it cannot judge — the
|
||||
# pre-push hook, which has the full clone, is where it bites. A silent skip would be the R-421
|
||||
# shape (a gate named in the table that never runs), so the skip is announced and pinned by
|
||||
# test_catalog_gates.py.
|
||||
shallow = is_shallow()
|
||||
if shallow:
|
||||
needs_history = [g[0] for g in selected if g[4]]
|
||||
selected = [g for g in selected if not g[4]]
|
||||
if needs_history:
|
||||
print(" SHALLOW CLONE — SKIPPED: %s — it diffs an image: line against the parent commit\n"
|
||||
" and this clone has none (the CI runner fetches at --depth 1; R-452). It is\n"
|
||||
" enforced by .githooks/pre-push, which runs on the full clone. NOT a pass — a\n"
|
||||
" cross-major engine move is caught at push time, not here." % ", ".join(needs_history))
|
||||
if skipped:
|
||||
print(" --fast SKIPPED: %s — they need network and a container runtime and take minutes\n"
|
||||
" per app, so they are NEVER in a hook. They remain deliberate periodic runs: start\n"
|
||||
@@ -106,12 +143,14 @@ def main(argv):
|
||||
" changes. Run them with no --fast, on a scratch host." % ", ".join(skipped))
|
||||
|
||||
results = []
|
||||
for label, script, scoped, _f in selected:
|
||||
for label, script, scoped, _f, takes_range in selected:
|
||||
args = []
|
||||
if include_hidden:
|
||||
args.append("--all")
|
||||
if scoped and apps:
|
||||
args += apps
|
||||
if takes_range and rng:
|
||||
args.append("--range=" + rng)
|
||||
results.append((label, run_gate(label, script, args)))
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""check-engine-major.py — refuse a push that moves a DATABASE ENGINE across a MAJOR version.
|
||||
|
||||
Run from the repo root:
|
||||
python3 scripts/check-engine-major.py # diff origin/main..HEAD
|
||||
python3 scripts/check-engine-major.py --range <A>..<B> # what .githooks/pre-push passes
|
||||
Exit 0 no engine crosses a major · 1 REFUSED · 2 INCONCLUSIVE (no parent to diff against, or a pin
|
||||
whose major cannot be read).
|
||||
|
||||
THE RULE THIS ENFORCES (app-catalog `CLAUDE.md`, operator ruling 2026-09-13):
|
||||
|
||||
Until the Update button takes a VERIFIED BACKUP as its precondition (update arc Slice 4,
|
||||
felhom.eu OPEN-ITEMS.md R-448), no template may move a database-engine image across a major
|
||||
version.
|
||||
|
||||
WHY IT EXISTS. On 2026-09-13 every `mariadb:` sidecar gained `MARIADB_AUTO_UPGRADE=1`, so the day a
|
||||
MariaDB pin moves a major, the engine will CONVERT the customer's datadir on the next deliberate
|
||||
Update (~7 s, own backup of the system tables first — SPIKE-r459-mariadb-upgrade-2026-09-06.md). That
|
||||
is the right behaviour and it was ruled so. But the Update button still takes no backup of the app's
|
||||
data (09-update-architecture.md §8.6), and PostgreSQL's image performs no conversion at all — it
|
||||
REFUSES to start on an older major's datadir (R-463). Either way a cross-major pin is a customer-data
|
||||
event, and until Slice 4 puts a verified backup in front of the button, the catalog must not offer one.
|
||||
|
||||
WHY A GATE AND NOT A SENTENCE IN CLAUDE.md. This project's most-repeated finding is that a rule with
|
||||
no instrument is a wish. The rule's own expiry condition is the tell: "until Slice 4 ships" is exactly
|
||||
the kind of clause nobody revisits. The gate carries the expiry in its refusal text, and removing the
|
||||
gate is a diff someone reviews.
|
||||
|
||||
WHAT IT COMPARES. For every `templates/<app>/docker-compose.yml` changed between the two ends of the
|
||||
range, the `image:` line of each SERVICE on both sides — per service, on that service's own line,
|
||||
never a blind string replace (the same discipline as `upgrade-test.py`'s `render`). Only images whose
|
||||
repository name is a database engine are judged (`ENGINES`); the app's own image may move as it
|
||||
likes. The engine set is a NAME MATCH on the repository's last path component, so a registry prefix
|
||||
(`docker.io/library/postgres:16`) or a digest suffix does not hide one. Scope is the glob, not a
|
||||
hand-kept list of the four MariaDB and eleven PostgreSQL services — a list would need maintaining, and
|
||||
the 2026-09-01 decoy sweep's rule is that scope is a fact too.
|
||||
|
||||
HONEST LIMIT — CI CANNOT RUN THIS YET. It needs a PARENT commit to diff against, and the CI runner
|
||||
fetches at `--depth 1` (`.gitea/workflows/gates.yml`), which is the very gap R-452 recorded for the
|
||||
`catalog_since` gate. So this runs in the pre-push hook, which has the full clone, and
|
||||
`catalog_gates.py` SKIPS it — out loud — on a shallow clone rather than turning CI red on every push.
|
||||
That is one gate short of enforcement, stated here so nobody mistakes the hook for CI. Fixing it is
|
||||
R-452's fix (a deeper fetch), not a second row.
|
||||
|
||||
FAIL-CLOSED WHERE IT CAN BE. A range that cannot be resolved, a compose file at either end that cannot
|
||||
be read, or an engine tag whose major cannot be parsed (`mariadb:lts`) is INCONCLUSIVE (2), never 0.
|
||||
The pin gate already forbids floating tags, so an unparseable engine tag is a defect in its own right.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Repository basenames that are database engines. A match here means "judge this service's major".
|
||||
ENGINES = ("mariadb", "mysql", "postgres", "postgresql")
|
||||
|
||||
SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$")
|
||||
IMAGE_RE = re.compile(r"^\s+image:\s*[\"']?(\S+?)[\"']?\s*$")
|
||||
TEMPLATE_RE = re.compile(r"^templates/[^/]+/docker-compose\.ya?ml$")
|
||||
ZERO_SHA_RE = re.compile(r"^0{40}$")
|
||||
|
||||
|
||||
def git(*args):
|
||||
p = subprocess.run(["git"] + list(args), capture_output=True, text=True)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
|
||||
def images_in(text):
|
||||
"""{service: image} — per service, from that service's OWN `image:` line."""
|
||||
out, cur = {}, None
|
||||
for line in 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 engine_of(ref):
|
||||
"""(engine_name, tag) if the image's repository is a database engine, else None.
|
||||
|
||||
`docker.io/library/postgres:16-alpine@sha256:…` -> ("postgres", "16-alpine").
|
||||
A registry with a port (`host:5000/postgres:16`) is handled by taking the LAST path component
|
||||
before splitting on ':'.
|
||||
"""
|
||||
ref = ref.split("@", 1)[0]
|
||||
last = ref.rsplit("/", 1)[-1]
|
||||
if ":" in last:
|
||||
name, tag = last.rsplit(":", 1)
|
||||
else:
|
||||
name, tag = last, ""
|
||||
if name.lower() in ENGINES:
|
||||
return name.lower(), tag
|
||||
return None
|
||||
|
||||
|
||||
def major_of(tag):
|
||||
m = re.match(r"^v?(\d+)", tag)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def resolve_range(spec):
|
||||
"""'A..B' -> (A, B, note). An all-zero A (a new remote ref) falls back to origin/main."""
|
||||
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 main(argv):
|
||||
spec = "origin/main..HEAD"
|
||||
for arg in argv:
|
||||
if arg.startswith("--range="):
|
||||
spec = arg[len("--range="):]
|
||||
elif arg == "--range" or arg.startswith("-"):
|
||||
pass
|
||||
if "--range" in argv:
|
||||
i = argv.index("--range")
|
||||
if i + 1 < len(argv):
|
||||
spec = argv[i + 1]
|
||||
|
||||
rc, shallow, _ = git("rev-parse", "--is-shallow-repository")
|
||||
if rc == 0 and shallow.strip() == "true":
|
||||
print("ENGINE-MAJOR GATE INCONCLUSIVE: this clone is SHALLOW — there is no parent commit to "
|
||||
"diff an image: line against (the R-452 gap; the CI runner fetches at --depth 1). "
|
||||
"This gate is enforced by the pre-push hook, which has the full clone.")
|
||||
return 2
|
||||
|
||||
a, b, why = resolve_range(spec)
|
||||
if a is None:
|
||||
print("ENGINE-MAJOR GATE INCONCLUSIVE: %s" % why)
|
||||
return 2
|
||||
|
||||
rc, names, err = git("diff", "--name-only", a, b, "--", "templates")
|
||||
if rc != 0:
|
||||
print("ENGINE-MAJOR GATE INCONCLUSIVE: git diff %s %s failed: %s" % (a, b, err.strip()))
|
||||
return 2
|
||||
files = [n for n in names.split("\n") if TEMPLATE_RE.match(n)]
|
||||
|
||||
compared, refused, unparseable = 0, [], []
|
||||
for path in files:
|
||||
rc_b, before_text, _ = git("show", "%s:%s" % (a, path))
|
||||
rc_a, after_text, _ = git("show", "%s:%s" % (b, path))
|
||||
if rc_a != 0:
|
||||
continue # deleted at B: nothing is being offered
|
||||
before = images_in(before_text) if rc_b == 0 else {}
|
||||
after = images_in(after_text)
|
||||
for svc, img_after in after.items():
|
||||
eng_after = engine_of(img_after)
|
||||
if eng_after is None or svc not in before:
|
||||
continue # not an engine, or a NEW service (nothing to move across)
|
||||
eng_before = engine_of(before[svc])
|
||||
if eng_before is None:
|
||||
continue # became an engine — there is no engine datadir to convert
|
||||
compared += 1
|
||||
if before[svc] == img_after:
|
||||
continue
|
||||
mb, ma = major_of(eng_before[1]), major_of(eng_after[1])
|
||||
if mb is None or ma is None:
|
||||
unparseable.append("%s %s: %s -> %s" % (path, svc, before[svc], img_after))
|
||||
continue
|
||||
if mb != ma:
|
||||
refused.append((path, svc, eng_after[0], mb, ma, before[svc], img_after))
|
||||
|
||||
print("engine-major gate — range %s..%s: %d compose file(s) changed, %d engine pin(s) compared"
|
||||
% (a, b, len(files), compared))
|
||||
|
||||
if refused:
|
||||
print("")
|
||||
for path, svc, eng, mb, ma, ib, ia in refused:
|
||||
print("ENGINE-MAJOR GATE FAILED: %s service %s moves %s %d -> %d (%s -> %s)."
|
||||
% (path, svc, eng, mb, ma, ib, ia))
|
||||
print("RULE (app-catalog CLAUDE.md, operator ruling 2026-09-13): until the Update button takes "
|
||||
"a VERIFIED BACKUP as its precondition (Slice 4, felhom.eu OPEN-ITEMS.md R-448), no "
|
||||
"template may move a database-engine image across a MAJOR version.")
|
||||
print("WHY: MariaDB sidecars now carry MARIADB_AUTO_UPGRADE=1 and WILL convert the customer's "
|
||||
"datadir on the next Update; PostgreSQL's image refuses to start on an older major's "
|
||||
"datadir (R-463). Either way this is a customer-data event with no backup in front of it.")
|
||||
print("EXPIRY: this rule is removed DELIBERATELY when R-448 ships — the removal is its own "
|
||||
"register row, not a silent edit. Until then, keep the engine within its major.")
|
||||
return 1
|
||||
|
||||
if unparseable:
|
||||
print("")
|
||||
for u in unparseable:
|
||||
print("ENGINE-MAJOR GATE INCONCLUSIVE: cannot read a MAJOR from %s" % u)
|
||||
print("An engine tag without a leading number cannot be judged and the pin gate already "
|
||||
"forbids floating tags — pin a numbered tag.")
|
||||
return 2
|
||||
|
||||
print("engine-major gate OK — no database engine crosses a major version"
|
||||
" (rule: CLAUDE.md, until Slice 4 / R-448 ships)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -58,8 +58,36 @@ 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), 3)
|
||||
self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins"])
|
||||
self.assertEqual(len(mod.GATES), 4)
|
||||
self.assertEqual([g[0] for g in mod.GATES if g[3]], ["image-pins", "engine-major"])
|
||||
# exactly one gate needs git history; it is the one the CI half cannot run (R-452)
|
||||
self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major"])
|
||||
|
||||
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
|
||||
exists to enforce its rule never runs it."""
|
||||
self.assertIn("engine-major gate", self.out)
|
||||
|
||||
def test_shallow_clone_skips_engine_major_out_loud(self):
|
||||
"""On a --depth 1 clone (what CI has) the runner must SKIP engine-major and SAY so — never
|
||||
convict every push, never pass silently. Asserted on a real shallow clone of this repo."""
|
||||
import shutil, tempfile
|
||||
tmp = tempfile.mkdtemp(prefix="catalog-shallow-")
|
||||
try:
|
||||
subprocess.run(["git", "clone", "-q", "--depth", "1", "file://" + ROOT, tmp],
|
||||
check=True, capture_output=True)
|
||||
# test the WORKING-TREE runner and gate, not whatever HEAD happens to hold
|
||||
for fn in ("catalog_gates.py", "check-engine-major.py", "check-image-pins.py"):
|
||||
shutil.copy(os.path.join(ROOT, "scripts", fn), os.path.join(tmp, "scripts", fn))
|
||||
p = subprocess.run([sys.executable, os.path.join(tmp, "scripts", "catalog_gates.py"),
|
||||
"--fast"], cwd=tmp, capture_output=True, text=True)
|
||||
out = p.stdout + p.stderr
|
||||
self.assertIn("SHALLOW CLONE", out)
|
||||
self.assertIn("engine-major", out)
|
||||
self.assertNotIn("engine-major gate OK", out)
|
||||
self.assertEqual(p.returncode, 0, out)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""test_gate_decoys.py — can this repo's gates be fooled by a LABEL? (R-421)
|
||||
|
||||
The same instrument as `felhom.eu/scripts/test_gate_decoys.py`: a decoy is the LABEL without the
|
||||
FACT, and a gate that convicts on the label alone — or fails to convict on the fact — is a live hole.
|
||||
Every case asserts BOTH directions where it can: the genuine article must pass and the decoy must be
|
||||
judged on what it IS, not on what it says.
|
||||
|
||||
Covered here (the `COVERS` literal is AST-read by `felhom.eu/scripts/decoy_coverage_gate.py`):
|
||||
|
||||
engine-major — `check-engine-major.py` refuses a database-engine pin that crosses a MAJOR.
|
||||
Its label is the version string; its fact is the `image:` line of an engine SERVICE. Decoys a
|
||||
real session would produce:
|
||||
* the major moves in a COMMENT and in kimai's `serverVersion=11.6.2-MariaDB` env var, while
|
||||
the image line stays — must PASS (nothing moved);
|
||||
* the APP's own image crosses a major (kimai 2.57 -> 3.0) — must PASS (not an engine);
|
||||
* a `mariadb:12.3` string lands in README.md — must PASS (not a template);
|
||||
* the engine moves WITHIN its major (11.6 -> 11.8) — must PASS (the rule says MAJOR);
|
||||
and the facts:
|
||||
* `mariadb:11.6 -> mariadb:12.3` on `kimai-db` — must be REFUSED (exit 1), naming the rule
|
||||
and its expiry (R-448);
|
||||
* `postgres:16-alpine -> postgres:17-alpine` on `docmost-postgres` — must be REFUSED (the
|
||||
eleven PostgreSQL services are covered by NAME MATCH, not by a list);
|
||||
* `mariadb:11.6 -> mariadb:lts` — INCONCLUSIVE (exit 2), never 0: a major nobody can read is
|
||||
not a pass.
|
||||
|
||||
HOW. The repo is cloned into a scratch directory; the WORKING-TREE gate is run inside the clone
|
||||
(so the file under test is the one being edited, not HEAD's); each case is one commit on top of the
|
||||
clone's HEAD and the gate is run with `--range HEAD~1..HEAD`. The real tree is never touched.
|
||||
|
||||
Run from the repo root: python3 scripts/test_gate_decoys.py
|
||||
Exit 0 every decoy judged correctly · 1 a decoy passed or a genuine article was refused.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# ── WHAT THIS FILE COVERS ────────────────────────────────────────────────────────────────────────
|
||||
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
|
||||
# MUST have a decoy below that has been seen to fail.
|
||||
COVERS = {
|
||||
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
|
||||
}
|
||||
|
||||
fails = []
|
||||
ran = 0
|
||||
|
||||
|
||||
def sh(args, cwd):
|
||||
return subprocess.run(args, cwd=cwd, capture_output=True, text=True)
|
||||
|
||||
|
||||
def make_clone():
|
||||
tmp = tempfile.mkdtemp(prefix="catalog-decoys-")
|
||||
r = sh(["git", "clone", "-q", "file://" + ROOT, tmp], cwd=ROOT)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit("clone failed: " + r.stderr)
|
||||
sh(["git", "config", "user.email", "decoy@gate.invalid"], cwd=tmp)
|
||||
sh(["git", "config", "user.name", "decoy"], cwd=tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
def edit(clone, relpath, fn):
|
||||
p = os.path.join(clone, relpath)
|
||||
text = io.open(p, encoding="utf-8").read() if os.path.exists(p) else ""
|
||||
new = fn(text)
|
||||
if new == text:
|
||||
raise SystemExit("case did not change %s — the case is broken, not the gate" % relpath)
|
||||
with io.open(p, "w", encoding="utf-8") as fh:
|
||||
fh.write(new)
|
||||
|
||||
|
||||
def commit(clone, msg):
|
||||
sh(["git", "add", "-A"], cwd=clone)
|
||||
r = sh(["git", "commit", "-q", "-m", msg], cwd=clone)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit("commit failed: " + r.stderr)
|
||||
|
||||
|
||||
def reset(clone):
|
||||
sh(["git", "reset", "-q", "--hard", "HEAD"], cwd=clone)
|
||||
|
||||
|
||||
def case(name, clone, edits, expect_rc, must_contain=()):
|
||||
"""edits: list of (relpath, fn). Commits them, runs the gate on HEAD~1..HEAD, restores."""
|
||||
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)
|
||||
# the WORKING-TREE gate, run inside the clone (it reads git from its cwd)
|
||||
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-engine-major.py"),
|
||||
"--range", "HEAD~1..HEAD"], cwd=clone)
|
||||
out = r.stdout + r.stderr
|
||||
ok = r.returncode == expect_rc and all(m in out for m in must_contain)
|
||||
if ok:
|
||||
print(" ok %-52s rc=%d (expected %d)" % (name, r.returncode, expect_rc))
|
||||
else:
|
||||
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:]))
|
||||
return out
|
||||
finally:
|
||||
sh(["git", "reset", "-q", "--hard", base], cwd=clone)
|
||||
|
||||
|
||||
def swap_image(service, frm, to):
|
||||
"""Change ONLY the named service's own image: line — the same per-service discipline as the
|
||||
gate, so the case moves the fact and nothing else."""
|
||||
def _fn(text):
|
||||
out, cur, done = [], None, False
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
mi = re.match(r"^(\s+image:\s*)(\S+)\s*$", line)
|
||||
if mi and cur == service and mi.group(2) == frm:
|
||||
line = mi.group(1) + to
|
||||
done = True
|
||||
out.append(line)
|
||||
if not done:
|
||||
raise SystemExit("%s does not carry image %s — fixture drifted" % (service, frm))
|
||||
return "\n".join(out) + "\n"
|
||||
return _fn
|
||||
|
||||
|
||||
def main():
|
||||
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
|
||||
if not os.path.isfile(gate):
|
||||
print("FAIL: scripts/check-engine-major.py is missing — a failure, never a skip")
|
||||
return 1
|
||||
clone = make_clone()
|
||||
try:
|
||||
KIMAI = "templates/kimai/docker-compose.yml"
|
||||
DOCMOST = "templates/docmost/docker-compose.yml"
|
||||
|
||||
# ── THE FACTS: these must be refused ─────────────────────────────────────────────────
|
||||
out = case("FACT: kimai-db mariadb:11.6 -> 12.3 (cross-major)", clone,
|
||||
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3"))],
|
||||
expect_rc=1,
|
||||
must_contain=("ENGINE-MAJOR GATE FAILED", "kimai-db", "mariadb 11 -> 12",
|
||||
"R-448", "EXPIRY"))
|
||||
if "REFUSAL_TEXT" in os.environ:
|
||||
print(out)
|
||||
case("FACT: docmost-postgres postgres:16-alpine -> 17-alpine", clone,
|
||||
[(DOCMOST, swap_image("docmost-postgres", "postgres:16-alpine", "postgres:17-alpine"))],
|
||||
expect_rc=1, must_contain=("docmost-postgres", "postgres 16 -> 17"))
|
||||
case("FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable)", clone,
|
||||
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:lts"))],
|
||||
expect_rc=2, must_contain=("INCONCLUSIVE",))
|
||||
|
||||
# ── THE GENUINE ARTICLES: these must pass ────────────────────────────────────────────
|
||||
case("GENUINE: kimai-db mariadb:11.6 -> 11.8 (within major)", clone,
|
||||
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:11.8"))],
|
||||
expect_rc=0, must_contain=("engine-major gate OK",))
|
||||
|
||||
# ── THE DECOYS: the label moves, the fact does not — these must pass ─────────────────
|
||||
def comment_and_env(text):
|
||||
# the version string moves in a COMMENT and in kimai's serverVersion env, image untouched
|
||||
t = text.replace("serverVersion=11.6.2-MariaDB", "serverVersion=12.3.0-MariaDB")
|
||||
return t.replace("# Database: mariadb", "# Database: mariadb (image: mariadb:12.3 soon)")
|
||||
case("DECOY: major moves only in a comment + serverVersion env", clone,
|
||||
[(KIMAI, comment_and_env)], expect_rc=0, must_contain=("engine-major gate OK",))
|
||||
case("DECOY: the APP image crosses a major (kimai 2.57 -> 3.0)", clone,
|
||||
[(KIMAI, swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-3.0.0"))],
|
||||
expect_rc=0, must_contain=("engine-major gate OK",))
|
||||
case("DECOY: 'mariadb:12.3' lands in README.md, not a template", clone,
|
||||
[("README.md", lambda t: t + "\nDecoy: mariadb:11.6 -> mariadb:12.3 pending.\n")],
|
||||
expect_rc=0, must_contain=("0 compose file(s) changed",))
|
||||
finally:
|
||||
shutil.rmtree(clone, ignore_errors=True)
|
||||
|
||||
if fails:
|
||||
print()
|
||||
for f in fails:
|
||||
print("FAIL: %s" % f)
|
||||
return 1
|
||||
print("\ncatalog gate decoys OK — %d case(s), every label judged on its fact (R-421)" % ran)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user