Files
app-catalog-felhom.eu/CLAUDE.md
T
admin 6db08a5eb3
gates / gates (push) Successful in 1s
test record: an image move must carry its proof (09 decision 13, part 4)
update_ladder: in .felhom.yml, one JSON entry per line (spiked live on
controller v0.266.0 and v0.267.0 first). Two gates: check-test-record.py
(static, CI too) and check-test-record-move.py (history + registry for
moved refs only). 16 decoys, 3 red-proofs. The ONLY writer is
upgrade-test.py --write-ladder (bench AND box proven, digests resolved).
Harness v3: box fixtures on the bench, files_may_change.
Backfill: the 21 moves of 2026-09-22, 21 proven from their records.
No image: line moved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 20:52:32 +02:00

12 KiB

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 four 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. Update 2026-08-02: .githooks/pre-push now runs catalog_gates.py --fast on every push, which is gate 1 (check-image-pins.py) and, since 2026-09-13, the engine-major gate with the push range — the other two need network and a container runtime and take minutes per app, and a push that pulls images and starts containers gets bypassed within a week, after which the bypass is the habit. They stay deliberate periodic runs. The hook is per-clone (git config core.hooksPath .githooks) and git push --no-verify bypasses it, which is why gate 1 alone cannot be the whole story — CI re-runs the entry point on every push and emails on failure (felhom.eu OPEN-ITEMS.md R-168, CLOSED 2026-08-02), which is what notices a bypass.
  • 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).
  • Any commit that changes an image: line MUST set that app's catalog_since to the same day. Gated since 2026-09-13 (R-452): scripts/check-catalog-since.py runs in the pre-push hook and refuses an image move whose catalog_since is older than the moving commit (CI's shallow clone skips it out loud). .felhom.yml carries catalog_since: "YYYY-MM-DD" — the date THIS repo last moved that app's pinned images. It is not a version and not an upstream release date; the controller uses it, and only it, to tell a customer "Frissítés elérhető — 45 napja". No version number is ever shown to the customer, so there is deliberately no version: key to keep in sync alongside it. A stale catalog_since under-reports how long a box has been behind, which is the one number the badge exists to give. Absent, empty, malformed or FUTURE-dated all degrade to a badge with no age and one WARN in the controller log — never a broken template. There is no gate for this yet and that is a known gap, filed as a register row: the gates runner fetches at --depth 1 and has no parent commit to diff an image: line against, so a drift gate needs a deeper fetch. Backfilled for all 53 apps from git history on 2026-09-02.
  • 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 be un-upgradable, and no gate can see that either. Fourth instrument, and the only one that needs REAL DATA: python3 scripts/upgrade-test.py <edge> deploys an app at a FROM image set, seeds through the app's own interface, swaps to a TO set, and asks the app for the data back. Success is an application-level readback, never file identity — a migration is supposed to rewrite files, so the persistence sweep's sha256+inode rule would fail every correct upgrade. It also records what the ABORT does (putting the old images back), verbatim. Run C3 first, every time: it is a negative control whose TO image exits immediately, and if it does not come back failed the harness is not measuring anything. Fixtures live in scripts/upgrade_fixtures.py; an app with no non-browser seed route is recorded inconclusive, never faked. Needs Docker + real images + minutes per edge, on a scratch host, never a customer box. First run: felhom.eu/documentation/audits/SPIKE-upgrade-test-2026-09-06.md.
  • 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).
  • A MariaDB major gets its OWN EDGE; PostgreSQL and MySQL may not cross a major at all. Operator ruling 2026-09-13, amended 2026-09-21 by R-469 now that update arc Slice 4 has shipped. The original rule — no database-engine image crosses a major until the Update button takes a verified backup as its precondition — named its own expiry, and that condition is met: Slice 4 shipped 2026-09-13 (controller v0.237.0/v0.238.0; any backup tier since v0.239.0). What is lifted. The four MariaDB services (bookstack-db, kimai-db, nextcloud-db, romm-db) may now cross a major. They have both halves they need: a verified backup in front of the Update, and MARIADB_AUTO_UPGRADE=1 on every sidecar (R-459), whose conversion the harness has WATCHED run on the E3/E3b edges with the seeded data read back after. What is NOT lifted. The eleven PostgreSQL services stay refused: the image performs no pg_upgrade and REFUSES to start on an older major's datadir (R-463). A backup is a route back, not a conversion — the app simply would not come up. MySQL is refused too, with nothing measured at all. This half is removed when R-463 has a scripted pg_upgrade edge proven on all eleven. The new clause — one edge, one migration (R-450). A MariaDB major must be the ONLY image move in its template in that commit. bookstack's 0b73e5e moved the application 25.02.2 → 26.05.2 and MariaDB 11.6 → 12.3 in one commit: two migrations behind one edge, and an unreadable failure when it breaks. Split it — the engine alone, then the app. Within a major (11.6 → 11.8, 16-alpine → 16.4-alpine) is allowed as before and may ride with anything. Gate: scripts/check-engine-major.py — fourth row of catalog_gates.py, --fast, run by .githooks/pre-push with the push range; decoys in scripts/test_gate_decoys.py. It needs a parent commit, and the CI runner fetches at --depth 1 (the same gap as R-452 — not re-filed), so on a shallow clone the runner skips it out loud; the hook is where it bites.
  • An image: move needs its TEST RECORD (night 2026-09-23, 09 §3 decision 13). .felhom.yml carries update_ladder: — one JSON entry per line, one per tested step (scripts/ladder.py documents the fields). Written only by scripts/upgrade-test.py --write-ladder, never by hand: it refuses unless the bench AND the box walk both say proven, resolves each ref's digest, moves the compose and sets catalog_since. Two gates: check-test-record.py (static — every ladder well-formed and its newest step IS the compose; runs in CI too) and check-test-record-move.py (history + the registry for MOVED refs only — a move must add a proven entry whose digests the registry still serves; the hook). The 21 moves of 2026-09-22 carry backfilled entries citing their records (ladder_backfill.py, one-off).
  • 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.

A gate ships with a decoy test that has been seen to fail (R-421). A decoy is the LABEL without the FACT — a directory with the right name and no bake log, a note whose prose mentions the marker it lacks. scripts/decoy_coverage_gate.py refuses a new gate that has neither a decoy nor a named exemption carrying its row. The four shapes, the 2026-09-01 sweep that fooled 16 of 29 gates, and the decoys withdrawn as illegitimate: documentation/audits/AUDIT-gate-decoys-2026-09-01.md and felhom-controller/.claude/rules/gates.md. Scope is a fact too — prefer os.walk over os.listdir, and a glob over a hand-maintained list.