fd7747d129
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.
120 lines
5.3 KiB
Python
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:]))
|