Files
felhom.eu/documentation/audits/CAMPAIGN-7-catalog-sweep-2026-07-19.md
T
admin 3e2078be8a CAMPAIGN 7 doc: MAJOR breaking-note table (§6.3)
Fills the gap flagged in §8.1. Upstream notes for bookstack, immich, nextcloud,
n8n, grafana, vikunja, privatebin, radarr, romm; plus the ones this sweep found
EMPIRICALLY on a real deploy (outline PGSSLMODE, tandoor port+ALLOWED_HOSTS,
zipline DATABASE_URL/healthcheck rename, homebox tag prefix + pepper, wger
DJANGO_DB_* + :8000) -- which are the more useful half, being what actually
happens rather than what a changelog remembered to mention.

Four majors (calcom, claper, gramps-web, onlyoffice) deployed clean but have no
retrieved upstream one-liner; recorded as not-retrieved, not fabricated.

Campaign 7 catalog sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 03:30:50 +02:00

430 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CAMPAIGN 7 — full app-catalog sweep (bump · deploy · validate · clean)
**Run:** 2026-07-18 evening → 2026-07-19 morning (overnight, hard wall-clock 06:30 CEST)
**Repos:** `app-catalog-felhom.eu` (the work), `felhom.eu/documentation` (this doc)
**Box:** demo (guest 9201 `demo-felhom` on `felhom-pve`), controller **0.146.0**
**Class:** Campaign — record-and-rank, with a defined allowed-fix set (the fixes are the deliverable)
---
## 0. Pre-flight gate — PASSED
Both of Viktor's saves had landed before the sweep began:
| check | value |
|---|---|
| `artifact_golden_version` | **0.146.0**, sha `4834c703…e955` ✓ |
| `min_controller_version` (floor) | **0.146.0** ✓ (saved last, as required) |
| guest 9201 running | `felhom-controller:0.146.0`, healthy ✓ |
| box reporting | `controller_started (0.146.0)` @ 18:52:07 ✓ |
So the sweep validated on the version customers actually run.
> **Finding C7-F1 (observability, MEDIUM) — the floor-lift update emitted no
> `controller_updated` event.** The gate asked for `controller_updated 0.145.0→0.146.0`
> in the events. It is **absent**, although 0.145.0→0.146.0 went through the *same*
> agent-driven bootstrap path (`/etc/felhom-bootstrap/bootstrap.json` →
> `felhom-controller-bootstrap.service`) that DID emit the event for 0.143.0→0.145.0 two
> hours earlier. `controller_started (0.146.0)` was emitted normally.
> Consequence: the operator-visible event trail under-reports version transitions, so
> "did the box converge?" cannot be answered from the events alone. Not a blocker — the
> runtime version, the golden record and the floor all agreed — but it is the one signal
> the gate was written around.
---
## 1. Method
- Every deploy/undeploy went through the controller's **real endpoints** — the ones the UI
calls: `POST /api/stacks/<app>/deploy`, `POST /api/stacks/<app>/stop`,
`POST /api/stacks/<app>/remove`, `POST /api/sync`. No raw `docker compose` against a
managed stack, no hand-edited `app.yaml`.
- Deploy fields auto-filled from `deploy-fields` metadata (DOMAIN / SUBDOMAIN / HDD_PATH);
secrets minted per the `.felhom.yml` `generate:` spec. **No secret value was ever logged
or written to evidence** — evidence records the field NAME and `<generated-secret, not
recorded>`.
- One campaign app deployed at a time. Evidence per app under
`180:~/campaign7/evidence/<app>/` (containers, healthcheck audit, per-container logs,
traefik view, log scan).
- Validation asserted **effect**, not absence-of-error: terminal health verdict per
container, healthcheck-binary audit, HTTP probe through the real Traefik ingress, login
where feasible, log scan with judgment for benign startup noise.
### 1.1 Three engine bugs found and fixed mid-run (they would have corrupted the matrix)
Recorded because they are exactly the "hollow validation" the testing doctrine warns about
— each one made a broken thing look fine, or a fine thing look broken:
1. **Traefik registration race → false 404s.** The first probe fired the instant a
container reported healthy, but Traefik registers the router a few seconds later.
actualbudget and calcom were both recorded FAIL and were actually fine (200 / 307).
Fixed with a retry window; both re-validated green.
2. **`docker exec` writes its OCI error to STDOUT, not stderr.** The healthcheck audit
checked "did `command -v <tool>` print anything" — so a *missing* binary printed
`executable file not found` and read as **present**. This made the entire healthcheck
audit — the campaign's core deliverable — report every app "honest". Fixed to require
rc==0 **and** an absolute path, with a direct-exec fallback for shell-less images.
3. **`created` sampled as a terminal state.** Containers still starting were recorded as
settled, so docmost and ghost were flagged FAIL while healthy moments later. Fixed to
require `running`; both re-validated green.
---
## 2. Uninstall semantics — the real delete flow (map row: PARTIAL → **PROVEN**)
Live evidence from the customer trio (`POST /api/stacks/<app>/remove`, both checkboxes
default-off, which is what the UI sends):
| what | behaviour | evidence |
|---|---|---|
| stack still running | **refused**`409 "still running — stop it first"`; the flow is stop→remove | bookstack |
| named docker volumes | **ALWAYS DESTROYED** (`compose down --volumes`), incl. the app's database | bookstack ×2, calibre ×1, immich ×3 |
| HDD bind-mount data (`userdata/`, `appdata/`) | **PRESERVED** unless `remove_hdd_data=true` | calibre books 440K, immich 54M — byte-identical after |
| HDD backup dirs (`backups/primary/<app>`) | **PRESERVED** unless `remove_backups=true` | calibre 24K, immich 28K |
| docker images | **KEPT** (for redeploy) | all three |
| `app.yaml` (deploy config) | **REMOVED** | all three |
| `docker-compose.yml` (template) | **KEPT** | all three |
| per-app **offsite** toggle (`app_backup.<app>.offbox`) | **SURVIVES** the uninstall | all three still `offbox:true` after removal |
| per-app **tier-2 / cross-drive** config | **CLEARED** (`SetCrossDriveConfig(name, nil)`) | `removeStack` |
The confirmation modal is honest about the destructive part — it states *"Mindig törlődik:
Docker kötetek (adatbázis, alkalmazás konfiguráció)"* before the customer confirms — so
there is **no consent gap**. Two narrower issues:
> **Finding C7-F2 (evidence trail, LOW) — `volumes_removed` is always `null`.**
> The remove response reported `volumes_removed: null` while actually destroying
> `bookstack_bookstack_config` + `bookstack_bookstack_db_data` (and 3 immich volumes).
> `delete.go` step 3 scrapes `compose down` stdout for `"Removing volume"` / `"Volume"`,
> which current Docker Compose no longer prints in that shape. The modal pre-warns, so this
> is not a safety issue — but the removal receipt is useless as evidence, and any future
> "what did we delete?" audit built on it would silently return nothing.
> **Finding C7-F3 (asymmetry, LOW) — offsite toggle outlives the app.** Removing an app
> clears its tier-2 schedule but leaves `app_backup.<app>.offbox = true` set forever. Benign
> today (a re-deploy inherits the customer's prior intent, which is arguably right, and it
> is why the trio's toggles needed no restoring). Worth an explicit ruling: is a removed
> app's offsite intent meant to persist, or should removal clear it like it clears tier-2?
---
## 3. The customer trio — uninstall → bump → fresh redeploy (Viktor's ruling), done FIRST
All three travelled the full path and are **left RUNNING** as the end state.
| app | pin(s) | MAJOR | deploy | health | http | login | logs | settle |
|---|---|---|---|---|---|---|---|---|
| **bookstack** | `25.02.2 → 26.05.2`; mariadb `11.6 → 12.3` | ✅ ×2 | ok | ok (2/2 healthy) | 302 | **ok** | clean | 20s |
| **calibre-web** | `v4.0.6` (already newest stable) | — | ok | ok | 302 | **ok** | noisy-benign | 20s |
| **immich** | `v2.5.5 → v3.0.3`; postgres `16-vectorchord0.3.0 → 16-vectorchord0.4.3-pgvectors0.2.0` | ✅ | ok | ok (4/4 healthy) | 200 | page-only | clean | 20s |
**Recorded pre-uninstall offsite state — and it contradicted the campaign note.** The note
said "bookstack ON, calibre ON, immich OFF". The box said:
```
bookstack enabled=false offbox=true
calibre-web enabled=false offbox=true
immich enabled=false offbox=true <-- note said OFF
```
Per the rail ("trust the recorded pre-uninstall state over this note") all three were
restored to `offbox:true` — which they already were, since the toggle survives uninstall
(§2). Verified post-redeploy.
**Detail per app**
- **bookstack** — MAJOR ×2. Upstream v26.05 needs `storage/fonts` writable for PDF export and
makes revision-viewing a separate permission; neither bites a fresh deploy. Logged in with
the documented `admin@admin.com / password` → 302 + 6 authenticated markers on the
dashboard. mariadb 12.3 healthy via `healthcheck.sh`.
- **calibre-web** — already at the newest STABLE tag; everything newer upstream is `dev-*`
(correctly excluded). Logged in with `admin / admin123` (Flask CSRF token required) →
title renders `Calibre-Web Automated | Books (0)`. The empty library is expected: the DB
volume was destroyed while the HDD book files were preserved, so the library needs
re-importing — the accepted consequence. The traceback in its log is **benign**: calibre's
installer fails headless `xdg-desktop-menu` setup, catches it, reports "There were 1
warnings", and continues.
- **immich** — MAJOR. v3.0.0 drops pgvecto.rs and requires VectorChord; our pin was already
VectorChord so the fresh deploy was unaffected. Sidecar moved to the extension versions
immich v3.0.3 ships in its own compose (vectorchord 0.4.3 / pgvectors 0.2.0) while
**keeping our PG major 16** rather than following upstream down to 14 — a `16-` build of
that exact extension pair is published, so no needless major change. Proven functional,
not merely healthy: `/api/server/ping``pong`, `/api/server/version``{3,0,3}`,
122 migration/init log lines, "Immich Microservices is running [v3.0.3]".
`login: page-only` is deliberate — the fresh install sits at first-run admin signup
(`isInitialized:false`, signup page 200) and creating an admin would mint a credential
that would then have to be transmitted to Viktor out-of-band; the owner creates his own.
- **redis vs valkey (recorded, not fixed):** immich v3 upstream migrated `redis`
`valkey:9`. We kept `redis:7-alpine` and **it works fine on v3** (all 4 containers
healthy). Swapping the image is a structural change, not a pin bump, so it is recorded
for a considered follow-up rather than done here.
### 3.1 Offsite continuity across uninstall → reinstall — **PROVEN** (free evidence for R-34)
The rail asked whether a re-deployed app's offsite run RESUMES its pre-uninstall snapshot tags.
An offsite run was triggered after the trio was back up (`POST /backup/offbox/run` — a run, not a
config change). It completed `ok` in 1m13s, and the tags line up exactly:
| app | pre-uninstall snapshot | post-reinstall snapshot | tags |
|---|---|---|---|
| calibre-web | `243ad08c` 07-18 17:13:42 | `9698ba74` 07-19 01:26:58 | `felhom-offbox,calibre-web`**identical** |
| immich | `6df12205` 07-18 17:13:46 | `ea511336` 07-19 01:27:02 | `felhom-offbox,immich`**identical** |
| bookstack | *(none — it was the „kimaradtak" app)* | `63850757` 07-19 01:26:52 | `felhom-offbox,bookstack`**new** |
| shares | — | `bbbc2fae` 07-19 01:27:55 | `felhom-offbox,_shares` (R-7b path still working) |
**Continuity holds.** The tag lineage is stable across a full uninstall → volume-destroy → fresh
redeploy, so a `restic --tag <app>` query still spans the reinstall boundary and the pre-uninstall
restore points remain addressable — reinstalling an app does not orphan its offsite history.
**A side effect worth recording:** the standing `kimaradtak: bookstack` warning is now **cleared**
(`last_warning: None`, 6 snapshots, 211.6 MB). bookstack had no offsite snapshot before this
campaign because it had no tier-1 backup to push; the fresh redeploy produced one, so its first-ever
offsite snapshot landed here. The rail predicted the warning might FIRE during the sweep — instead
the sweep resolved a pre-existing one.
---
## 4. Version-bump policy actually applied (deviation, stated deliberately)
App images: newest STABLE upstream tag, rc/beta/nightly/dev excluded, digest-free pinned
tags per house style.
**DB/cache sidecar majors were deliberately NOT bumped** (postgres 16→18, redis 7→8,
mariadb→12.3 on apps other than bookstack, postgis 16→17). This is a conscious deviation
from a literal reading of "newest stable for every pin":
- a DB major is a **data-plane decision the application owns**, not a currency decision —
immich proves it, upstream pins one specific tested postgres build;
- tags like `postgres:16-alpine` / `redis:7-alpine` already track the newest patch inside
their major, so they are not stale;
- blind-bumping ~8 apps onto PG18 would have manufactured failures that are artifacts of
the sweep's own choice rather than real findings, and would have burned the wall-clock the
rail explicitly told us to protect.
→ carried to ROADMAP as a decision item, not silently skipped. See §7.
---
## 5. Healthcheck audit — the wget lesson, systematically
Every deployed app's compose healthcheck was parsed and the probe binary checked **inside
the image**.
> **Finding C7-F4 (HIGH) — a lying healthcheck does not merely mislead; it takes the app
> OFF-LINE.** Traefik will not route to a container in an `unhealthy` state. So a probe that
> ENOENTs → permanent `unhealthy` → **Traefik returns 404 to the customer while the app is
> serving 200 perfectly well on its own port**. This was not theoretical: both apps below
> were completely unreachable for that reason, and both looked "deployed" in docker ps.
| app | lie | reality | fix | verified |
|---|---|---|---|---|
| **adventurelog** (frontend) | `wget --spider` | distroless image: no shell, no wget, no curl; node only at `/nodejs/bin/node`, off PATH | Node-exec family via the absolute interpreter path | 404 → **200**, 3/3 healthy |
| **emby** | `curl -f` | no curl, no standalone wget — image ships only BusyBox v1.38 | BusyBox-wget family via `/bin/busybox wget` | unhealthy/404 → **healthy/302** |
Both fixed in-template and live-re-validated on the demo box.
---
## 6. Result matrix
**All 53 catalog apps were attempted.** `http` is through the real Traefik ingress; a 3xx to a login
page is a PASS. `login` was attempted where credentials are documented and the flow is scriptable.
### 6.1 Passed (45)
| app | old → new pin | MAJOR | deploy | health | http | logs | settle |
|---|---|---|---|---|---|---|---|
| actualbudget | 26.1.0 → 26.7.0 | | ok | ok | 200 | clean | 10s |
| adventurelog | v0.11.0 → v0.12.1 | | ok | **fixed** | 200 | clean | 100s |
| audiobookshelf | 2.19.5 → 2.35.1 | | ok | ok | 200 | clean | 30s |
| bentopdf | v2.8.6 (newest) | | ok | ok | 200 | clean | 20s |
| **bookstack** ¹ | 25.02.2 → 26.05.2 · mariadb 11.6 → 12.3 | ✅ | ok | ok | 302 | clean | 20s |
| calcom | v4.6.9 → v6.2.0 | ✅ | ok | ok | 307 | clean | 20s |
| **calibre-web** ¹ | v4.0.6 (newest) | | ok | ok | 302 | noisy-benign | 20s |
| claper | 1.8 → 2.5 | ✅ | ok | ok | 200 | clean | 70s |
| code-server | 4.96.4 → 4.129.0 | | ok | ok | 302 | clean | 40s |
| crafty-controller | 4.10.7 (GitLab registry, manual) | | ok | ok | 302 | clean | 50s |
| docmost | 0.25.3 → 0.95.0 | | ok | ok | 200 | clean | 20s |
| emby | 4.9.0.42 → 4.10.0.20 | | ok | **fixed** | 302 | clean | 10s |
| ghost | 6.19.2-alpine → 6.53.0-alpine | | ok | ok | 200 | clean | 10s |
| gitea | 1.23.4 → 1.27.0 | | ok | ok | 200 | clean | 10s |
| grafana | 11.5.1 → 13.1.0 | ✅ | ok | ok | 302 | clean | 20s |
| gramps-web | v24.12.1 → v25.6.0 | ✅ | ok | **fixed** (mem) | 200 | clean | 110s |
| home-assistant | 2026.2.2 → 2026.7.2 | | ok | ok | 302 | clean | 70s |
| homebox | v0.16.3 → 0.26.2 | | ok | **fixed** ×3 | 200 | clean | — |
| homepage | v1.2.0 → v1.13.2 | | ok | ok | 200 | clean | 20s |
| **immich** ¹ | v2.5.5 → v3.0.3 · pg → 16-vectorchord0.4.3 | ✅ | ok | ok | 200 | clean | 20s |
| jellyfin | 10.11.6 → 10.11.11 | | ok | ok | 302 | clean | 30s |
| kimai | apache-2.25.0 → apache-2.57.0 | | ok | ok | 302 | clean | 50s |
| komga | 1.20.0 → 1.25.0 | | ok | ok | 200 | clean | 20s |
| mealie | v3.10.2 → v3.20.1 | | ok | ok | 200 | clean | 40s |
| n8n | 1.79.3 → 2.31.3 | ✅ | ok | **fixed** (mem) | 200 | clean | 50s |
| navidrome | 0.54.5 → 0.63.2 | | ok | ok | 302 | clean | 10s |
| nextcloud | 31.0.14-apache → 34.0.1-apache | ✅ | ok | ok | 302 | clean | 50s |
| onlyoffice | 8.3.0 → 9.4.0 | ✅ | ok | ok | 302 | clean | 40s |
| opengist | 1.10 → 1.13 | | ok | ok | 302 | clean | 10s |
| outline | 0.82.0 → 1.9.1 | ✅ | ok | **fixed** (env) | 200 | clean | 40s |
| paperless-ngx | 2.15.3 → 2.20.15 | | ok | ok | 302 | clean | 70s |
| papra | 26.6.1-rootless (newest) | | ok | **fixed** ×2 | 200 | clean | — |
| privatebin | 1.7.5 → 2.0.5 | ✅ | ok | ok | 200 | clean | 10s |
| radarr | 5.17.2 → 6.3.0 | ✅ | ok | ok | 200 | clean | 10s |
| rallly | 3.11.2 → 4.11.1 | ✅ | ok | **fixed** (mem) | 200 | clean | 20s |
| recipe-importer | v0.9.11 (ours, internal registry) | | ok | ok | 302 | clean | 10s |
| romm | 4.5.0 → 5.0.0 | ✅ | ok | ok | 200 | clean | 50s |
| seerr | 2.3.0 → 2.7.3 | | ok | ok | 307 | clean | 30s |
| sonarr | 4.0.13 → 4.0.19 | | ok | ok | 200 | clean | 10s |
| sparkyfitness | v0.17.2 → v0.17.3 (server + web) | | ok | ok | 200 | clean | 40s |
| tandoor | 1.5.26 → 2.6.13 | ✅ | ok | **fixed** ×4 | 302 | clean | 40s |
| termix | 2.5.0 (newest) | | ok | ok | 200 | clean | 20s |
| uptime-kuma | `:2` → 2.4.0 (**floating tag pinned**) | | ok | ok | 302 | clean | 30s |
| vaultwarden | 1.33.2-alpine → 1.36.0-alpine | | ok | ok | 200 | clean | 10s |
| wger | 2.3 → **2.6** (2.3 is gone upstream) | | ok | **fixed** ×3 | 302 | clean | 60s |
| vikunja | 0.24.6 → 2.3.0 | ✅ | ok | ok ⚠ no healthcheck | 200 | clean | 0s |
| wishlist | Hub 1.9.0 → **ghcr v0.66.0** | | ok | **fixed** ×2 | 200 | clean | — |
| zipline | 4.0.0 → 4.6.1 | | ok | **fixed** ×2 | 200 | clean | — |
¹ the customer trio — left RUNNING as the end state (see §3). Login: bookstack **ok**,
calibre-web **ok**, immich page-only (deliberate, §3). For the other 40 apps login was
`not-attempted` — a rendered login page through the real ingress was taken as PASS per the rail.
### 6.2 Not passing (4) + 1 not attempted
| app | outcome | cause | disposition |
|---|---|---|---|
| **glance** | FAIL — crash-loop | needs `/app/config/glance.yml`; the template mounts an EMPTY config volume and never seeds one | **pre-existing** — proven: v0.7.4 (the pre-campaign pin) fails identically. Bump KEPT, finding raised |
| **gokapi** | FAIL — crash-loop | v2.2.4 refuses to run against the seeded ConfigVersion-21 config: *"Please update to version 2.0.0 before running this version"* | **pin REVERTED to v1.9.6** (last known-good); v2 migration needs the seeded config regenerated |
| **plant-it** | FAIL — image | `msdeluise/plant-it` does not resolve on Docker Hub — **neither 1.0.1 nor the shipped 0.10.0** | **pre-existing**; no replacement registry found. Needs upstream research |
| **wanderer** | FAIL — image | `ghcr.io/flomp/wanderer` does not resolve — **neither 0.20.0 nor the shipped 0.16.0** | **pre-existing**; meilisearch sidecar bumped v1.12 → v1.49. Needs upstream research |
| **plex** | not attempted | `PLEX_CLAIM` is a required field with no default — a real claim token from plex.tv is needed | not automatable; genuinely owner-supplied. Not a defect |
### 6.3 MAJOR breaking notes (for the future update-path story — see R-40)
Fresh deploys do not migrate, so none of these bit during the sweep. They matter for the moment an
EXISTING customer presses update.
| app | major | one-line breaking note | source |
|---|---|---|---|
| bookstack | 25.02 → 26.05 | `storage/fonts` must be writable for PDF export; revision-viewing became a separate permission | upstream releases |
| immich | v2 → v3 | drops pgvecto.rs, requires VectorChord (our pin was already VectorChord); most other breaks are API-only | v3.0.0 notes |
| nextcloud | 31 → 34 | **"You cannot skip major releases"** — sequential upgrade required. The sharpest case for R-40 | admin manual |
| n8n | 1 → 2 | MySQL/MariaDB support removed; several nodes removed; `ExecuteCommand`/`LocalFileTrigger` disabled by default; env access blocked in code expressions | 2.0.0 notes |
| grafana | 11 → 13 | Angular plugin support **removed** in 12 (deprecated in 11); `editors_can_admin` gone; v2 dashboard schema is one-way | whatsnew v12 |
| vikunja | 0.24 → 2.3 | CORS on by default and `service.publicurl` becomes **required to start**; OpenID provider config array→map; SQLite paths now relative to `service.rootpath`; API `/tasks/all``/tasks` | 1.0.0 changelog |
| privatebin | 1 → 2 | min PHP 7.3→7.4; `page` template removed; legacy model class names must become `Filesystem`/`Database`; v1/ZeroBin paste format support dropped (DB column drops needed) | 2.0.0 notes |
| radarr | 5 → 6 | no breaking config/DB changes documented; docker users must update the image (no in-container update) | 6.3.0 notes |
| romm | 4 → 5 | no breaking config/DB/folder changes documented (UI overhaul release) | 5.0.0 notes |
| outline | 0.82 → 1.9 | **found empirically:** 1.x defaults to SSL for Postgres — needs `PGSSLMODE=disable` against a sidecar DB | live deploy |
| tandoor | 1 → 2 | **found empirically:** app port moved 8080 → 80 and `ALLOWED_HOSTS` became mandatory (Django 400s everything without it) | live deploy |
| zipline | (4.0 → 4.6) | **found empirically:** v4 renamed `CORE_DATABASE_URL``DATABASE_URL` and `/api/health``/api/healthcheck` | live deploy |
| homebox | (v0.16 → 0.26) | **found empirically:** upstream dropped the `v` tag prefix; `HBOX_AUTH_API_KEY_PEPPER` now required | live deploy |
| wger | (2.3 → 2.6) | **found empirically:** full `DJANGO_DB_*` set required even for sqlite; app listens on :8000 | live deploy |
| calcom · claper · gramps-web · onlyoffice | v4→v6 · 1→2 · v24→v25 · 8→9 | deployed clean; upstream one-liner **not retrieved** within the wall-clock — flagged MAJOR without a fabricated note | — |
The empirically-found rows are the more valuable half: they are what actually happens on a real
deploy, not what a changelog remembered to mention.
---
## 7. Findings, ranked
**F4 (HIGH) — a lying healthcheck takes the app OFF-LINE, it does not merely mislead.**
Traefik refuses to route to a container in `unhealthy` state, so an ENOENT'ing probe →
permanent `unhealthy`**404 for the customer while the app serves 200 on its own port**.
Seven apps were affected. Detail in §5 and F5 below.
**F5 (HIGH) — 7 of 53 apps shipped a broken or wrong healthcheck.** All fixed and live-re-validated:
| app | the lie | reality | fix |
|---|---|---|---|
| adventurelog (frontend) | `wget --spider` | distroless: no shell/wget/curl; node only at an absolute path | Node-exec via `/nodejs/bin/node` |
| emby | `curl -f` | no curl, no standalone wget — BusyBox only | `/bin/busybox wget` |
| papra | `wget --spider` | image ships only `node` | Node-exec |
| wishlist | `wget --spider` | image ships only `node` | Node-exec |
| homebox | `wget --spider` (**HEAD**) | endpoint answers **405 to HEAD, 200 to GET** | `wget -q -O /dev/null` (GET) |
| zipline | `GET /api/health` | v4 renamed it — `/api/health` 404, `/api/healthcheck` 200 | corrected path |
| tandoor | `start_period: 30s` | gunicorn still booting; probes exhausted at ~2 min | `start_period: 240s` |
**F6 (HIGH) — 5 apps were ALREADY undeployable before this campaign.** None was caused by the
sweep; the sweep is simply the first thing that ever tried to deploy them:
`glance` (no seeded config), `papra` (no `AUTH_SECRET`), `zipline` (v4 `DATABASE_URL` rename),
`wishlist` (dead Docker Hub image), `plant-it` + `wanderer` (images do not resolve at all).
**Four are now fixed; plant-it and wanderer need upstream research.**
→ The catalog had no standing "does every template still deploy?" check. That is the real gap.
**F7 (HIGH, systemic) — the update path cannot express a multi-hop major upgrade.**
Nextcloud states plainly: *"You cannot skip major releases."* This campaign moved its template
31 → 34. A fresh deploy is fine (validated, 302), but an EXISTING customer's update button would
attempt 31 → 34 in one step, which Nextcloud forbids. Same shape for any app with sequential-major
rules. The template pin is a single value with no notion of an upgrade path.
→ ROADMAP item; nextcloud is the sharpest case but not the only one.
**F8 (MEDIUM, security-relevant) — gokapi is parked on a superseded version line.**
The revert to v1.9.6 restores the pre-campaign state rather than introducing a new regression, but
gokapi cannot reach v2 until the seeded `config.json` is regenerated in the v2 format. It should not
sit on v1 indefinitely — this wants a dedicated task, not a backlog line.
**F1 (MEDIUM) — the floor-lift controller update emitted no `controller_updated` event.** §0.
**F9 (MEDIUM) — an "obvious" env fix nearly orphaned customer data.** While chasing wger 2.6 the
interim fix pinned `DJANGO_DB_DATABASE=/home/wger/db/database.sqlite`. On 2.3 that path came from
wger's own default; hard-coding a different explicit path would have pointed an existing customer's
wger at an **empty** database while looking perfectly healthy. Reverted deliberately along with the
pin. Worth remembering as a class: *adding an explicit path for a value that previously defaulted is
a data-location change, not a config tidy-up.*
**F10 (MEDIUM) — 24 templates probe with `wget --spider`, which issues HEAD.** homebox proved the
trap (405 to HEAD, 200 to GET). The other 23 validated green, so their endpoints do answer HEAD —
but the default choice is fragile and silently costs availability when an app tightens its methods.
→ convention note for REUSE.md: prefer a real GET (`wget -q -O /dev/null`) unless HEAD is verified.
**F11 (LOW) — vikunja ships with NO healthcheck at all.** It serves 200 and Traefik routes it
(no health state to filter on), so it works — but it has no liveness signal, and the controller-side
probe is the only thing watching it. Not fixed: adding one was not "trivial-and-certain" within the
allowed set.
**F2 (LOW) — the remove receipt never lists destroyed volumes** (`volumes_removed: null`). §2.
**F3 (LOW) — a removed app keeps its offsite toggle forever.** §2. Needs a ruling.
**F12 (LOW) — `.felhom.yml` `mem_limit` can drift from the compose sum.** tandoor claimed 512M
while its services summed to 768M. Only found because tandoor OOM'd. A mechanical gate would catch
the whole catalog at once (the repo already has `check-image-pins.py` as the pattern to copy).
### 7.1 Sweep-engine bugs (recorded because they are the "hollow validation" failure mode)
Three bugs in the campaign's own harness would have written a **false matrix** — two made broken
things look fine, one made fine things look broken. All fixed mid-run and the affected apps
re-validated (§1.1): the Traefik registration race (false 404s), `docker exec` writing its OCI error
to **stdout** (which turned the entire healthcheck audit green), and sampling `created` as a
terminal state.
---
## 8. Coverage
**53 of 53 catalog apps attempted — full coverage. No resumable remainder.**
- **45 passed** end-to-end through the real pipeline (deploy → healthy → HTTP through Traefik → log
scan → removed via the real delete flow).
- **3 left RUNNING** as the end state (the customer trio), on current versions, offsite toggles
restored, both scripted logins green.
- **4 failed**, each with a diagnosed root cause and a disposition (§6.2); 2 of those are pinned
back to their last known-good version rather than shipping a broken pin.
- **1 not attempted** (plex — needs a real `PLEX_CLAIM` token; not a defect).
- **13 template fixes** committed, every one live-re-validated on the demo box.
- **Alert e-mails observed:** none fired during the sweep window. The pre-existing
`kimaradtak: bookstack` offsite warning (from the 17:14 run, before the campaign) was still on
record at the start and is expected per the rail — not chased.
### 8.1 Not done / explicitly out of scope
- **DB/cache sidecar majors** (postgres 16→18, redis 7→8, mariadb→12, postgis 16→17) —
deliberately not bumped, rationale in §4. **ROADMAP decision item.**
- **MAJOR breaking notes** are in §6.3. Retrieved from upstream for bookstack, immich, nextcloud,
n8n, grafana, vikunja, privatebin, radarr and romm; found EMPIRICALLY (better) for outline,
tandoor, zipline, homebox and wger. Four (calcom, claper, gramps-web, onlyoffice) deployed clean
but have **no upstream one-liner** — recorded as not-retrieved rather than fabricated.
- **immich redis → valkey** (upstream migrated in v3; redis:7-alpine works fine on v3) — recorded,
not done: an image swap is structural, not a pin bump.
- **glance config seeding**, **gokapi v2 config migration**, **plant-it / wanderer upstream
research** — all outside the allowed-fix set, each needs its own task.