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.
5.6 KiB
CLAUDE.md — app-catalog-felhom.eu
Loads when Claude Code touches this repo. Current state:
CONTEXT.md+CHANGELOG.mdtop. Cross-repo orientation: workspace-root/mnt/5_hdd/felhom.eu/git/CLAUDE.md.
What this repo is
The Felhom app catalog: one directory per app under templates/<app>/, each holding exactly
docker-compose.yml + .felhom.yml (deploy fields, resources, healthcheck probe, app_info — all
customer-facing text in Hungarian). The felhom-controller git-syncs these to every customer box;
.felhom.yml drives the deploy wizard. templates.json + scripts/generate-customer.sh are LEGACY
(Portainer-era) — new apps don't touch them.
Deploy contract
Push to main = deploy. The controller's sync picks changes up within 15 minutes (or trigger via
the dashboard "Sablonok frissítése" button / POST /api/sync). Only the two template files sync;
deployed app.yaml (customer secrets) is never overwritten. Full deploy details: the
felhom-build-deploy skill.
Conventions
- See
REUSE.mdbefore adding or editing an app — canonical example app (paperless-ngx), required.felhom.ymlfields, healthcheck family per image type, memory-limit rules, traps. - Update
REUSE.mdin the same commit that changes a catalog-wide convention. README.mdis the format spec — update its app tables when adding an app.- Update
CHANGELOG.md(newest on top) and overwriteREPORT.mdwith every pushed change. - No secrets in any committed file; secrets are generated at deploy time via
deploy_fieldsgenerate:specs. - Run
python3 scripts/catalog_gates.py <app>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 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. Why a runner and not four separate invocations (operator ruling 2026-08-02, R-161): 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 —felhom.eu/scripts/site_gates.pyis run, and R-29's three orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a check at template load 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. CI was rejected for now: neither 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. - Never
:latestor untagged images in templates — pin a concrete version tag; an app deployed anywhere in the fleet is pinned to the digest it is currently running (a pin must never cause a version jump). Digest pins (@sha256:) also count. Gate:python scripts/check-image-pins.py(run after any compose change; exit 1 on any floating/missing tag). - A pinned tag can still rot away upstream — the pin gate is syntactic and cannot see that.
Second gate:
python3 scripts/check-image-resolvable.py(exit 0 resolve / 1 GONE / 2 inconclusive), run at the start of every catalog campaign and before any publish train that vouches the catalog. Needs network +docker; unauthenticated Docker Hub throttles a full sweep, sodocker loginfirst or expect exit 2. It reports a throttle as INCONCLUSIVE, never as a dead image. - A well-formed template can still preserve the wrong folder — and no static check can see it.
Third gate, the only RUNTIME one:
python3 scripts/check-volume-persistence.py(0 all clean / 1 REFUSED / 2 undecided). It deploys each template, exercises it into writing data, and compares where the data landed against what the compose mounts. Needs Docker + network and minutes per app, so it is periodic like the resolvability gate — run it whenever a template'svolumes:block or image tag changes, and at the start of every catalog campaign, on a scratch host, never a customer box. It refuses to report at all unless it has just re-proven itself in both directions against two canary templates. Fixture tests (no Docker):python3 scripts/test_check_volume_persistence.py. UNDETERMINED is exit 2 and is never a pass — an app that wrote nothing has not been shown to be correct. Why it exists: papra mountedpapra_data:/app/datawhile the app wrote its database to/app/app-data/db/, so its backup completed, verified, and contained an empty directory (R-156, Campaign 10). - Taking an app out of circulation — use
lifecycle:, never a directory move..felhom.ymlgains an optionallifecycle:field:available(default; absent/empty means this),hidden(not offered for new installs, no explanation owed),abandoned(upstream stopped developing it — not offered for new installs, and every box already running it shows a permanent "Nem karbantartott" notice). Deployed instances keep working in full either way — the state affects what is OFFERED, never what already runs, and the controller REFUSES a deploy of a non-available template server-side. An unknown value degrades toavailablewith one WARN, so a typo can never brick a template. This supersedes the short-livedretired/directory move, which was wrong: removing a template orphans every customer already running it.