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
+42 -2
View File
@@ -389,7 +389,12 @@ func main() {
syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks, func(updated []string) {
stackMgr.InjectMissingFields(updated)
})
syncer.Start()
// v0.235.0 — the render seam. Without this line the syncer copies the catalog verbatim, i.e.
// the pre-v0.235.0 product; the freeze is INERT unless it is wired here, which is why a test
// walks this file's AST for the call rather than trusting the package to be correct alone.
syncer.SetRenderPlanFn(stackMgr.RenderPlanFor)
// Start() is deliberately NOT called here — see the comment beside the call further down, after
// the pin adoption pass.
defer syncer.Stop()
// --- Graceful shutdown context ---
@@ -447,6 +452,21 @@ func main() {
// reason: a just-restarted app is observed in its settled state.
stackMgr.BackfillInstalledImages()
// --- v0.235.0: pin adoption (complete AND matching observations only) ---
// Give every deployed app a pin for what it is already running, so the render has something to
// obey. Runs immediately after the backfill so an app that pass has just observed can be pinned
// in the same boot. It reads and writes FILES only — no container is started, stopped or touched.
// An app it cannot pin confidently is left UNPINNED and keeps pre-v0.235.0 behaviour, loudly.
stackMgr.AdoptPins()
// --- Start the catalog syncer, AFTER adoption ---
// ORDERING IS LOAD-BEARING. Start() fires an immediate sync in a goroutine. Started at its
// original place — before adoption — that first sync would run while every app was still
// unpinned, copy the catalog verbatim over a deployed app, and hand the next restart a version
// change: precisely the behaviour this release removes, once per boot. Adoption first means the
// very first sync of a boot already obeys the pins.
syncer.Start()
// --- R-52: boot desired-state reconciliation ---
// A deployed app that missed its boot start used to stay down until a human noticed (F5: immich
// and calibre-web sat Exited for ~18 h while ten siblings came back). One bounded start-once
@@ -2595,7 +2615,27 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
return fmt.Errorf("restoring %s from unit: %w", fname, err)
}
}
return a.mgr.PersistUnitRedeployConfig(name, fullEnv)
if err := a.mgr.PersistUnitRedeployConfig(name, fullEnv); err != nil {
return err
}
// v0.235.0 — PIN TO WHAT THE UNIT CAPTURED. THIS IS WHAT CLOSES R-441.
//
// Measured before this line existed: this function writes the recovery unit's compose — carrying
// the OLD image pin — into the live stack dir, and `Syncer.copyIfChanged` then overwrote it from
// the catalog on the next 15-minute tick. So a restore's image-level recovery had a <=15-minute
// half-life, and the next `compose up -d` from any of the thirteen call sites re-applied the
// catalog's version. Pinning here makes the render obey the restored definition instead.
//
// Pinned from the file just written, so the pin and the stored definition cannot disagree.
// A failure is loud and does NOT fail the restore: an unpinned app is exactly today's behaviour,
// and refusing a completed restore over a bookkeeping write would be the worse trade.
if pin, data, err := stacks.PinFromCompose(stacks.ComposePathIn(stackDir)); err != nil {
log.Printf("[WARN] [stacks] pin %s: cannot pin from the restored definition: %v", name, err)
} else if err := a.mgr.SetPin(name, stackDir, pin, data); err != nil {
log.Printf("[ERROR] [stacks] pin %s: %v", name, err)
}
return nil
}
// StartStackServices brings up only the named compose services (the DB-only replay window, R-47).