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
163 lines
14 KiB
Markdown
163 lines
14 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
|
|
|
|
- **Adding a NEW app starts with `cp onboarding/_TEMPLATE.md onboarding/<app>.md`** (operator request
|
|
2026-10-01). `NEW-APP-CHECKLIST.md` is the list — 60 checks in 10 groups, each with how to test it and
|
|
why it exists; the record answers every id `done` (with evidence that exists), `n/a` (with a reason) or
|
|
`open`. The template and its complete record are published in ONE commit: `scripts/check-onboarding.py`
|
|
(gate `onboarding`, in `--fast`, so the hook and CI) refuses a new template directory without one. The 53
|
|
apps published before 2026-10-01 are exempt by name; what the catalog shows for them is
|
|
`onboarding/EXISTING-APPS-GAPS.md` (regenerate with `python3 scripts/onboarding_gaps.py`).
|
|
- **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. **ONE APP AT A TIME, since 2026-09-25 (`09` §3 decision 35):** the BOX converts a PostgreSQL
|
|
major itself (controller v0.273.0 — dump from the old engine, load into the new, check, undo on any
|
|
failure), but ONLY on a step whose ladder entry carries `engine_conversion {service, engine: postgres,
|
|
from, to}`. So a PostgreSQL major passes the gate only when the template's ladder entry for that step
|
|
is `proven`, cites BOTH venues (`evidence` + `box_evidence`) and carries the mark — written by
|
|
`upgrade-test.py --write-ladder`, which refuses unless the bench converted it (harness v4) AND the box
|
|
converted it through the product — and only as the ONLY image move in its commit. Every other
|
|
PostgreSQL app stays refused until it has its own proof. **PostgreSQL 18 moves the data mount** to
|
|
`/var/lib/postgresql` (18 refuses even an empty volume at `/var/lib/postgresql/data`); the writer and
|
|
the harness move that line with the image (`pg_mounts_for`). The postgis family is judged too (it was
|
|
not before 2026-09-25).
|
|
**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). **Every entry but the
|
|
newest carries its own compose at `templates/<app>/steps/<ladder.step_key(to)>.yml`** (2026-09-24, `09` §6.4 part
|
|
5): the box pins that file, one step per press. The writer keeps the superseded step's definition — fixes
|
|
included — and `check-test-record.py` rule 4 refuses a step with no file or a file naming other images.
|
|
- **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.
|