From bd328307d4b05403f12c6bf2dde3efbece7b50d2 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 13 Sep 2026 09:45:35 +0200 Subject: [PATCH] engine-major gate: no database-engine pin crosses a MAJOR until Slice 4 (R-448) ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=... 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 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .githooks/pre-push | 14 ++- CLAUDE.md | 22 +++- REUSE.md | 2 +- scripts/catalog_gates.py | 55 +++++++-- scripts/check-engine-major.py | 206 ++++++++++++++++++++++++++++++++++ scripts/test_catalog_gates.py | 32 +++++- scripts/test_gate_decoys.py | 192 +++++++++++++++++++++++++++++++ 7 files changed, 508 insertions(+), 15 deletions(-) create mode 100644 scripts/check-engine-major.py create mode 100644 scripts/test_gate_decoys.py diff --git a/.githooks/pre-push b/.githooks/pre-push index 794db1f..3d9aab3 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -70,8 +70,18 @@ if ! command -v python3 >/dev/null 2>&1; then exit 1 fi -echo "pre-push [app-catalog-felhom.eu]: running scripts/catalog_gates.py --fast ..." -python3 "scripts/catalog_gates.py" --fast +# ── THE PUSH RANGE (2026-09-13, engine-major gate) ──────────────────────────────────────────────── +# git feeds this hook " " per ref on stdin. The +# engine-major gate diffs image: lines between the two shas, so the range is handed through as +# --range=... An all-zero remote sha (a ref that does not exist yet) is +# resolved inside the gate to origin/main. Read from stdin ONCE — a second read would block. +refs=$(cat) +range_arg="" +first=$(printf '%s\n' "$refs" | awk 'NF>=4 {print $4".."$2; exit}') +[ -n "$first" ] && range_arg="--range=$first" + +echo "pre-push [app-catalog-felhom.eu]: running scripts/catalog_gates.py --fast $range_arg ..." +python3 "scripts/catalog_gates.py" --fast $range_arg rc=$? if [ "$rc" -ne 0 ]; then echo "pre-push [app-catalog-felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2 diff --git a/CLAUDE.md b/CLAUDE.md index a2f77f9..7fec985 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details - No secrets in any committed file; secrets are generated at deploy time via `deploy_fields` `generate:` specs. - **Run `python3 scripts/catalog_gates.py ` after ANY template change** — it is the ONE entry - point and runs all three gates below, exiting non-zero if any fails. Name the app(s) you touched + point and runs all four gates below, exiting non-zero if any fails. Name the app(s) you touched and it scopes the two gates that accept scoping, which is fast; with no names the runtime gate deploys **every** template, so that form belongs **on a scratch host, never a customer box**. Exit: 0 all clean · 1 convicted · 2 UNDETERMINED, which is never a pass. @@ -41,7 +41,8 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details repo has any, and there are no users yet. **R-161 stays open at reduced scope** — this is convention, run by a person; real automatic enforcement is owed when a second person touches templates. **Update 2026-08-02:** `.githooks/pre-push` now runs `catalog_gates.py --fast` on every - push, which is gate 1 (`check-image-pins.py`) only — the other two need network and a container + push, which is gate 1 (`check-image-pins.py`) and, since 2026-09-13, the engine-major gate with the + push range — the other two need network and a container runtime and take minutes per app, and a push that pulls images and starts containers gets bypassed within a week, after which the bypass is the habit. They stay deliberate periodic runs. The hook is per-clone (`git config core.hooksPath .githooks`) and `git push --no-verify` bypasses it, which is @@ -91,6 +92,23 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details be correct. Why it exists: papra mounted `papra_data:/app/data` while the app wrote its database to `/app/app-data/db/`, so its backup completed, verified, and contained an empty directory (R-156, Campaign 10). +- **No template moves a database-engine image across a MAJOR version — until Slice 4 ships.** + 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.* It covers the **four MariaDB** services — `bookstack-db`, `kimai-db`, + `nextcloud-db`, `romm-db` — and the **eleven PostgreSQL** ones (`docmost-postgres`, + `paperless-postgres` and the rest; the gate finds them by image name, not by this list). **Why now:** + every `mariadb:` sidecar carries `MARIADB_AUTO_UPGRADE=1` since 2026-09-13, so the day a MariaDB pin + moves a major the engine CONVERTS the customer's datadir on the next Update (measured 7 s, own + backup first — `SPIKE-r459-mariadb-upgrade-2026-09-06.md`); PostgreSQL's image converts nothing and + refuses to start on an older major's datadir (R-463). Either way it is a customer-data event with no + backup in front of it. **Within a major** (`11.6 → 11.8`, `16-alpine → 16.4-alpine`) is allowed. + **Gate: `scripts/check-engine-major.py`** — fourth row of `catalog_gates.py`, `--fast`, run by + `.githooks/pre-push` with the push range. **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. **EXPIRY, so it is removed deliberately and not forgotten:** when + R-448 ships, delete this rule, the gate's row in `catalog_gates.py` and the gate — tracked as its + own register row (`OPEN-ITEMS.md`, blocked-on R-448). The rule does not lapse on its own. - **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 — diff --git a/REUSE.md b/REUSE.md index b74a6bb..28459a7 100644 --- a/REUSE.md +++ b/REUSE.md @@ -17,7 +17,7 @@ None — this repo is templates/config, not code. See §2/§5. | deploy_fields conventions | `templates/paperless-ngx/.felhom.yml` (`deploy_fields:` block) | Every app starts with `DOMAIN` (type `domain`) + `SUBDOMAIN` (type `subdomain`, `locked_after_deploy: true`). Secrets: `type: secret` + `generate:` — dominant generators `password:24` (DB passwords) and `hex:32` (app secret keys); `password:16` for shown admin passwords (`type: password`). HDD apps add `HDD_PATH` (`type: path`, placeholder `/mnt/felhom-drives/hdd_1`, locked). Labels/descriptions in Hungarian. | | Controller-side health probe | `templates/vaultwarden/.felhom.yml` (`healthcheck:` block) | `healthcheck.checks[]` with `type: http` (port only), `type: api` (port + `path` + `expect.status: 200`), or `type: tcp` (port only — mealie, crafty-controller). Prefer `api` with a real health path when the app has one. | | App lifecycle (`available`/`hidden`/`abandoned`) | `templates/plant-it/.felhom.yml` (`lifecycle:` block) | Optional top-level `lifecycle:` in `.felhom.yml`. Absent/empty ≡ `available`. `hidden` = not offered for new installs; `abandoned` = same, PLUS a permanent "Nem karbantartott" badge + notice on every box already running it. **Deployed instances keep full function in both states** — lifecycle governs what is OFFERED, never what runs; the controller refuses a deploy of a non-available template server-side (fail-closed, so a stale link or direct POST cannot install one). Unknown value → treated as `available` + one WARN, never a broken template. **Do NOT take an app out of circulation by deleting or moving its directory** — that orphans every customer already running it, which is what the 2026-07-21 `retired/` experiment got wrong. The resolvability gate skips non-available apps, so an abandoned app's dead image is not a standing red. | -| **Catalog gates — THE entry point** | `scripts/catalog_gates.py` | **Run `python3 scripts/catalog_gates.py ` after ANY template change** (mandated in `CLAUDE.md`). Runs all three gates below in order — image-pins, image-resolvable, volume-persistence — and exits **non-zero if any fails**; **2 (UNDETERMINED) is reported distinctly and is never a pass**, 1 (convicted) outranks 2 in the summary. Naming app(s) scopes the two gates that accept scoping, which is the normal after-a-change run; with no names the RUNTIME gate deploys every template, so that form is **scratch host only**. **Why a runner** (operator ruling 2026-08-02, R-161): the only gates in this project that ever get run are the ones with a single entry point named in a CLAUDE.md — `felhom.eu/scripts/site_gates.py` is run, R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a load-time check reads only the file and a static audit reports the catalog clean **including papra** — it would pass on the very defect it exists to catch; CI was rejected for now (neither repo has any, no users yet). Adding a fourth gate here means adding it to `GATES` in this file — nothing else. | +| **Catalog gates — THE entry point** | `scripts/catalog_gates.py` | **Run `python3 scripts/catalog_gates.py ` after ANY template change** (mandated in `CLAUDE.md`). Runs all four gates below in order — image-pins, image-resolvable, volume-persistence, engine-major (2026-09-13; git-history diff, hook-only until CI fetches deeper, R-452) — and exits **non-zero if any fails**; **2 (UNDETERMINED) is reported distinctly and is never a pass**, 1 (convicted) outranks 2 in the summary. Naming app(s) scopes the two gates that accept scoping, which is the normal after-a-change run; with no names the RUNTIME gate deploys every template, so that form is **scratch host only**. **Why a runner** (operator ruling 2026-08-02, R-161): the only gates in this project that ever get run are the ones with a single entry point named in a CLAUDE.md — `felhom.eu/scripts/site_gates.py` is run, R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a load-time check reads only the file and a static audit reports the catalog clean **including papra** — it would pass on the very defect it exists to catch; CI was rejected for now (neither repo has any, no users yet). Adding a fourth gate here means adding it to `GATES` in this file — nothing else. | | Image pinning | ALL `templates/*/docker-compose.yml` (`image:` line) | **Never `:latest` or untagged** (recovery-unit `ImagePins` pins the tag — `:latest` breaks restore fidelity). Pin a concrete version tag; an app deployed anywhere in the fleet pins to the digest it is RUNNING (pin ≠ upgrade); `@sha256:` digest pins also count. Gate: `python scripts/check-image-pins.py` after any compose change (swept 2026-07-12: 5 pins). TRAP: ghcr `tags/list` can be stale/partial — verify tag existence via `docker manifest inspect`, never the tag list. | | Image RESOLVABILITY (does the pin still exist?) | `scripts/check-image-resolvable.py` + `scripts/test_check_image_resolvable.py` | The complement to the pin gate, which is purely syntactic and cannot see rot. Run it at the START of every catalog campaign and before any publish train that vouches the catalog: `python3 scripts/check-image-resolvable.py [app …]`. Exit **0** all resolve, **1** the registry says an image is GONE, **2** INCONCLUSIVE/harness error. **Two traps it encodes, both live-observed:** (a) `docker manifest inspect` prints `toomanyrequests` and **still exits 0** — never trust the exit code alone (same shape as the ISO `validate-answer` trap); (b) the inverse — the first sweep called 24 of 65 pins dead, `postgres:16-alpine` among them, because Docker Hub throttled it partway. Ambiguity therefore resolves to INCONCLUSIVE, never to an accusation; a gate that cries wolf gets ignored. Unauthenticated Hub lookups WILL throttle on a full 65-pin sweep — `docker login` first, or expect exit 2. | | Volume PERSISTENCE (does the app write where the template preserves?) | `scripts/check-volume-persistence.py` + `scripts/test_check_volume_persistence.py` | The third gate and the only RUNTIME one — **the two image gates are static and this class is invisible to static analysis**, which was measured, not assumed: a static audit of all 53 composes (every declared volume attached, no anonymous mounts, no stray host binds) reports the catalog clean AND reports papra clean. papra's compose is well-formed; it mounts `papra_data:/app/data` while the app writes `/app/app-data/db/db.sqlite` into the container's **writable layer** and cannot write `/app/data` at all — so `DumpAppVolumes` (`felhom-controller internal/backup/backup.go:543`) tars an empty directory and the backup verifies (R-156, Campaign 10). Run: `python3 scripts/check-volume-persistence.py [app …]` **on a scratch host, never a customer box**. Exit **0** all CLEAN, **1** REFUSED, **2** UNDETERMINED/prober untrustworthy. **Traps it encodes:** (a) `A` vs `C` in `docker diff` — a linuxserver.io entrypoint chowning its app tree produced 1305 `C` entries and called calibre-web BROKEN on the first pass, so DATA is decided from `A` only and a `C` on a DB file is adjudicated by comparing bytes against a pristine container of the same image; (b) no `docker exec` anywhere — Campaign 7 §1.1's OCI-error-to-stdout trap, so uid comes from `/proc//status` and writability from a host-side `stat`; (c) `base64key` secrets need the controller's `base64:` prefix (`deploy.go:904`) or bookstack serves 500s and the harness looks like an app defect; (d) it self-tests in BOTH directions against two canary templates before reporting anything — a detector that flags nothing turns an unexamined catalog into a documented-clean one. UNDETERMINED is **never** folded into CLEAN. | diff --git a/scripts/catalog_gates.py b/scripts/catalog_gates.py index 065e129..b2d928a 100644 --- a/scripts/catalog_gates.py +++ b/scripts/catalog_gates.py @@ -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=.. # 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=..` 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) diff --git a/scripts/check-engine-major.py b/scripts/check-engine-major.py new file mode 100644 index 0000000..9d3b451 --- /dev/null +++ b/scripts/check-engine-major.py @@ -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 .. # 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//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 .. (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:])) diff --git a/scripts/test_catalog_gates.py b/scripts/test_catalog_gates.py index e26077f..249d1dd 100644 --- a/scripts/test_catalog_gates.py +++ b/scripts/test_catalog_gates.py @@ -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__": diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py new file mode 100644 index 0000000..5a1e38b --- /dev/null +++ b/scripts/test_gate_decoys.py @@ -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())