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
+52 -6
View File
@@ -150,13 +150,26 @@ type Stack struct {
// forgetting costs at most one threshold window, whereas persisting could carry a stale
// "this app is crash-looping" verdict across the restart that fixed it.
RestartingSince time.Time `json:"restarting_since,omitempty"`
// TemplateImages is what the stack's CURRENT docker-compose.yml pins, per compose service —
// i.e. what the catalog says this app should be running right now. Refreshed by ScanStacks for
// deployed, non-protected apps only; nil for everything else and nil when the file cannot be
// parsed. Nil means CANNOT-TELL and never means "matches": web.updateBadge renders nothing.
// Not persisted — it is a read of a file the syncer owns, and re-reading is cheaper than a
// second copy that can go stale.
// TemplateImages is what the stack's LIVE docker-compose.yml pins, per compose service.
//
// ⚠ SINCE v0.235.0 THIS IS NOT "WHAT THE CATALOG OFFERS". The live file is RENDERED: for a
// pinned app whose version the catalog has moved past, it is the app's own frozen definition.
// So TemplateImages answers "what will the next `compose up -d` bring this app to" — which is
// exactly what a debugger wants and exactly the WRONG input for the update badge, because a
// frozen app's live file names the OLD version and the comparison would read „Naprakész".
// THE BADGE USES CatalogImages. See web.compareInstalledToTemplate.
//
// Refreshed by ScanStacks for deployed, non-protected apps only; nil otherwise and nil when the
// file cannot be parsed. Not persisted.
TemplateImages map[string]string `json:"template_images,omitempty"`
// CatalogImages is what the CATALOG currently offers for this app, read from the syncer's git
// clone (`<DataDir>/catalog-cache/templates/<app>/docker-compose.yml`) rather than from the
// stack dir. Added in v0.235.0 because the render made the live file unusable for the question
// "is this app behind?".
//
// Nil means CANNOT-TELL — the cache is missing, unreadable, or the app is not in the catalog —
// and the badge then renders NOTHING. Absent is unknown; it is never „Naprakész".
CatalogImages map[string]string `json:"catalog_images,omitempty"`
}
// Manager handles all docker compose stack operations.
@@ -527,6 +540,19 @@ func (m *Manager) ScanStacks() error {
}
}
// What the CATALOG offers — the badge's input, and deliberately a different file from the
// one above (v0.235.0). A missing catalog entry is silent at INFO: an orphaned app has no
// catalog template by definition, and warning once per app per scan would be noise.
var catImages map[string]string
if deployed && !m.cfg.IsProtectedStack(name) {
catPath := m.CatalogTemplatePath(name, "docker-compose.yml")
if imgs, cerr := ParseComposeImages(catPath); cerr == nil {
catImages = imgs
} else if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] ScanStacks: no readable catalog template for %s (%v) — the update badge will render nothing", name, cerr)
}
}
if existing, ok := m.stacks[name]; ok {
existing.ComposePath = composePath
existing.Meta = meta
@@ -537,6 +563,7 @@ func (m *Manager) ScanStacks() error {
existing.Deployed = deployed
existing.AppConfig = appCfg
existing.TemplateImages = tplImages
existing.CatalogImages = catImages
}
} else {
m.stacks[name] = &Stack{
@@ -548,6 +575,7 @@ func (m *Manager) ScanStacks() error {
Protected: m.cfg.IsProtectedStack(name),
AppConfig: appCfg,
TemplateImages: tplImages,
CatalogImages: catImages,
}
}
}
@@ -1205,6 +1233,24 @@ func (m *Manager) UpdateStack(name string) error {
m.logger.Printf("[INFO] [stacks] Updating stack: %s", name)
start := time.Now()
dir := filepath.Dir(stack.ComposePath)
// v0.235.0 — ADVANCE THE PIN FIRST, AND RE-RENDER BEFORE THE PULL.
//
// This is the ONE act entitled to move a version; the freeze exists so that nothing else can.
// The ordering is load-bearing, not stylistic: `compose pull` and `up -d` act on the file on
// disk, so the catalog's current definition has to BE that file before either runs. Setting the
// pin afterwards would pull the frozen version and change nothing, while reporting success — and
// a button that lies is worse than a button that refuses.
//
// A FAILED PIN WRITE REFUSES THE UPDATE, deliberately the opposite of recordInstalledImages.
// That field is an observation and a failed write is a bookkeeping gap; this one is INTENT, and
// an update whose intent could not be recorded leaves the box running a version it has no record
// of choosing — the exact ambiguity R-166 closed for desired_state, one field over.
if err := m.advancePinToCatalog(name, dir); err != nil {
m.logger.Printf("[ERROR] [stacks] Stack %s update refused: %v", name, err)
return fmt.Errorf("updating stack %s: %w", name, err)
}
env := m.stackEnv(dir)
if m.isDebug() {