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
+25
View File
@@ -138,6 +138,21 @@ type AppConfig struct {
// WRITTEN BY: Manager.recordInstalledImages ONLY, from StartStack / RestartStack / UpdateStack
// and the deploy path. READ BY: web.updateBadge (v0.233.0). Nothing takes a DECISION from it.
InstalledImages map[string]InstalledImage `yaml:"installed_images,omitempty" json:"installed_images,omitempty"`
// PinnedImages is what this app is SUPPOSED to run, per compose service — the customer's
// INTENT, and the input the catalog render obeys (v0.235.0, operator ruling 2026-09-06:
// "freeze the version, keep the fixes flowing").
//
// IT IS NOT InstalledImages. That field is an OBSERVATION ("what is running"), written by
// looking at containers. This one is a DECISION ("what should run"), written only by an act
// that is entitled to move a version: a deploy, a deliberate update, a restore, or the
// one-time adoption pass. Letting an observation feed a decision would make a bad reading
// become a bad deployment — the same category error the desired_state field exists to avoid
// (R-166). They will normally agree; when they disagree that is a signal, not a bug to
// paper over.
//
// ABSENT MEANS UNPINNED, and unpinned means the app behaves exactly as it did before
// v0.235.0. It never means "pin to whatever the catalog says now".
PinnedImages map[string]string `yaml:"pinned_images,omitempty" json:"pinned_images,omitempty"`
}
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
@@ -482,6 +497,16 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
deployEnv := m.stackEnv(stackDir)
m.recordInstalledImages(name, stackDir, deployEnv)
// Pin what we just deployed FROM (v0.235.0). The stack dir's compose file IS what the deploy
// used, so it is both the pin's source and the definition stored beside it. A failure here is
// logged and never fails the deploy — the app is up, and an unpinned app simply keeps
// pre-v0.235.0 behaviour.
if pin, data, err := PinFromCompose(ComposePathIn(stackDir)); err != nil {
m.logger.Printf("[WARN] [stacks] pin %s: cannot pin from the deployed compose file: %v", name, err)
} else if err := m.SetPin(name, stackDir, pin, data); err != nil {
m.logger.Printf("[ERROR] [stacks] pin %s: %v", name, err)
}
// Post-deploy container state check (async, non-blocking)
m.logPostStartStatus(name, stackDir, deployEnv)