v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s

Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no
auto-update, the three lifecycle buttons byte-identical.

Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each
entry carrying ref + repo digest + first-seen timestamp. Written by
Manager.recordInstalledImages after a successful compose up from StartStack,
RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never
from docker-compose.yml: the syncer overwrites a deployed app's compose on a
15-minute cycle and the two disagreed for 25 minutes in the spike's own
measurement. A failed write NEVER refuses the action - the deliberate opposite
of SetDesiredState, because this is an observation and that is an intent. Not
called from StartStackServices (the R-47 DB-only window). Its own docker seam
with a context and a 30s timeout, which neither existing exec helper has.

Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the
recorded ref per service against what the current template pins and returns a
*MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS.
NO RECORD RENDERS NOTHING: absent means unknown and never means current. No
version number reaches the customer and no registry is queried.

Known limitation, filed not hidden: 23 catalog pins float, so those apps can read
Naprakesz when the image behind the tag has moved.

+17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real
RestartStack plus an AST walk of the four call sites. Three companion red-proofs
run and reverted.
This commit is contained in:
2026-09-02 20:18:01 +02:00
parent 960d29b061
commit 8025304acc
14 changed files with 1637 additions and 9 deletions
+81
View File
@@ -1,3 +1,84 @@
## v0.233.0 — the box writes down what it installed, and one label says whether it is current (2026-09-02, update arc slices 1 & 2)
**Neither slice changes any behaviour.** The Frissítés button, the restart path, the sync and the boot
reconciler are byte-identical. This adds a RECORD and a LABEL, because the behaviour work (slice 3)
is easier to judge once the fleet's real state is visible. Opened by
`felhom.eu/documentation/audits/SPIKE-app-update-2026-09-01.md`; the reasoning now lives in
`felhom.eu/documentation/architecture/09-update-architecture.md` (**R-438**, an absence that was itself
a finding).
### Slice 1 — `app.yaml` gains `installed_images`
`AppConfig.InstalledImages map[string]InstalledImage`, keyed by **compose SERVICE name**, each entry
carrying `ref`, `digest` and `at`. Written by `Manager.recordInstalledImages`
(new `controller/internal/stacks/installed.go`) after a successful compose up from `StartStack`,
`RestartStack`, `UpdateStack` and `runComposeDeploy`.
- **Read from the CONTAINER, never from `docker-compose.yml`.** That file is the value that has
already moved — the syncer overwrites a deployed app's compose on a 15-minute cycle with no
deployed check, and the two disagreed for 25 minutes in the spike's own measurement.
`checkLocalImages` (a line scan of that file) is deliberately not reused.
- **A failed write NEVER refuses the action**, and that is the deliberate opposite of
`SetDesiredState`. Intent refused, observation logged: refusing to start a customer's app because a
note could not be written trades a real outage for a bookkeeping gap. One ERROR line, app stays up.
- **Not called from `StartStackServices`** — the R-47 DB-only window would overwrite a complete
record with a partial one.
- **An unchanged observation does not rewrite `app.yaml`** (the `SetDesiredState` rule — that file
holds encrypted secrets), and `at` is carried forward so it answers "running since", not
"last looked at".
- Its own docker seam, `Manager.installedExecFn`, with a **context and a 30 s timeout** —
`composeExecCustomEnv`/`execCommand` have neither, and REUSE.md's trap table says so.
- `ParseComposeImages` is a real yaml.v3 `services:` parse, never a line scan (immich's top-level
`immich_ml_cache:` has exactly the shape a scan misreads as a service).
### Slice 2 — one Hungarian badge, and no version number anywhere
`.felhom.yml` gains optional `catalog_since: "YYYY-MM-DD"` (`Metadata.CatalogSince`, backfilled across
all 53 catalog apps in `app-catalog-felhom.eu@69761cf`). `web.updateBadge`
(new `controller/internal/web/updatebadge.go`) compares the RECORDED reference per service against
what the CURRENT template pins, and returns a `*MetaBadge` rendered by the existing `meta_badge`
partial on `app_info.html` and `stacks.html` — **no new markup and no new CSS**, which is what
`metabadge.go`'s own comment asked of its second user.
| state | badge |
|---|---|
| every service matches | „Naprakész" (`tag-ok`) |
| any service differs, age known | „Frissítés elérhető — N napja" (`tag-warn`) |
| any service differs, age unknown | „Frissítés elérhető" |
| **no record, or template unreadable** | **nothing rendered** |
- **NO RECORD MEANS UNKNOWN AND NEVER MEANS CURRENT** — the R-166 lesson applied to an observation.
Every `app.yaml` written before this version has no entry, so a fall-through to „Naprakész" would
have told the whole fleet their months-old apps were current. Red-proved.
- **No version number reaches the customer** (operator ruling: a household cannot act on `26.05.2`).
- **No registry is queried** — a box must not need eight upstream registries to render a page.
- `catalog_since` is tolerant in the `lifecycle` style: absent, empty, malformed **or future-dated**
all degrade to a badge with no age plus one WARN. The future case is not pedantry — a box whose
clock lags the catalog would otherwise print „-3 napja".
### Known limitation, stated rather than hidden
For the **23 floating pins** (`postgres:16-alpine`, `mariadb:11.6`, …) the reference can be identical
while the image behind it has moved upstream — measured live in the spike §5, where `mariadb:11.4` and
`mariadb:12.3` had both already moved. **Those apps read „Naprakész" when they may not be.** Closing it
needs a registry query and a digest comparison; filed as a register row, not left implicit.
### Tests
+17 test functions (1707 → 1724). New: `controller/internal/stacks/installed_test.go`,
`controller/internal/web/updatebadge_test.go`. Full suite green, 28 packages, 0 FAIL.
- **Wiring, through the REAL caller:** `TestGroupE_RestartStackReachesTheRecorder` drives
`RestartStack` end to end (only the compose and `docker ps` process boundaries are stubbed), and
`TestGroupE_EveryBringUpPathCallsTheRecorder` walks the **AST** of `manager.go`/`deploy.go` for the
four call sites — a `strings.Contains` would match a commented-out call, which is the exact shape of
the seam-built-but-never-wired class.
- **Companion red-proofs, all three run and reverted (2026-09-02):** (1) making a recording failure
refuse the action → `TestGroupC` fails with "restart must SUCCEED"; (2) the no-record case falling
through to `updateCurrent` → three sub-tests fail with a „Naprakész" badge on an app nobody measured;
(3) deleting the `{{template "meta_badge" (updateBadge …)}}` line from each template → the matching
render sub-test fails.
## the decoy sweep — can this gate be fooled by a label? (2026-09-01, R-421) — NOT A RELEASE
**No product code, no version bump, no image, no golden.** A scripts change is not a release.