v0.260.0 — a box ahead of the catalog reads „Naprakész", and the pin never moves backwards (R-524)
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
MEASURED 2026-09-15 (BIGNIGHT Phase 6): privatebin updated 2.0.5 -> 2.0.6, catalog reverted to 2.0.5, and the box read „Frissítés elérhető — ma" over an Update that would have moved the pin BACKWARDS onto a possibly-migrated datadir. - stacks.CatalogOrder: the comparison gains a fourth answer (Ahead) and moves out of web, so the badge and UpdatePreflight cannot drift apart. - The badge: ahead reads „Naprakész"/"Up to date", tag-ok, with a title saying why. - The refusal: UpdatePreflight returns `downgrade` (409), born as a bundle key; the API now renders update refusals through errText so it reaches English households. - Ahead is narrow: every differing service must be orderable AND newer, else Behind. - Ordering is util.Version.Compare behind a tag normaliser — no second comparator. - Three red-proofs, each seen to fail. R-589 was already fixed in v0.258.0; only its register row was stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -374,6 +374,13 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
|
||||
Class: "tag-ok",
|
||||
Title: b.Msg(lang, "badge.update.current.title"),
|
||||
}
|
||||
case updateAhead:
|
||||
// R-524's twin. Same word, same class as updateCurrent — see updatebadge.go.
|
||||
return &MetaBadge{
|
||||
Label: b.Msg(lang, "badge.update.current"),
|
||||
Class: "tag-ok",
|
||||
Title: b.Msg(lang, "badge.update.ahead.title"),
|
||||
}
|
||||
case updateBehind:
|
||||
label := b.Msg(lang, "badge.update.behind")
|
||||
if days, ok := st.Meta.CatalogSinceAge(time.Now().UTC()); ok {
|
||||
|
||||
@@ -617,6 +617,10 @@ func TestUpdateBadgeFollowsTheLanguage(t *testing.T) {
|
||||
}
|
||||
current := behind
|
||||
current.CatalogImages = map[string]string{"a": "old"}
|
||||
// R-524: the box runs something the catalog has moved BACK from.
|
||||
ahead := behind
|
||||
ahead.AppConfig = &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{"a": {Ref: "privatebin/pdo:2.0.6"}}}
|
||||
ahead.CatalogImages = map[string]string{"a": "privatebin/pdo:2.0.5"}
|
||||
unknown := stacks.Stack{Name: "app", Deployed: false}
|
||||
|
||||
huFn := s.templateFuncMap()["updateBadge"].(func(stacks.Stack) *MetaBadge)
|
||||
@@ -654,7 +658,23 @@ func TestUpdateBadgeFollowsTheLanguage(t *testing.T) {
|
||||
if !strings.HasPrefix(enBehind.Label, "Update available") || !strings.Contains(enBehind.Label, "3 days ago") {
|
||||
t.Errorf("en behind label = %q, want the English age suffix", enBehind.Label)
|
||||
}
|
||||
for _, b := range []*MetaBadge{enCur, enBehind} {
|
||||
// 4. R-524's ahead arm: the SAME word and the SAME class as current in both languages, with a
|
||||
// title that differs from current's — so the household is told why, and never warned.
|
||||
huAhead, enAhead := huFn(ahead), enFn(ahead)
|
||||
if huAhead == nil || enAhead == nil {
|
||||
t.Fatal("an app ahead of the catalog must carry a badge")
|
||||
}
|
||||
if huAhead.Label != huFn(current).Label || enAhead.Label != enCur.Label {
|
||||
t.Errorf("ahead must wear the same word as current; hu %q / en %q", huAhead.Label, enAhead.Label)
|
||||
}
|
||||
if huAhead.Class != "tag-ok" || enAhead.Class != "tag-ok" {
|
||||
t.Errorf("ahead must not be a warning; hu %q / en %q", huAhead.Class, enAhead.Class)
|
||||
}
|
||||
if huAhead.Title == huFn(current).Title || enAhead.Title == enCur.Title {
|
||||
t.Error("ahead must say WHY in its title, not reuse current's sentence")
|
||||
}
|
||||
|
||||
for _, b := range []*MetaBadge{enCur, enBehind, enAhead} {
|
||||
if strings.ContainsAny(b.Label+b.Title, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ") {
|
||||
t.Errorf("an English badge still carries Hungarian: %q / %q", b.Label, b.Title)
|
||||
}
|
||||
|
||||
@@ -7,67 +7,55 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// updateState is the three-way answer to "is this app running what the catalog currently pins?".
|
||||
// updateState is the answer to "is this app running what the catalog currently pins?".
|
||||
//
|
||||
// THREE values, and the third is the entire safety property — the same shape, and the same lesson,
|
||||
// as AppConfig.DesiredState (R-166):
|
||||
// THE THIRD VALUE IS THE ENTIRE SAFETY PROPERTY — the same shape, and the same lesson, as
|
||||
// AppConfig.DesiredState (R-166):
|
||||
//
|
||||
// ABSENT MEANS UNKNOWN. IT NEVER MEANS "UP TO DATE".
|
||||
//
|
||||
// Every app.yaml written before v0.233.0 carries no installed_images, so unknown is the common value
|
||||
// on upgrade. An implementation that fell through to "Naprakész" would tell every customer on the
|
||||
// fleet that their months-old app is current — a confident wrong answer, which is worse than none.
|
||||
//
|
||||
// THE FOURTH VALUE, updateAhead, ARRIVED IN v0.260.0 (R-524). See stacks.CatalogOrder.
|
||||
type updateState int
|
||||
|
||||
const (
|
||||
updateUnknown updateState = iota // nothing recorded, or nothing to compare against
|
||||
updateCurrent // every service runs exactly what the template pins
|
||||
updateBehind // at least one service does not
|
||||
updateAhead // the box runs something NEWER than the catalog offers
|
||||
)
|
||||
|
||||
// compareInstalledToTemplate answers the question WITHOUT touching the network.
|
||||
//
|
||||
// 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 CATALOG currently offers.
|
||||
// SINCE v0.260.0 IT IS A THIN WRAPPER OVER stacks.CatalogOrder, and that move is the point of R-524:
|
||||
// the badge and the guarded update's downgrade refusal must reach the same verdict, and two
|
||||
// implementations of one comparison are two verdicts waiting to disagree. Everything the old body
|
||||
// said still holds and now lives in updateorder.go:
|
||||
//
|
||||
// ⚠ 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
|
||||
// reference can be identical while the image behind it has moved upstream — measured live in
|
||||
// SPIKE-app-update-2026-09-01 §5, where mariadb:11.4 and mariadb:12.3 had both already moved. Those
|
||||
// apps will read "Naprakész" when they may not be. Closing that needs a registry query and a digest
|
||||
// comparison, which is deferred.
|
||||
// - NO REGISTRY QUERY, deliberately: a customer's box must not depend on reaching eight upstream
|
||||
// registries to render a page. The comparison is reference-to-reference.
|
||||
// - ⚠ IT COMPARES AGAINST Stack.CatalogImages, NEVER Stack.TemplateImages, AND v0.235.0 IS WHY.
|
||||
// Since the freeze, a pinned app's LIVE compose file is rendered from its own stored definition
|
||||
// once the catalog moves past it, so installed would equal template and this function would
|
||||
// answer „Naprakész" on precisely the apps that are behind — with every test still green,
|
||||
// because the two fields have the same type and shape.
|
||||
// - KNOWN LIMITATION: 23 of the catalog's 66 distinct pins FLOAT (postgres:16-alpine,
|
||||
// mariadb:11.6, …). For those the reference can be identical while the image behind it has moved
|
||||
// upstream. Those apps read „Naprakész" when they may not be — R-446.
|
||||
func compareInstalledToTemplate(s stacks.Stack) updateState {
|
||||
if !s.Deployed || s.Protected || s.Orphaned {
|
||||
// Not deployed: nothing is running. Protected: infra is ours, not the customer's to update.
|
||||
// Orphaned: the template is gone from the catalog, so there is nothing to be current WITH.
|
||||
switch stacks.CatalogOrder(s) {
|
||||
case stacks.UpdateOrderCurrent:
|
||||
return updateCurrent
|
||||
case stacks.UpdateOrderBehind:
|
||||
return updateBehind
|
||||
case stacks.UpdateOrderAhead:
|
||||
return updateAhead
|
||||
default:
|
||||
return updateUnknown
|
||||
}
|
||||
if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 {
|
||||
return updateUnknown // legacy app.yaml — no record was ever written
|
||||
}
|
||||
if len(s.CatalogImages) == 0 {
|
||||
return updateUnknown // no readable catalog template — cannot tell, so say nothing
|
||||
}
|
||||
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.CatalogImages {
|
||||
got, ok := s.AppConfig.InstalledImages[svc]
|
||||
if !ok || got.Ref != want {
|
||||
return updateBehind
|
||||
}
|
||||
}
|
||||
return updateCurrent
|
||||
}
|
||||
|
||||
// updateBadgeAt is the pure form: `now` is injected so the age is a testable contract rather than a
|
||||
@@ -75,6 +63,13 @@ func compareInstalledToTemplate(s stacks.Stack) updateState {
|
||||
//
|
||||
// It returns a *MetaBadge and calls the EXISTING meta_badge partial — no new markup and no new CSS.
|
||||
// metabadge.go's own comment asks for exactly that of its second user, and this is it.
|
||||
//
|
||||
// ⚠ THIS IS THE HUNGARIAN FORM AND IT KEEPS ITS LITERALS ON PURPOSE. The parity guarantee of the
|
||||
// localisation arc is that templateFuncMap's Hungarian output is byte-identical to what it was
|
||||
// before the bundle existed; the English form is rebuilt from the bundle in
|
||||
// i18n_web.go localeFuncs, over the SAME compareInstalledToTemplate, so only the words differ and
|
||||
// never the decision. A new branch here needs its twin there, and
|
||||
// TestLocaleFuncsHungarianBundleMatchesFuncMap fails if hu.json and these literals disagree.
|
||||
func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
|
||||
switch compareInstalledToTemplate(s) {
|
||||
case updateCurrent:
|
||||
@@ -83,6 +78,17 @@ func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge {
|
||||
Class: "tag-ok",
|
||||
Title: "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.",
|
||||
}
|
||||
case updateAhead:
|
||||
// R-524. THE PILL SAYS THE SAME WORD AS updateCurrent, and that is the ruling: an app that
|
||||
// runs something newer than the catalog has nothing for the household to do, so it must not
|
||||
// wear a warning. The note that says WHY goes in the title, which is the explanation slot
|
||||
// this type exists for — a badge that only says a word is a riddle. No version number
|
||||
// reaches the customer here either.
|
||||
return &MetaBadge{
|
||||
Label: "Naprakész",
|
||||
Class: "tag-ok",
|
||||
Title: "Ez az alkalmazás a katalógusnál újabb változatot futtat, ezért nincs teendőd.",
|
||||
}
|
||||
case updateBehind:
|
||||
label := "Frissítés elérhető"
|
||||
if days, ok := s.Meta.CatalogSinceAge(now); ok {
|
||||
|
||||
@@ -87,6 +87,14 @@ func TestGroupD_FourStates(t *testing.T) {
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("old:1"), "db": rec(tpl["db"])}, tpl, "2026-09-02"),
|
||||
wantBadge: true, wantLabel: "Frissítés elérhető — ma", wantClass: "tag-warn",
|
||||
},
|
||||
{
|
||||
// R-524, the BIGNIGHT case: the box updated and the catalog was reverted under it.
|
||||
// It must NOT read „Frissítés elérhető" — the update behind that word is a downgrade.
|
||||
name: "AHEAD of the catalog — up to date, not a warning",
|
||||
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("privatebin/pdo:2.0.6")},
|
||||
map[string]string{"web": "privatebin/pdo:2.0.5"}, "2026-09-02"),
|
||||
wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok",
|
||||
},
|
||||
{
|
||||
name: "NO RECORD AT ALL (legacy app.yaml) — nothing rendered",
|
||||
stack: ubStack(nil, tpl, "2026-07-18"),
|
||||
|
||||
Reference in New Issue
Block a user