Files
app-catalog-felhom.eu/scripts/catalog_gates.py
T
admin fd7747d129 catalog gates: one entry point, mandated in CLAUDE.md (R-161 ruling)
scripts/catalog_gates.py runs all three gates - image-pins, image-resolvable,
volume-persistence - and exits non-zero if any fails. Mandated in CLAUDE.md the way
felhom.eu/scripts/site_gates.py is: run it after any template change, naming the
app(s) you touched.

Operator ruling, recorded because both alternatives were rejected for measured
reasons. Controller-side enforcement at template load was rejected because such a
check can only read the file, and a static audit of all 53 templates reports the
catalog clean INCLUDING papra - it would pass on the exact defect it exists to
catch; the property is decidable only at runtime. CI was rejected for now: neither
repo has any, and there are no users yet. What was chosen copies the shape that
demonstrably works here - of this project's gates, the only ones that ever get run
are the ones with a single entry point named in a CLAUDE.md; site_gates.py is run,
and R-29's three orphans are named nowhere and have stopped nothing.

Behaviour: 0 all clean / 1 convicted / 2 UNDETERMINED, never a pass; a conviction
outranks an undetermined result so the reader knows which they have. Gate output is
streamed, not captured. App names scope the two gates that accept scoping; with no
names the runtime gate deploys every template and belongs on a scratch host.
Adding a fourth gate means one line in GATES.

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.

Verified: image-pins passes standalone (53 templates, 0 unpinned), the
unknown-option path exits 2, and the aggregation was unit-checked over five
gate-code combinations. The runtime leg was deliberately NOT executed - it deploys
templates via docker compose and DooPlex is the recovery chain - so the runner's
end-to-end invocation of that third gate is inferred, not measured, and is flagged
in REPORT.md to be closed on a scratch host at the next campaign.

REPORT.md overwritten per convention; the persistence sweep's report is preserved
at audits/persistence-sweep-2026-08-02/ and pointed to from the new one.
2026-08-02 14:03:55 +02:00

120 lines
5.3 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
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)
GATES = [
("image-pins", "check-image-pins.py", False),
("image-resolvable", "check-image-resolvable.py", True),
("volume-persistence", "check-volume-persistence.py", True),
]
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
apps = [a for a in argv if not a.startswith("-")]
unknown = [a for a in argv if a.startswith("-") and a != "--all"]
if unknown:
print("unknown option(s): %s" % " ".join(unknown))
print(__doc__.strip().splitlines()[0])
return 2
scope_note = ("apps: " + ", ".join(apps)) if apps else (
"ALL apps (runtime gate deploys every template — scratch host only)")
print("catalog_gates — %s%s" % (scope_note, " [--all: incl. hidden/abandoned]" if include_hidden else ""))
results = []
for label, script, scoped in GATES:
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:]))