dc0ab8b2a8
gates / gates (push) Successful in 2s
NEW-APP-CHECKLIST.md: the reviewer's draft reviewed - 60 rows in 10 groups, each with how/why and a since date; 7 rows added, 16 sharpened, 9 wrong claims fixed. onboarding/_TEMPLATE.md (one line per id), onboarding/wger.md (the pilot, exempt app, 11 open rows each a register row), onboarding/EXISTING-APPS-GAPS.md (read only, from scripts/onboarding_gaps.py). Gate onboarding (scripts/check-onboarding.py) in --fast: a template directory not among the 53 published before 2026-10-01 needs a complete record; decoys in test_gate_decoys.py (16 cases, 5 gate mutants seen red). CLAUDE.md, REUSE.md 5, README point to it. No template changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
86 lines
21 KiB
Markdown
86 lines
21 KiB
Markdown
# 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
|
|
|
|
Templates are config; the few script helpers other scripts must REUSE, never re-implement:
|
|
|
|
- `scripts/ladder.py` — the test record (`update_ladder:` in `.felhom.yml`): `parse`, `check_entry`,
|
|
`images_in` (the per-service image reading every gate makes), `append_entry`. One JSON entry per line.
|
|
- `scripts/image_digest.py` — `resolve(ref)` → the digest the registry serves now (the one Docker
|
|
records in `RepoDigests`). stdlib only — the CI runner has no `requests`/PyYAML.
|
|
- `scripts/upgrade_boxport.py` — runs the box walk's fixtures (`upgrade_fixtures_box*.py`) on the bench.
|
|
|
|
## 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. |
|
|
| **Known default login → `after_install:`** (decision 45, controller ≥ 0.279.0) | `templates/bookstack/.felhom.yml` (`ADMIN_PASSWORD` field + `after_install:` block); `FIRST-ADMIN.md` for every app | An app that starts with a known admin login gets a generated `type: password` field (`generate: "password:24"`, `locked_after_deploy: true`) and ONE `after_install: {service, env: [ADMIN_PASSWORD], command: [...], success: "<marker the output must carry>"}` through the app's OWN CLI, run once after a FRESH install. Keep `app_info.default_creds` — the page hides it once the command succeeded and warns while it is in effect. **Prove on 9202 (drill catalog) before live: the default fails, the generated password works, a wrong one fails.** TRAPS: `success:` is required because a CLI can exit 0 on an error (claper's `rpc`); **pass the password as its own argument, never inside program code** (`sys.argv[1]` — mealie, wger; security review 2026-09-29); a special-character policy uses `generate: "password:24:special"` (controller ≥ 0.280.0, calibre-web); a Hungarian first-steps change needs `check-copy-i18n.py --capture-freeze`. |
|
|
| **Open first-run screen → `setup_gate:`** (decision 46, controller ≥ 0.280.0) | `templates/n8n/.felhom.yml` (probe), `templates/uptime-kuma/.felhom.yml` (no probe → the household's button); `FIRST-ADMIN.md` | `setup_gate: true` + optional `setup_done_probe: {url: http://<container_name>:<port>/<path>, field: <dotted.json.path>, done: "<text>"}` | TRAPS: the probe must FLIP on the setup — measure it before and after on 9202; an app with open sign-up after setup (R-711) is not closed by the gate; `url` is read on the docker network, so it names the container, not the subdomain. |
|
|
| **Open sign-up after the setup → `signup_block:`** (decision 47, controller ≥ 0.281.0) | `templates/opengist/.felhom.yml`, `templates/calcom/.felhom.yml` | `signup_block: "<traefik matcher>"` + `app_info.add_people` (hu) / `i18n.en.app_info.add_people` | TRAPS: block the app's API sign-up call, not only the page; an app's own invite link often uses the same address (the household's 15-minute window covers it); `add_people` is copy — `--capture-freeze`. |
|
|
| **The app's own sign-up switch → `after_setup:`** (decisions 47/49, controller ≥ 0.282.0) | `templates/homebox/` (compose `HBOX_OPTIONS_ALLOW_REGISTRATION=${SIGNUP_OPEN:-true}` + `.felhom.yml` `after_setup.env`) | compose default OPEN; `after_setup: {env: {SIGNUP_CLOSED: "true"}}` | TRAPS: the default must be OPEN (an installed app is unchanged by the catalog); several apps' switch also refuses the household's FIRST account, so never set it at install; an app with no env switch (opengist, wishlist) keeps the block alone — make that block case-insensitive. |
|
|
| **A same-tag security fix → a RE-TEST step** (`09` decision 52, 2026-09-30) | `scripts/retest-floating.py` (the ONE monthly command), `scripts/retest_box.py` (the box venue), `scripts/box_walk.py` (the 9202 client), `upgrade-test.py --retest` + `--write-ladder` | An entry whose `from` == `to`, with `digest_from` (the tested digest it started from) and `box_evidence`. `--dry-run` lists; `--engines-only` is the ruled start. Runbook `felhom.eu/documentation/runbooks/monthly-floating-retest.md`. TRAPS: never write one by hand (the gates refuse no new digest, a digest the registry stopped serving, a missing box proof, a `digest_from` that is not the previous entry's digest); `image_digest.resolve` IGNORES a `@digest` in its argument — ask the registry by manifest for a digest. |
|
|
| **A bench-only environment override** (R-739) | `upgrade-test.py` `BENCH_ENV_OVERRIDES` | Only for an app that cannot run on the bench at all (wanderer: its web server calls the DB at the public https name). Every verdict carries `bench_overrides`. Never a template change. |
|
|
| 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)
|
|
|
|
0. **First: `cp onboarding/_TEMPLATE.md onboarding/<app>.md`** and work `NEW-APP-CHECKLIST.md` top to bottom — the
|
|
record is what the `onboarding` gate (`scripts/check-onboarding.py`) reads; a new template directory without a
|
|
complete record is refused at push. The app is tested from the drill catalog on 9202 (`09` §6.5) and reaches the
|
|
live catalog in ONE commit with its record.
|
|
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. **Moving an existing app's `image:`** is not an edit: run `scripts/upgrade-test.py --move <app> <svc>=<ref>`
|
|
on the bench, walk it on a scratch box, then `upgrade-test.py --write-ladder …` writes the compose move,
|
|
`catalog_since` and the ladder entry. `check-test-record-move.py` refuses a move without it (`09` decision 13).
|
|
|
|
## 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.
|