c3e4bb18c7
--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.
140 lines
6.8 KiB
Python
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:]))
|