v0.235.0: freeze the version, keep the fixes flowing (operator ruling 2026-09-06)
gates / gates (push) Successful in 12s
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:
+101
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user