# REPORT — image pinning: `:latest` eliminated from the catalog (2026-07-12) **Class:** implementation (template change + sweep gate; push = deploy). **Baseline:** `main` @ `2ebe082` → (this commit). **Scope:** 5 compose image lines + new gate script + CLAUDE.md rule. No healthcheck/env/format touch-ups; no controller change; no redeploys triggered. ## Why The controller's recovery-unit `ImagePins` (felhom-controller `internal/backup/recovery_unit.go`) pins the compose *tag*: restoring a `:latest`-pinned app re-pulls whatever `:latest` points to at restore time — potentially schema-incompatible with the data being restored. Load-bearing rule: **a deployed app pins to the digest it is currently running** (the pin never causes a version jump); undeployed apps pin to the latest verified upstream stable. ## The five pins (old → new, with evidence) | App | New pin | Evidence (all gathered 2026-07-12) | |-----|---------|------------------------------------| | **calibre-web** (DEPLOYED, demo 9201) | `crocodilestick/calibre-web-automated:v4.0.6` | Running RepoDigest on 9201: `sha256:c31a738b6d5e…`; Docker Hub tag `v4.0.6` digest: `sha256:c31a738b6d5e…` — **exact match** (Scenario A: pin == running image; a newer tag would have violated the no-silent-upgrade guard) | | **bentopdf** (undeployed) | `ghcr.io/alam00000/bentopdf:v2.8.6` | GitHub latest release v2.8.6 (2026-06-28); ghcr manifest digest `sha256:eaeea1e4…` == `latest` | | **papra** (undeployed) | `ghcr.io/papra-hq/papra:26.6.1-rootless` | ghcr `latest` digest `sha256:a7a42e22…` == `26.6.1-rootless`; the `-root` variant differs (`c5757540…`) — the **rootless** variant is what `:latest` was shipping, so behavior (permission semantics) is preserved exactly | | **recipe-importer** (own image, undeployed) | `gitea.dooplex.hu/admin/recipe-importer:v0.9.11` | Its repo CHANGELOG declares v0.9.11 (2026-02-26); the registry **already had** `v0.9.11` (build.sh pushes `:VERSION`+`:latest` together), digest `sha256:f3cb617c…` == `latest` — **Scenario C's retag was unnecessary**; no push performed | | **termix** (undeployed) | `ghcr.io/lukegus/termix:2.5.0` | ghcr digests: `latest` == `2.5.0` == `release-2.5.0` (`sha256:4d337131…`); bare CalVer tag chosen | All five `docker manifest inspect ` checks ran on the build server (180) with per-command exit codes checked — 5× OK, zero pipe-swallowed. `templates.json`: contains **no image strings** (legacy Portainer-era metadata) — untouched, per §4.3. ## Sweep + red-proofs (§10 / Scenario D) - New rerunnable gate: `scripts/check-image-pins.py` — scans every `templates/*/docker-compose.yml` `image:` line; fails on floating tags (`latest`,`dev`,`nightly`,`edge`,`main`,`master`) AND on untagged refs (implicit :latest); `@sha256:` counts as pinned; registry-port refs handled. - **Final run: `image-pin gate OK — 53 templates, 0 unpinned images`, exit 0.** - **Red-proof 1** (floating tag): reverted termix to `:latest` → exit 1 with `templates/termix/docker-compose.yml:11 … [floating tag :latest]` → restored → exit 0. ✓ - **Red-proof 2** (bare shape): stripped termix's tag entirely → exit 1 with `[NO TAG (implicit :latest)]` → restored → exit 0. ✓ (Both §7-D shapes proven.) ## Fleet-deployment verification (Scenario B) - demo 9201: `docker ps` — bentopdf/papra/termix/recipe-importer **not deployed**; calibre-web running from `:latest` (Up 4 hours at check time). - felhotest (router.abonet.hu:33022): **unreachable** (connection refused). - Peti's box: offline (dead fan, no ETA). - Operator asked and approved proceeding: every pin is digest-identical to current `:latest`, so even a hidden deployment elsewhere sees zero change vs. the status quo on its next redeploy. ## Git-sync no-op verification on 9201 - Pushed `71828a8`; sync triggered via `docker exec felhom-controller curl -s -X POST http://127.0.0.1:8080/api/sync` → HTTP 200. (Gotcha confirmed: guest-netns `127.0.0.1:8080` is NOT reachable — the API must be curled from inside the controller container; the public-URL POST proxy gotcha also applies.) - Synced file on 9201: `/opt/docker/stacks/calibre-web/docker-compose.yml:21` now reads `image: crocodilestick/calibre-web-automated:v4.0.6`. ✓ - calibre-web container after sync: `crocodilestick/calibre-web-automated:latest | Up 5 hours (healthy)` — **uptime unchanged, no restart**; the running image's digest equals the v4.0.6 pin, so the next redeploy pulls the byte-identical image. ✓ ## Observations (not acted on) - When Peti's box returns: check whether bentopdf/papra/termix/recipe-importer are deployed there; if any is running an OLDER digest than the pin, decide upgrade vs. per-node pin then. felhotest should get the same check when reachable again. - papra upstream publishes only `-root`/`-rootless` variant tags — any future manual upgrade must keep the `-rootless` suffix or permissions semantics change. - bentopdf's ghcr `tags/list` API returns a stale/partial page (newest listed was 1.15.3 while v2.8.6 exists and serves) — tag existence must be checked via manifest HEAD/inspect, not tags/list. - recipe-importer's registry `:latest` remains published (allowed; only templates must not use it).