Files
app-catalog-felhom.eu/CLAUDE.md
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

75 lines
5.6 KiB
Markdown

# CLAUDE.md — `app-catalog-felhom.eu`
> Loads when Claude Code touches this repo. Current state: `CONTEXT.md` + `CHANGELOG.md` top.
> 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.md` before adding or editing an app** — canonical example app (paperless-ngx),
required `.felhom.yml` fields, healthcheck family per image type, memory-limit rules, traps.
- Update `REUSE.md` in the same commit that changes a catalog-wide convention.
- `README.md` is the format spec — update its app tables when adding an app.
- Update `CHANGELOG.md` (newest on top) and overwrite `REPORT.md` with every pushed change.
- No secrets in any committed file; secrets are generated at deploy time via `deploy_fields`
`generate:` 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.py` is 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 `:latest` or 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, so `docker login`
first 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's
`volumes:` 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 mounted `papra_data:/app/data` while 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.yml`
gains an optional `lifecycle:` 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 to `available` with one WARN, so a typo can never brick a
template. This supersedes the short-lived `retired/` directory move, which was wrong: removing a
template orphans every customer already running it.