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
+58
View File
@@ -467,6 +467,64 @@ through them: `EffectiveLifecycle()`, `CanInstall()`, `IsAbandoned()`.
`lifecycleBadge` funcmap entry. R-56's difficulty labels are intended as a sibling funcmap
function returning the same `*MetaBadge` — no new markup or CSS.
#### What is installed, and is it current? (v0.233.0 — update arc slices 1 & 2)
Two additions, and **neither changes how an update behaves**. Slice 1 is a record; slice 2 is a label.
**Slice 1 — `app.yaml` gains `installed_images`.** After every successful `compose up` from
`StartStack`, `RestartStack`, `UpdateStack` and the deploy path, `Manager.recordInstalledImages`
(`internal/stacks/installed.go`) reads what each container is ACTUALLY running and writes it down,
**keyed by compose SERVICE name**:
```yaml
installed_images:
web:
ref: lscr.io/linuxserver/bookstack:26.05.2
digest: sha256:aaaa… # the only identifier that cannot move; "" if never pulled
at: "2026-09-02T18:41:03Z" # when this ref+digest was FIRST seen for this service
```
- **Read from the CONTAINER, never from `docker-compose.yml`.** That file is the value that has
already moved: the catalog syncer overwrites a deployed app's compose file on a 15-minute cycle
with no deployed check, and file and container can disagree indefinitely (measured live,
`SPIKE-app-update-2026-09-01` §3). `Manager.checkLocalImages` is a line scan of that file and is
deliberately NOT reused.
- **A failed write NEVER refuses the action** — the deliberate opposite of `SetDesiredState`.
`desired_state` is the customer's INTENT, so an act whose intent could not be recorded is refused;
`installed_images` is an OBSERVATION, and refusing to start an app because a note could not be
written would trade a real outage for a bookkeeping gap. Logged at ERROR and the app stays up.
- **NOT called from `StartStackServices`** — that path starts only the database service for the R-47
restore window, and a partial record would overwrite a complete one.
- **Absent means UNKNOWN and never means current.** Every `app.yaml` predating v0.233.0 has no entry.
- Its own docker seam (`Manager.installedExecFn`) carries a **context and a 30 s timeout**, which
`composeExecCustomEnv`/`execCommand` do not — a bookkeeping read must not be able to wedge a
lifecycle action.
**Slice 2 — one badge, and no version number.** `.felhom.yml` gains optional
`catalog_since: "YYYY-MM-DD"` (`Metadata.CatalogSince` + `CatalogSinceAge`), the date the catalog last
moved that app's pins. `web.updateBadge` compares the recorded reference for each service against what
the current template pins and returns a `*MetaBadge` rendered by the existing `meta_badge` partial —
**no new markup, no new CSS**, which is exactly what `metabadge.go`'s comment asks of its second user.
| state | badge |
|---|---|
| every service matches the template | „Naprakész" (`tag-ok`) |
| any service differs, `catalog_since` usable | „Frissítés elérhető — N napja" (`tag-warn`) |
| any service differs, `catalog_since` absent/malformed/future | „Frissítés elérhető" |
| **no record, or the template cannot be read** | **nothing is rendered** |
- **No version string is shown to the customer anywhere** — operator ruling, 2026-09-02: a household
cannot act on `26.05.2`, only on "you are behind, and by this long". Versions stay in the logs, the
API and the hub.
- **No registry is queried.** A customer's box must not need eight upstream registries to render a
page. **Known limitation:** for the 23 floating pins (`postgres:16-alpine`, `mariadb:11.6`, …) the
reference can be identical while the image behind it has moved, so those apps can read „Naprakész"
when they may not be. Digest-level comparison needs a registry query and is deferred.
- **Information only.** The badge is wired to no action; `Frissítés`/`Újraindítás`/`Leállítás` are
byte-identical to before (`TestScenarioE_TheUpdateButtonIsUntouched`).
Reasoning and the seven-slice plan: `felhom.eu/documentation/architecture/09-update-architecture.md`.
#### App Info Pages
Each app can define rich metadata in `.felhom.yml`: