v0.235.0: freeze the version, keep the fixes flowing (operator ruling 2026-09-06)
gates / gates (push) Successful in 12s

Slice 3. R-447 was BLOCKED because R-438 established that RestartStack's use of
up -d to pick up template changes was CHOSEN and written down in its own comment.
The operator ruled Option 1, and this implements it.

The rule: while the catalog offers the same version you run, its fixes flow to
you; the moment it moves to a newer version you are frozen until you update.

NOTHING was added to any of the thirteen compose up -d call sites. Most of them
are repairs - the boot reconciler, the drive-return gate, the app-stop guard -
and a repair path that refuses to repair leaves a customer's app down, which is
worse than the problem. They are made safe by removing the reason.

app.yaml gains pinned_images: what the app is SUPPOSED to run. It is NOT
installed_images, which is an observation; letting a reading become a deployment
is the R-166 category error one field over. Four writers, each also storing the
exact definition as applied-compose.yml. UpdateStack advances the pin and
re-renders BEFORE the pull, because pull and up -d act on the file on disk, and a
pin set afterwards would pull the frozen version and report success.

The syncer renders instead of copying, through one nil-safe seam. Catalog images
equal the pin -> verbatim, so fixes and self-healing both survive; they differ ->
the WHOLE stored definition, never a substitution of refs into a newer template
(wger 2.6 needs a DB config the older template cannot supply). This is
deliberately not 'skip deployed apps', which was option B and was rejected.

AdoptPins runs once at boot after the backfill, files only, and skips loudly
rather than inventing a pin. syncer.Start() moved to after it: the initial sync
would otherwise run while every app was unpinned and overwrite a deployed app's
version once per boot.

THE BADGE HAD TO CHANGE OR SLICE 2 WOULD HAVE INVERTED SILENTLY. TemplateImages
reads the LIVE compose file, which is now the frozen one, so the comparison would
have answered Naprakesz on exactly the apps that are behind - with every test
green, because the new field has the same type. It now reads CatalogImages.

+16 tests (1729 -> 1745), 28 packages green. Three red-proofs run and reverted.
A test also caught the syncer writing an empty compose file over a live app.
This commit is contained in:
2026-09-06 09:45:34 +02:00
parent 998aa31958
commit 8a0e0a59ad
13 changed files with 1437 additions and 17 deletions
+101
View File
@@ -1,3 +1,104 @@
## v0.235.0 — freeze the version, keep the fixes flowing (2026-09-06, update arc slice 3)
**OPERATOR RULING, 2026-09-06 — Option 1.** R-447 was `BLOCKED` because R-438 established that
`RestartStack`'s use of `up -d` to pick up template changes was **chosen** and written down in its own
comment; reversing a chosen behaviour is a decision, not a bug fix. The decision is made and this
release implements it.
**The rule, in one sentence: while the catalog is offering the same version you are running, its fixes
flow to you; the moment it moves to a newer version, you are frozen at what you have until you choose
to update.**
**Nothing was added to any of the thirteen `compose up -d` call sites.** They are made safe by
removing the reason, not by gating them — the most important of them are REPAIRS (the boot reconciler,
the drive-return gate, the app-stop guard), and a repair path that refuses to repair leaves a
customer's app down, which is worse than the problem.
### The pin (`app.yaml.pinned_images`)
`AppConfig.PinnedImages`, service → image ref. **It is not `InstalledImages`.** That field is an
OBSERVATION ("what is running"); this one is a DECISION ("what should run"), written only by an act
entitled to move a version. Letting an observation feed a decision would make a bad reading become a
bad deployment — the category error `desired_state` exists to avoid (R-166). **Absent means UNPINNED,
and unpinned means the app behaves exactly as it did before this release.**
**Four writers** (`internal/stacks/pin.go`): the deploy path; `UpdateStack`; the restore
(`stackAdapter.RecreateStackDefinitionFromUnit`); and the one-time adoption pass. Each also stores the
exact definition the pin came from as **`applied-compose.yml`** in the stack dir — a name
`Syncer.copyTemplates` does not copy, written atomically.
**`UpdateStack` advances the pin and re-renders BEFORE the pull, and the ordering is load-bearing:**
`pull` and `up -d` act on the file on disk, so the catalog's definition has to BE that file first.
A pin set afterwards would pull the frozen version and report success. **A failed pin write REFUSES
the update** — the opposite of `recordInstalledImages`, because this field is intent.
### The render (`internal/sync/sync.go`)
One new nil-safe seam, `SetRenderPlanFn`, in the same shape as `rescanFn`/`postSyncHook`. The syncer
never reads `app.yaml`. `copyTemplates` now applies a table rather than copying:
| app state | result |
|---|---|
| not deployed / protected / no seam | catalog verbatim — today's behaviour |
| deployed, **unpinned** | catalog verbatim + one DEBUG |
| deployed, pinned, catalog images **equal** | catalog verbatim — **fixes flow, self-healing works** |
| deployed, pinned, catalog images **differ** | the **stored definition** — frozen WHOLE |
| pinned, differ, nothing stored | catalog verbatim + one WARN |
| mid-deploy | the compose file is left alone this cycle |
**`.felhom.yml` is always copied verbatim** — it holds no image, and it carries `catalog_since`, which
the badge needs. That asymmetry is a known limitation, filed as **R-458**.
**The frozen branch writes a WHOLE file, never a substitution of refs into a newer template:**
`wger 2.6` needs a full DB configuration the older template cannot supply, so a new template around an
old image is a third state nobody chose.
**And this is NOT "skip deployed apps"** — that was option B, rejected, because it also stops
health-check fixes, memory limits and new deploy fields, and destroys the self-healing measured live
in `SPIKE-app-update-2026-09-01` §3.
### Adoption, and the startup ordering
`Manager.AdoptPins` runs once at boot, immediately after `BackfillInstalledImages`, and pins every
deployed app to what it is already running. It reads and writes **files only** — no container is
started, stopped or touched. It skips, loudly, when the observation is incomplete (reusing
`observationCoversTemplate`, not a second rule) or when the app is running something the current
template no longer offers and no stored definition exists. Those apps keep pre-v0.235.0 behaviour.
**`syncer.Start()` moved to after adoption.** It fires an immediate sync; at its old position that
first sync ran while every app was still unpinned and would have copied the catalog over a deployed
app once per boot — precisely the behaviour this release removes.
### The badge had to change or slice 2 would have inverted silently
`Stack.TemplateImages` is read from the app's LIVE compose file, which is now the **rendered** one. On
a frozen app that file names the OLD version, so `compareInstalledToTemplate` would have found
installed == template and answered **„Naprakész" on exactly the apps that are behind** — with every
test still green, because the two fields have the same type. The comparison now reads a new
`Stack.CatalogImages`, taken from the syncer's git clone. **No readable catalog entry renders
nothing.** No Hungarian string, badge state or partial changed.
### Tests
+16 (1729 → 1745). 28 packages green, 0 FAIL. New: `internal/sync/render_test.go`,
`internal/stacks/pin_test.go`, Group G in `internal/web/updatebadge_test.go`.
**Three companion red-proofs, each run, observed failing, and reverted (2026-09-06):**
| # | mutation | observed failure |
|---|---|---|
| 1 | the frozen branch returns the catalog template | `TestGroupB` — *"a pinned app must NOT receive the catalog's new version"* |
| 2 | adoption's completeness guard removed | `TestGroupE/incomplete_observation` — *"pinned 1, want 0 — only 1 of 2 services was observed"* |
| 3 | the badge reads `TemplateImages` again | `TestGroupG` — *"THE FEATURE IS INVERTED"*, plus „Naprakész" with no catalog entry |
**Wiring:** `TestGroupH` walks the AST of `cmd/controller/main.go` for `SetRenderPlanFn` and
`AdoptPins` **and asserts their order** against the backfill and `syncer.Start()` — a
`strings.Contains` would match a commented-out call, and the render is inert without the seam.
**One hole was found by a test rather than by review:** the syncer trusted the applied path handed to
it and would have written an empty compose file over a live app. It now re-reads and falls back to the
catalog. `TestRenderTable_EmptyStoredDefinitionIsTreatedAsAbsent`.
## v0.234.0 — the label now appears on an app nobody has touched (2026-09-03, update arc slice 1b)
**Found by the operator on demo-felhom the morning after v0.233.0, and it is a real gap, not a