v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user