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:
@@ -29,7 +29,14 @@ const (
|
||||
//
|
||||
// NO REGISTRY QUERY, deliberately: a customer's box must not depend on reaching eight upstream
|
||||
// registries to render a page. The comparison is therefore reference-to-reference — what the
|
||||
// container was created from, against what the compose file now pins.
|
||||
// container was created from, against what the CATALOG currently offers.
|
||||
//
|
||||
// ⚠ IT COMPARES AGAINST Stack.CatalogImages, NEVER Stack.TemplateImages, AND v0.235.0 IS WHY.
|
||||
// Since the freeze, a pinned app's LIVE docker-compose.yml is rendered from its own stored
|
||||
// definition once the catalog moves past it — so the live file names the OLD version, installed
|
||||
// would equal template, and this function would answer „Naprakész" on precisely the apps that are
|
||||
// behind. It would invert the feature silently, with every test still green, because the two fields
|
||||
// have the same type and shape. CatalogImages is read from the syncer's git clone instead.
|
||||
//
|
||||
// KNOWN LIMITATION, stated rather than hidden (see 09-update-architecture.md and the register row):
|
||||
// 23 of the catalog's 66 distinct pins FLOAT (postgres:16-alpine, mariadb:11.6, …). For those the
|
||||
@@ -46,15 +53,15 @@ func compareInstalledToTemplate(s stacks.Stack) updateState {
|
||||
if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 {
|
||||
return updateUnknown // legacy app.yaml — no record was ever written
|
||||
}
|
||||
if len(s.TemplateImages) == 0 {
|
||||
return updateUnknown // the compose file could not be read or pins nothing
|
||||
if len(s.CatalogImages) == 0 {
|
||||
return updateUnknown // no readable catalog template — cannot tell, so say nothing
|
||||
}
|
||||
if len(s.AppConfig.InstalledImages) != len(s.TemplateImages) {
|
||||
if len(s.AppConfig.InstalledImages) != len(s.CatalogImages) {
|
||||
// A service was added or removed by the template. That IS a change the customer's running
|
||||
// stack has not taken up.
|
||||
return updateBehind
|
||||
}
|
||||
for svc, want := range s.TemplateImages {
|
||||
for svc, want := range s.CatalogImages {
|
||||
got, ok := s.AppConfig.InstalledImages[svc]
|
||||
if !ok || got.Ref != want {
|
||||
return updateBehind
|
||||
|
||||
@@ -15,18 +15,35 @@ import (
|
||||
|
||||
var badgeNow = time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// ubStack builds a deployed app whose record and template pins are stated explicitly.
|
||||
func ubStack(installed map[string]stacks.InstalledImage, template map[string]string, since string) stacks.Stack {
|
||||
// ubStack builds a deployed app whose record and CATALOG images are stated explicitly.
|
||||
//
|
||||
// Since v0.235.0 the badge compares against `CatalogImages` — what the catalog OFFERS — and never
|
||||
// against `TemplateImages`, which after the freeze is the app's own (possibly frozen) live file.
|
||||
// Both are set to the same map here because that is the un-frozen case; `ubFrozenStack` is the one
|
||||
// where they deliberately differ.
|
||||
func ubStack(installed map[string]stacks.InstalledImage, catalog map[string]string, since string) stacks.Stack {
|
||||
return stacks.Stack{
|
||||
Name: "bookstack",
|
||||
Deployed: true,
|
||||
State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{DisplayName: "BookStack", Slug: "bookstack", CatalogSince: since},
|
||||
AppConfig: &stacks.AppConfig{Deployed: true, InstalledImages: installed},
|
||||
TemplateImages: template,
|
||||
TemplateImages: catalog,
|
||||
CatalogImages: catalog,
|
||||
}
|
||||
}
|
||||
|
||||
// ubFrozenStack models a v0.235.0 FROZEN app: it runs an old version, its LIVE compose file has been
|
||||
// rendered from its own stored definition and therefore also names the old version, and the CATALOG
|
||||
// has moved on. This is the shape that silently inverts the badge if the comparison reads the wrong
|
||||
// field — see TestGroupG.
|
||||
func ubFrozenStack(running, catalogRef, since string) stacks.Stack {
|
||||
st := ubStack(map[string]stacks.InstalledImage{"web": rec(running)}, map[string]string{"web": catalogRef}, since)
|
||||
st.AppConfig.PinnedImages = map[string]string{"web": running}
|
||||
st.TemplateImages = map[string]string{"web": running} // the frozen live file
|
||||
return st
|
||||
}
|
||||
|
||||
func rec(ref string) stacks.InstalledImage {
|
||||
return stacks.InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"}
|
||||
}
|
||||
@@ -277,3 +294,50 @@ func TestGroupF_CatalogSinceTolerance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- GROUP G (v0.235.0): the badge must read the CATALOG, not the rendered file ---
|
||||
|
||||
// TestGroupG_FrozenAppStillReadsBehind is the test that stops slice 3 from silently inverting slice 2.
|
||||
//
|
||||
// After the freeze, a pinned app whose version the catalog has moved past has its LIVE
|
||||
// docker-compose.yml rendered from its own stored definition — so that file names the OLD version.
|
||||
// A comparison against it finds installed == template and answers „Naprakész" on precisely the apps
|
||||
// that are behind. The two fields have the same type and shape, so nothing but this test catches it.
|
||||
//
|
||||
// COMPANION RED-PROOF 3 (run 2026-09-06): point compareInstalledToTemplate back at
|
||||
// s.TemplateImages. This test then fails with „Naprakész" on a frozen app. Reverted.
|
||||
func TestGroupG_FrozenAppStillReadsBehind(t *testing.T) {
|
||||
since := time.Now().UTC().AddDate(0, 0, -46).Format("2006-01-02")
|
||||
frozen := ubFrozenStack("nextcloud:31.0.14-apache", "nextcloud:34.0.1-apache", since)
|
||||
|
||||
b := updateBadgeAt(frozen, time.Now().UTC())
|
||||
if b == nil {
|
||||
t.Fatal("a frozen, behind app must carry a badge")
|
||||
}
|
||||
if b.Label == "Naprakész" {
|
||||
t.Fatal("THE FEATURE IS INVERTED: the comparison read the frozen live file instead of the catalog")
|
||||
}
|
||||
if !strings.HasPrefix(b.Label, "Frissítés elérhető") || b.Class != "tag-warn" {
|
||||
t.Fatalf("label = %q class = %q", b.Label, b.Class)
|
||||
}
|
||||
|
||||
// And it renders that way on the real page, not just in the pure function.
|
||||
html := renderBackupPage(t, "stacks", ubStacksData(frozen))
|
||||
if !strings.Contains(html, "Frissítés elérhető") {
|
||||
t.Error("the frozen app must be badged as behind on the app list")
|
||||
}
|
||||
if strings.Contains(html, "Naprakész") {
|
||||
t.Error("a frozen, behind app must never render Naprakész")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupG_NoCatalogEntryRendersNothing — an orphaned app, or a box whose catalog cache is missing,
|
||||
// cannot be judged. Absent is unknown; it is never „Naprakész".
|
||||
func TestGroupG_NoCatalogEntryRendersNothing(t *testing.T) {
|
||||
st := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")},
|
||||
map[string]string{"web": "nginx:1.27"}, "2026-07-18")
|
||||
st.CatalogImages = nil // the catalog cache could not be read
|
||||
if b := updateBadgeAt(st, badgeNow); b != nil {
|
||||
t.Fatalf("no readable catalog template must render NOTHING, got %q", b.Label)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user