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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user