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
+12 -5
View File
@@ -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
+67 -3
View File
@@ -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)
}
}