Files
app-catalog-felhom.eu/REUSE.md
T
admin bd328307d4 engine-major gate: no database-engine pin crosses a MAJOR until Slice 4 (R-448) ships
The rule (CLAUDE.md, operator ruling 2026-09-13): until the Update button takes a verified backup
as its precondition, no template may move a mariadb:/postgres: image across a major version. Four
MariaDB and eleven PostgreSQL services; the gate finds them by image name, not by a list.

scripts/check-engine-major.py — fast (git reads only), diffs each changed template's per-service
image: line between the two ends of the push range, refuses a major move naming the rule and its
expiry (R-448). Fourth row of catalog_gates.py; .githooks/pre-push now hands the push range
through as --range=<remote sha>..<local sha>.

HONEST LIMIT: it needs a parent commit and CI fetches at --depth 1 (the R-452 gap, not re-filed),
so on a shallow clone the runner SKIPS it out loud instead of reddening every CI push. The hook,
which has the full clone, is where it bites.

Red-proof (scripts/test_gate_decoys.py, 7 cases, all seen to judge correctly): mariadb 11.6->12.3
REFUSED, postgres 16->17 REFUSED, mariadb:lts INCONCLUSIVE; 11.6->11.8 PASSES; the major moving
only in a comment / kimai's serverVersion env / README / the app's own image PASSES. COVERS literal
registered for felhom.eu's decoy_coverage_gate (which now reads 1 covered, 3 exempt, 0 unaccounted).
test_catalog_gates.py pins the four-gate table and the announced shallow-clone skip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 09:45:35 +02:00

16 KiB

REUSE.md — app-catalog-felhom.eu

Before adding or editing an app, check here. Conventions to copy, traps to avoid. Maintenance: update in the SAME commit that changes a catalog-wide convention. Entries cite real files. Line numbers are landmarks only — reconfirm before editing.

1. Canonical helpers

None — this repo is templates/config, not code. See §2/§5.

2. Canonical patterns (copy structure from THE named file)

Pattern Canonical file Key traits
The one canonical example app templates/paperless-ngx/ (both files) Multi-container (app + postgres + redis), HDD + userdata mounts, full deploy_fields spectrum (domain/subdomain/secret/password/text/path/select). Copy this structure for any new app.
.felhom.yml required fields templates/paperless-ngx/.felhom.yml All 53 apps: display_name, description (Hungarian), category, subdomain, slug, resources{mem_request, mem_limit, pi_compatible, needs_hdd}, deploy_fields, app_info{tagline, use_cases, first_steps, ...}, healthcheck. Optional: smtp_mapping (email-capable apps), open_path (non-root landing page, e.g. ghost).
deploy_fields conventions templates/paperless-ngx/.felhom.yml (deploy_fields: block) Every app starts with DOMAIN (type domain) + SUBDOMAIN (type subdomain, locked_after_deploy: true). Secrets: type: secret + generate: — dominant generators password:24 (DB passwords) and hex:32 (app secret keys); password:16 for shown admin passwords (type: password). HDD apps add HDD_PATH (type: path, placeholder /mnt/felhom-drives/hdd_1, locked). Labels/descriptions in Hungarian.
Controller-side health probe templates/vaultwarden/.felhom.yml (healthcheck: block) healthcheck.checks[] with type: http (port only), type: api (port + path + expect.status: 200), or type: tcp (port only — mealie, crafty-controller). Prefer api with a real health path when the app has one.
App lifecycle (available/hidden/abandoned) templates/plant-it/.felhom.yml (lifecycle: block) Optional top-level lifecycle: in .felhom.yml. Absent/empty ≡ available. hidden = not offered for new installs; abandoned = same, PLUS a permanent "Nem karbantartott" badge + notice on every box already running it. Deployed instances keep full function in both states — lifecycle governs what is OFFERED, never what runs; the controller refuses a deploy of a non-available template server-side (fail-closed, so a stale link or direct POST cannot install one). Unknown value → treated as available + one WARN, never a broken template. Do NOT take an app out of circulation by deleting or moving its directory — that orphans every customer already running it, which is what the 2026-07-21 retired/ experiment got wrong. The resolvability gate skips non-available apps, so an abandoned app's dead image is not a standing red.
Catalog gates — THE entry point scripts/catalog_gates.py Run python3 scripts/catalog_gates.py <app> after ANY template change (mandated in CLAUDE.md). Runs all four gates below in order — image-pins, image-resolvable, volume-persistence, engine-major (2026-09-13; git-history diff, hook-only until CI fetches deeper, R-452) — and exits non-zero if any fails; 2 (UNDETERMINED) is reported distinctly and is never a pass, 1 (convicted) outranks 2 in the summary. Naming app(s) scopes the two gates that accept scoping, which is the normal after-a-change run; with no names the RUNTIME gate deploys every template, so that form is scratch host only. Why a runner (operator ruling 2026-08-02, R-161): the only gates in this project 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 orphans are named nowhere and have stopped nothing. Controller-side enforcement was rejected because a load-time check reads only the file and a static audit reports the catalog clean including papra — it would pass on the very defect it exists to catch; CI was rejected for now (neither repo has any, no users yet). Adding a fourth gate here means adding it to GATES in this file — nothing else.
Image pinning ALL templates/*/docker-compose.yml (image: line) Never :latest or untagged (recovery-unit ImagePins pins the tag — :latest breaks restore fidelity). Pin a concrete version tag; an app deployed anywhere in the fleet pins to the digest it is RUNNING (pin ≠ upgrade); @sha256: digest pins also count. Gate: python scripts/check-image-pins.py after any compose change (swept 2026-07-12: 5 pins). TRAP: ghcr tags/list can be stale/partial — verify tag existence via docker manifest inspect, never the tag list.
Image RESOLVABILITY (does the pin still exist?) scripts/check-image-resolvable.py + scripts/test_check_image_resolvable.py The complement to the pin gate, which is purely syntactic and cannot see rot. Run it at the START of every catalog campaign and before any publish train that vouches the catalog: python3 scripts/check-image-resolvable.py [app …]. Exit 0 all resolve, 1 the registry says an image is GONE, 2 INCONCLUSIVE/harness error. Two traps it encodes, both live-observed: (a) docker manifest inspect prints toomanyrequests and still exits 0 — never trust the exit code alone (same shape as the ISO validate-answer trap); (b) the inverse — the first sweep called 24 of 65 pins dead, postgres:16-alpine among them, because Docker Hub throttled it partway. Ambiguity therefore resolves to INCONCLUSIVE, never to an accusation; a gate that cries wolf gets ignored. Unauthenticated Hub lookups WILL throttle on a full 65-pin sweep — docker login first, or expect exit 2.
Volume PERSISTENCE (does the app write where the template preserves?) scripts/check-volume-persistence.py + scripts/test_check_volume_persistence.py The third gate and the only RUNTIME one — the two image gates are static and this class is invisible to static analysis, which was measured, not assumed: a static audit of all 53 composes (every declared volume attached, no anonymous mounts, no stray host binds) reports the catalog clean AND reports papra clean. papra's compose is well-formed; it mounts papra_data:/app/data while the app writes /app/app-data/db/db.sqlite into the container's writable layer and cannot write /app/data at all — so DumpAppVolumes (felhom-controller internal/backup/backup.go:543) tars an empty directory and the backup verifies (R-156, Campaign 10). Run: python3 scripts/check-volume-persistence.py [app …] on a scratch host, never a customer box. Exit 0 all CLEAN, 1 REFUSED, 2 UNDETERMINED/prober untrustworthy. Traps it encodes: (a) A vs C in docker diff — a linuxserver.io entrypoint chowning its app tree produced 1305 C entries and called calibre-web BROKEN on the first pass, so DATA is decided from A only and a C on a DB file is adjudicated by comparing bytes against a pristine container of the same image; (b) no docker exec anywhere — Campaign 7 §1.1's OCI-error-to-stdout trap, so uid comes from /proc/<pid>/status and writability from a host-side stat; (c) base64key secrets need the controller's base64: prefix (deploy.go:904) or bookstack serves 500s and the harness looks like an app defect; (d) it self-tests in BOTH directions against two canary templates before reporting anything — a detector that flags nothing turns an unexamined catalog into a documented-clean one. UNDETERMINED is never folded into CLEAN.
Docker healthcheck host ALL templates/*/docker-compose.yml (healthcheck.test:) Always 127.0.0.1, never localhost. BusyBox wget (and node/python/curl one-shots) resolve localhost→IPv6 ::1 with NO cross-address-family fallback; an app that binds IPv4-only then reads docker-unhealthy while fully serving (vaultwarden, re-run 2026-07-06 — swept all 48 templates).
Docker healthcheck — BusyBox/wget images templates/vaultwarden/docker-compose.yml (~L49) test: ["CMD", "wget", "--spider", "-q", "http://localhost:<port>/<path>"]. Most common family (~20 apps, e.g. homebox, glance).
Docker healthcheck — curl-capable images templates/paperless-ngx/docker-compose.yml (~L76) test: ["CMD", "curl", "-f", "http://localhost:<port>/<path>"] (~18 apps: jellyfin, immich, sonarr…).
Docker healthcheck — Node images (no wget/curl) templates/rallly/docker-compose.yml (~L49) test: ["CMD", "node", "-e", "require('http').get(...)"] — used when the image lacks wget (that was rallly's actual bug).
Docker healthcheck — Python images templates/mealie/docker-compose.yml (~L47) test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.create_connection(('localhost',<port>),2); s.close()\""] (mealie, crafty-controller). tandoor/wger use urllib.request variants for real HTTP checks.
Docker healthcheck — DB/Redis sidecars templates/paperless-ngx/docker-compose.yml (~L107, L129) postgres: pg_isready -U <user> -d <db>; mariadb: healthcheck.sh --connect --innodb_initialized; redis: redis-cli ping. App container gets depends_on: <db>: condition: service_healthy.
MariaDB sidecar — MARIADB_AUTO_UPGRADE=1 templates/bookstack/docker-compose.yml (bookstack-db environment:), also kimai/nextcloud/romm Every mariadb: sidecar carries MARIADB_AUTO_UPGRADE=1 (operator ruling 2026-09-13, felhom.eu/documentation/audits/SPIKE-r459-mariadb-upgrade-2026-09-06.md). Without it a major engine move starts on the old datadir, logs that the conversion was skipped, and says Check required! on every start forever (R-459); with it the engine converts in ~7 s and backs its system tables up first (system_mysql_backup_<ver>.sql.zst left in the datadir). MARIADB_DISABLE_UPGRADE_BACKUP stays UNSET — that backup is the precaution. Not an image change, so catalog_since does not move. TRAP (R-464): the entrypoint prints MariaDB upgrade not required on an UNSUPPORTED downgrade too — ask mariadb-upgrade --check-if-upgrade-is-needed (exit 0 = needed, 1 = not), never the log line. PostgreSQL sidecars have NO equivalent (the image runs no pg_upgrade, R-463). Until Slice 4 (R-448) ships, no template may move a mariadb:/postgres: pin across a MAJOR — scripts/check-engine-major.py refuses it in the pre-push hook.
Memory convention templates/paperless-ngx/docker-compose.yml (~L71) + .felhom.yml resources: EVERY service has deploy.resources.limits.memory (compose is the enforcement). NO reservations anywhere. .felhom.yml mem_limit = SUM of all containers' limits (see paperless header comment: 768+256+128=1152M); mem_request = expected steady-state usage, display-only.
Compose file skeleton templates/paperless-ngx/docker-compose.yml (header) Header comment (app, domain, DB type, RAM math, Pi), restart: unless-stopped, TZ=Europe/Budapest, explicit container_name, traefik-public external network + <app>-internal for DBs, Traefik labels with Host(`${SUBDOMAIN}.${DOMAIN}`), named volumes for DB/config (NVMe), ${HDD_PATH}/appdata/<app>/... for bulk data, ${USERDATA_PATH}/... for customer-browsable content.
App-email (SMTP shim) opt-in templates/vaultwarden/.felhom.yml (smtp_mapping:) + README.md §smtp_mapping smtp_mapping maps shim host/port/security/from to the app's own env names; compose MUST reference the mapped ${VAR:-} keys with empty defaults. STARTTLS if the app can accept self-signed certs, else security_value: "NONE" plaintext (or the :2526 plaintext listener for STARTTLS-insistent clients — see calcom/nextcloud). TRAP: an image that treats defined-but-EMPTY mail vars as "set" (vaultwarden — campaign F1 2026-07-06) needs its own enable-flag gated false in compose and flipped "true" via smtp_mapping.extra; boot-prove a fresh email-off deploy for every new smtp-mapped app.
Probe-container naming templates/vaultwarden/docker-compose.yml (container_name: vaultwarden) + templates/sparkyfitness/ The controller-side healthcheck.checks[] probe dials the container whose name equals the stack (directory) name exactly; fallback = the FIRST running prefix-match, which in a multi-container stack can be the DB (verified: felhom-controller/controller/internal/stacks/healthprobe.go findProbeContainer). So the Traefik-exposed service's container_name must be exactly the stack name; sidecars <app>-db, <app>-redis, ….

3. Dangerous lookalikes — do NOT copy

Trap Why it bites Use instead
templates/gokapi/docker-compose.yml custom entrypoint seeding config.json + --deployment-password One-off hack because Gokapi has no env-var headless setup (pinned to ConfigVersion 21 / v1.9.6). Copying this entrypoint pattern to another app will break on image updates. Normal env-var config via deploy_fields; entrypoint-seeding only as last resort.
templates.json + scripts/generate-customer.sh LEGACY Portainer-only mechanism (marked so in README.md). New apps do NOT need entries here; the controller syncs templates/<app>/ directly. Just templates/<app>/{docker-compose.yml,.felhom.yml}.
templates/uptime-kuma/docker-compose.yml test: ["CMD", "extra/healthcheck"] Image-provided binary, unique to this image — not a portable pattern. Pick the wget/curl/node/python family matching your image (§2).

4. Seams & interfaces (cross-repo)

  • Controller pulls this repo (default 15m) and copies templates/<app>/docker-compose.yml + .felhom.yml to /opt/docker/stacks/<app>/; it NEVER overwrites deployed app.yaml / .env; SHA-256 change detection. Contract description: README.md §"How It Works".
  • .felhom.yml is the contract surface consumed by felhom-controller (repo felhom-controller/): deploy_fields drive the deploy wizard, resources the deploy screen hints, healthcheck.checks the controller-side probe, smtp_mapping the app-email injection at deploy/redeploy.
  • Assets are NOT in this repo: logo/screenshots resolve from felhom.eu via slug ({assets.base_url}/assets/{slug}-logo.webp — see comment block in templates/paperless-ngx/.felhom.yml).
  • Commit+push to main IS the deploy: the controller picks changes up on next sync.

5. Extension points (adding a new app)

  1. templates/<app>/docker-compose.yml — copy templates/paperless-ngx/docker-compose.yml skeleton; every service needs container_name, restart: unless-stopped, TZ=Europe/Budapest, deploy.resources.limits.memory, a healthcheck (family per §2), Traefik labels on the web service, traefik-public external + <app>-internal network if it has a DB.
  2. templates/<app>/.felhom.yml — copy templates/paperless-ngx/.felhom.yml; required keys per §2; DOMAIN + SUBDOMAIN fields always; mem_limit = sum of compose limits; Hungarian user-facing text; healthcheck.checks probe.
  3. Update README.md App Catalog + Variable-types tables (convention — every existing app is listed).
  4. Skip templates.json / generate-customer.sh (legacy, §3).
  5. Email-capable app: add smtp_mapping + matching ${VAR:-} compose lines (§2 last row).

6. Known inconsistencies (observed — NOT fixed)

  • README.md lists templates/filebrowser/ in the tree, but no such directory exists; conversely templates/recipe-importer/ exists but is absent from README's tree and both catalog tables.
  • README.md field-type table (§".felhom.yml Format") omits subdomain (used by all 53 apps) and secret_input (templates/romm/.felhom.yml), and lists boolean which no descriptor uses.
  • README.md says smtp_mapping is "Currently mapped: Vaultwarden, Mealie" — six descriptors now carry it (calcom, gitea, mealie, nextcloud, rallly, vaultwarden).
  • Healthcheck URL style drifts: localhost vs 127.0.0.1, trailing-slash vs none, curl -f vs curl -sf, and two distinct Node one-liner styles (compact rallly vs verbose docmost).
  • One unquoted generator value (generate: password:24) among otherwise-quoted generate: "..." values.
  • The in-file comment block in .felhom.yml headers (e.g. templates/paperless-ngx/.felhom.yml "Generator types") omits base64key:N, which README.md documents and one app uses.