persistence sweep: 53 templates measured; gramps-web + wishlist fixed; runtime gate added
Campaign 10's R-156 found papra writing its database into the container's writable layer while the volume the template preserves stayed empty — a backup that completes, verifies, and contains nothing. papra was never the point: nothing anywhere checked that the folder a template preserves is the folder the app writes to. All 53 templates have now been measured live. 43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason, and never folded into CLEAN. FIXED (neither app is deployed anywhere, so nothing was stranded): - gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's own environment names, matching upstream's reference compose. Proven: users.sqlite and the family-tree files survive a redeploy byte-identical, same inode. - wishlist mounted wishlist_data:/data, another path the app never writes; prod.db landed in the ANONYMOUS volume from the image's VOLUME directive — absent from ResolveDockerVolumeNames, so never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads. Proven: prod.db byte-identical, same inode, across a redeploy. Every corrected path confirmed by two independent sources — the shipped image's own environment/Config.Volumes and upstream's reference compose — never inferred from a directory name. papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up recreate the container and destroy the writable layer its documents live in. The fix is prepared and proven in the scratch guest (current: db.sqlite differs after a redeploy, so a real account created via the API is lost; fixed: byte-identical, it survives). Referred to the operator with the two options; no migration written. NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one. This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. It refuses to report at all unless it has just re-proven itself in both directions against two canary templates that differ only in which path the volume mounts at, so every run carries a live demonstration of R-156 and of its fix. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests driving check(), the function __main__ calls; every rule red-proofed. Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it is proposed as R-160. Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu is fenced this session): audits/persistence-sweep-2026-08-02/
This commit is contained in:
@@ -19,6 +19,7 @@ None — this repo is templates/config, not code. See §2/§5.
|
||||
| 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. |
|
||||
| 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…). |
|
||||
|
||||
Reference in New Issue
Block a user