Files
app-catalog-felhom.eu/scripts/catalog_gates.py
T
admin c3e4bb18c7 gates: catalog_gates --fast + pre-push hook
--fast selects only gates that touch no network and no container runtime: gate 1
(check-image-pins) runs, image-resolvable and volume-persistence do NOT. Default behaviour with
no flag is unchanged. The skip is ANNOUNCED with the reason and with what still owes a periodic
run — a silently narrowed run reads as 'covered everything' when it did not.

Why the runtime gates are never in a hook: a push that pulls images and starts containers gets
bypassed within a week, and the bypass becomes the habit. They stay deliberate periodic runs at
the start of a catalog campaign, before a publish train, and when a template's volumes: block or
image tag changes — on a scratch host, never a customer box.

.githooks/pre-push runs catalog_gates.py --fast and refuses the push. Per-clone and
--no-verify-able, both stated in the hook itself.

test_catalog_gates.py pins --fast's CONTENT, not just its exit code: the runtime gates must not
run, the skip must be announced, and the no-flag path must still select all three. Red-proofed:
an inert run_gate turns it red.
2026-08-02 15:23:08 +02:00

140 lines
6.8 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""catalog_gates.py — THE entry point for this repo's gates. Run from the repo root:
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
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
WHY THIS FILE EXISTS (operator ruling, 2026-08-02 — R-161).
The volume-persistence gate was built because papra's backup completed, verified, and contained an
empty directory. The obvious enforcement points were both rejected, each for a measured reason:
- **Controller-side, at template load: rejected because it would PASS on the defect it exists to
catch.** A check at load time can only read the file, and papra's compose is well-formed — a
static audit of all 53 templates reports the catalog clean, papra included. The property is only
decidable at runtime (see `check-volume-persistence.py`'s header).
- **CI: rejected for now** — neither repo has any CI to build on, and there are no users yet.
What was chosen instead is the shape that demonstrably works in this project. Of every gate written
here, **the only ones 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 orphaned gates are named nowhere and have
stopped nothing. So this copies that shape rather than adding a fourth gate nobody invokes. It is
mandated in `CLAUDE.md` the way `site_gates.py` is.
**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.
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits **non-zero if
any gate is non-zero**, and reports 2 distinctly as INCONCLUSIVE — an undetermined result is never a
pass (an app that wrote nothing has not been shown correct; a throttled registry has not shown an
image alive), but it is also not a conviction, and the operator reading the summary needs to know
which they have.
SCOPE. With app names, every gate that accepts scoping is scoped to them — that is the normal
after-a-template-change run and it is fast. With no names the runtime gate deploys **every** template,
which takes minutes per app and **belongs on a scratch host, never a customer box** (see CLAUDE.md).
"""
import os
import subprocess
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)
#
# `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
# app — neither belongs in a hook. A push that pulls images and starts containers gets bypassed
# within a week, and the bypass becomes the habit; both stay deliberate periodic runs (start of a
# 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),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
def run_gate(label, script, args):
path = os.path.join(SCRIPTS, script)
if not os.path.exists(path):
print("FAIL: %s%s is missing from scripts/" % (label, script))
return 1
print("\n" + "=" * 78)
print("== gate: %s (%s%s)" % (label, script, (" " + " ".join(args)) if args else ""))
print("=" * 78, flush=True)
# stream the gate's own output rather than capturing it — its diagnostics are the point,
# and a runner that swallows them makes a conviction unreadable.
return subprocess.call([sys.executable, path] + args, cwd=ROOT)
def main(argv):
include_hidden = "--all" in argv
fast = "--fast" in argv
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")]
if unknown:
print("unknown option(s): %s" % " ".join(unknown))
print(__doc__.strip().splitlines()[0])
return 2
scope_note = ("apps: " + ", ".join(apps)) if apps else (
"static gate only" if fast else
"ALL apps (runtime gate deploys every template — scratch host only)")
print("catalog_gates — %s%s%s" % (scope_note, " [--fast]" if fast else "",
" [--all: incl. hidden/abandoned]" if include_hidden else ""))
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)]
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"
" of a catalog campaign, before a publish train, or when a template's volumes:/image\n"
" changes. Run them with no --fast, on a scratch host." % ", ".join(skipped))
results = []
for label, script, scoped, _f in selected:
args = []
if include_hidden:
args.append("--all")
if scoped and apps:
args += apps
results.append((label, run_gate(label, script, args)))
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
worst = 0
for label, rc in results:
print(" %-20s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
# 1 (a conviction) outranks 2 (undetermined) in what it tells the operator to do
if rc != 0:
worst = 1 if rc == 1 or worst == 1 else 2
if worst == 0:
print("\nall catalog gates OK")
return 0
convicted = [l for l, rc in results if rc == 1]
undecided = [l for l, rc in results if rc not in (0, 1)]
if convicted:
print("\nCONVICTED: %s" % ", ".join(convicted))
if undecided:
print("UNDETERMINED (never a pass): %s" % ", ".join(undecided))
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))