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

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:
2026-09-21 12:48:55 +02:00
parent 19ef0329ab
commit 8f8a64cad7
14 changed files with 597 additions and 42 deletions
+7 -1
View File
@@ -646,7 +646,13 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
if ref.Reason == "not_found" {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: ref.Message})
// errText, not ref.Message (v0.260.0): a refusal built with refuseUpdateErr carries its
// bundle key, so the household reads the refusal in its own language. A refusal still
// built from a Hungarian literal has no key and errText returns ref.Message byte for
// byte — which is why this line is safe to change for all of them at once, and why the
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
// wired" class).
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref)})
return
}
}
+2
View File
@@ -2017,6 +2017,7 @@
"disk.err.wipe_failed": "the erase failed: %s",
"disk.err.attach_unavailable": "This drive cannot be attached right now — it may already be registered, or it may have been unplugged. Reload the page and look at Storage → Drives.",
"err.stacks.migracio_mar_folyamatban": "a migration is already running",
"err.stacks.update_downgrade": "This version is newer than the one in the catalog — moving back needs the operator.",
"err.stacks.alkalmazas_nem_talalhato": "app not found: %s",
"err.stacks.ismeretlen_migracios_hatokor": "unknown migration scope: %s",
"err.stacks.migracios_naplo_irasa": "writing the migration log: %s",
@@ -2289,6 +2290,7 @@
"event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)",
"badge.update.current": "Up to date",
"badge.update.current.title": "This app is running the newest version available.",
"badge.update.ahead.title": "This app is running a version newer than the catalog offers, so there is nothing for you to do.",
"badge.update.behind": "Update available",
"badge.update.behind.today": " — today",
"badge.update.behind.title": "A newer version of this app is available. Select the Update button to start it.",
+2
View File
@@ -2014,6 +2014,7 @@
"disk.err.wipe_failed": "törlés sikertelen: %s",
"disk.err.attach_unavailable": "Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát.",
"err.stacks.migracio_mar_folyamatban": "migráció már folyamatban",
"err.stacks.update_downgrade": "Ez a változat újabb a katalógusban lévőnél — visszalépés csak az üzemeltető kérésére.",
"err.stacks.alkalmazas_nem_talalhato": "alkalmazás nem található: %s",
"err.stacks.ismeretlen_migracios_hatokor": "ismeretlen migrációs hatókör: %s",
"err.stacks.migracios_naplo_irasa": "migrációs napló írása: %s",
@@ -2278,6 +2279,7 @@
"event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)",
"badge.update.current": "Naprakész",
"badge.update.current.title": "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.",
"badge.update.ahead.title": "Ez az alkalmazás a katalógusnál újabb változatot futtat, ezért nincs teendőd.",
"badge.update.behind": "Frissítés elérhető",
"badge.update.behind.today": " — ma",
"badge.update.behind.title": "Újabb változat érhető el ehhez az alkalmazáshoz. A frissítés indításához nyomd meg a Frissítés gombot.",
+19
View File
@@ -10,6 +10,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── The guarded update (update arc slice 4, controller v0.237.0) ─────────────────────────────────
@@ -322,6 +323,24 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
if m.IsMigrating() {
return m.refuseUpdate(name, "migrating", MsgUpdateMigrating, "a data migration is running")
}
// R-524 — THE PIN NEVER MOVES BACKWARDS WITHOUT THE OPERATOR. MEASURED 2026-09-15 (BIGNIGHT
// Phase 6): privatebin was updated 2.0.5 → 2.0.6, the catalog was reverted to 2.0.5, and the
// „Frissítés" button behind the badge would have advanced the pin to the OLDER image — on a
// datadir the newer version may already have migrated, with §4's ruling saying that cannot be
// undone. A catalog revert is an operator act on our side; it must never become a data event on
// the customer's side by itself.
//
// It refuses ONLY the provable case (stacks.CatalogOrder's Ahead arm: every differing service
// orderable and newer). Anything unorderable, mixed or equal falls through to the behaviour that
// shipped in v0.237.0 — this gate can block an update, so it errs towards letting one run.
//
// COMPANION RED-PROOF (REPORT.md): make CatalogOrder's Ahead arm return Behind.
// TestR524_PreflightRefusesDowngrade then fails — the update is allowed to move the pin back.
if CatalogOrder(*st) == UpdateOrderAhead {
return m.refuseUpdateErr(name, "downgrade", util.MsgError("err.stacks.update_downgrade"),
fmt.Sprintf("installed is provably NEWER than the catalog on every differing service (installed=%v catalog=%v)",
st.AppConfig.InstalledImages, st.CatalogImages))
}
// R-475: any tier counts, and an app with no copy at all is backed up first by the job. So the only
// refusal left here is Scenario L — no copy on any tier AND no way to make one now. (With a copy
// but no way to back up, the job still applies the age rule and refuses then if the copy is stale.)
+180
View File
@@ -0,0 +1,180 @@
package stacks
import (
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── Is this app behind the catalog, level with it, or AHEAD of it? (R-524, v0.260.0) ─────────────
//
// Slice 2 (v0.233.0) asked only "does the installed reference DIFFER from the catalog's?" and called
// every difference „Frissítés elérhető". MEASURED 2026-09-15 (BIGNIGHT Phase 6): privatebin was
// updated 2.0.5 → 2.0.6 and the catalog was then reverted to 2.0.5. The box read
// „Frissítés elérhető — ma", and the guarded Update behind that badge would have advanced the pin
// 2.0.6 → 2.0.5 — a DOWNGRADE offered to a household as an update, with a migrated datadir behind it
// and §4's ruling saying it cannot be undone.
//
// So the comparison gains a fourth answer. It lives HERE, in stacks, and not in web, because two
// callers must reach the SAME verdict: the badge (web.compareInstalledToTemplate) and the guarded
// update's refusal (Manager.UpdatePreflight). A comparison implemented twice is a comparison that
// drifts — the same lesson localisation learned when the badge's two language paths were written
// apart (R-589).
//
// IT QUERIES NO REGISTRY, exactly as before (09-update-architecture.md §8.1). Ordering is decided
// from the TAG TEXT alone, and only when the tag text can carry an order at all.
// UpdateOrder is the four-way answer to "how does what this app RUNS stand against what the catalog
// OFFERS?".
//
// ABSENT STILL MEANS UNKNOWN, AND NEVER „NAPRAKÉSZ" — the R-166 property slice 2 was built around,
// carried over verbatim. The new value is Ahead, and it is deliberately NOT folded into Current:
// the badge shows the same word for both, but the UPDATE must refuse only one of them, and a caller
// that cannot tell them apart cannot refuse correctly.
type UpdateOrder int
const (
UpdateOrderUnknown UpdateOrder = iota // nothing recorded, or nothing to compare against
UpdateOrderCurrent // every service runs exactly what the catalog pins
UpdateOrderBehind // at least one service differs and is not provably newer
UpdateOrderAhead // every differing service is provably NEWER than the catalog
)
// CatalogOrder compares an app's recorded installed images against what the catalog offers.
//
// The Ahead arm is deliberately the narrow one: EVERY differing service must be orderable and newer.
// One service that is older, or one tag that cannot carry an order, and the answer falls back to
// Behind — i.e. to exactly the behaviour of v0.233.0..v0.259.0. A half-ahead app is not a downgrade
// the box may refuse on its own; it is a mixed state a human should look at, and „Frissítés elérhető"
// is the honest label for it.
func CatalogOrder(s Stack) UpdateOrder {
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.
return UpdateOrderUnknown
}
if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 {
return UpdateOrderUnknown // legacy app.yaml — no record was ever written
}
if len(s.CatalogImages) == 0 {
return UpdateOrderUnknown // 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, and it is not a version order.
return UpdateOrderBehind
}
differing := 0
for svc, want := range s.CatalogImages {
got, ok := s.AppConfig.InstalledImages[svc]
if !ok {
return UpdateOrderBehind // the catalog names a service the record does not cover
}
if got.Ref == want {
continue
}
differing++
if cmp, ok := CompareImageRefs(got.Ref, want); !ok || cmp <= 0 {
return UpdateOrderBehind
}
}
if differing == 0 {
return UpdateOrderCurrent
}
return UpdateOrderAhead
}
// CompareImageRefs orders two image references the way a human reads them: -1 when a is older than
// b, 0 when they are the same version, 1 when a is newer. The second return is the whole point —
// FALSE means "these two cannot be ordered", and every caller must treat that as "do not know"
// rather than as "equal".
//
// It answers false, on purpose, for far more than it answers true:
// - a digest-pinned reference (`…@sha256:…`) — the digest carries no order;
// - a reference with no tag — the implicit `latest` is a moving target, not a position;
// - two references to DIFFERENT images (`alpine:3.20` against `…/bookstack:26.05.2`) — the numbers
// are comparable and the comparison is meaningless, which is the worst kind of false positive;
// - any tag that is not plain digits and dots: `16-alpine`, `latest`, `26.05.2-ls310`, `stable`,
// a date stamp, a git sha. 23 of the catalog's 66 pins float exactly like this (§8.1).
//
// THE ORDER ITSELF IS util.Version.Compare AND NOTHING ELSE. The house rule is one comparator in
// this repo; this function is a tag NORMALISER in front of it, never a second implementation.
func CompareImageRefs(a, b string) (int, bool) {
repoA, tagA := splitImageRef(a)
repoB, tagB := splitImageRef(b)
if repoA == "" || repoA != repoB {
return 0, false
}
va, sufA, okA := parseImageTag(tagA)
vb, sufB, okB := parseImageTag(tagB)
if !okA || !okB {
return 0, false
}
// THE SUFFIXES MUST BE IDENTICAL, and this is not pedantry. `nextcloud:31.0.14-apache` and
// `nextcloud:31.0.15-apache` are the same flavour of the same image and order cleanly; but
// `26.05.2-ls310` against `26.05.2-ls311` differs only in a build number this function has no
// rule for, and `…:2.4.0-alpine` against `…:2.4.0` is a different image content under one repo
// name. Equal-or-nothing keeps every one of those out of the Ahead arm.
if sufA != sufB {
return 0, false
}
return va.Compare(vb), true
}
// splitImageRef separates `repo` from `tag`, and returns two empty strings whenever the reference
// carries no plain tag to compare.
//
// The `/` test after the last colon is what keeps a registry PORT from being read as a tag:
// `gitea.dooplex.hu:3000/admin/app` has a colon in it and no tag at all.
func splitImageRef(ref string) (repo, tag string) {
if strings.Contains(ref, "@") {
return "", "" // digest-pinned
}
i := strings.LastIndex(ref, ":")
if i < 0 {
return "", "" // no tag — the implicit `latest`
}
if strings.Contains(ref[i+1:], "/") {
return "", "" // that colon was a registry port
}
return ref[:i], ref[i+1:]
}
// parseImageTag splits a tag into the version at its FRONT and whatever follows, and refuses
// anything whose front is not `X.Y` or `X.Y.Z` (an optional leading `v` is allowed).
//
// "2.0.6" → 2.0.6, ""
// "31.0.14-apache" → 31.0.14, "-apache" ← real: the catalog's nextcloud pin
// "11.6" → 11.6.0, "" ← real: the catalog's mariadb pins
// "16-alpine" → refused: one component is not a version, it is a major line
// "apache-2.57.0" → refused: the version is not at the front (real: the catalog's kimai pin)
// "20260915" → refused: a date stamp is one component
//
// A two-part tag is padded with `.0` before it reaches the comparator. The padding is safe in the
// one direction that matters: it only ever adds the smallest possible patch number, and it is
// applied to whichever side is short, so it can never make an older tag look newer.
func parseImageTag(tag string) (util.Version, string, bool) {
t := strings.TrimPrefix(tag, "v")
end := 0
for end < len(t) && ((t[end] >= '0' && t[end] <= '9') || t[end] == '.') {
end++
}
head, suffix := t[:end], t[end:]
parts := strings.Split(head, ".")
if len(parts) < 2 || len(parts) > 3 {
return util.Version{}, "", false
}
for _, p := range parts {
if p == "" {
return util.Version{}, "", false
}
}
for len(parts) < 3 {
parts = append(parts, "0")
}
v, err := util.ParseVersion(strings.Join(parts, "."))
if err != nil {
return util.Version{}, "", false
}
return v, suffix, true
}
@@ -0,0 +1,239 @@
package stacks
import (
"strings"
"testing"
)
// R-524 (v0.260.0) — the order, and the refusal to move a pin backwards.
//
// The defect this pins was MEASURED, not imagined: BIGNIGHT Phase 6, 2026-09-15, privatebin
// installed 2.0.6 against a catalog reverted to 2.0.5, badge „Frissítés elérhető — ma", and the
// button behind it offering the downgrade.
func oi(ref string) InstalledImage {
return InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"}
}
// coStack builds a deployed app with its record and its CATALOG images stated explicitly.
func coStack(installed map[string]InstalledImage, catalog map[string]string) Stack {
return Stack{
Name: "privatebin",
Deployed: true,
State: StateRunning,
AppConfig: &AppConfig{Deployed: true, InstalledImages: installed},
CatalogImages: catalog,
}
}
// TestR524_CatalogOrder is the whole verdict table, including every arm that must NOT be Ahead.
//
// COMPANION RED-PROOF (run 2026-09-21): in CatalogOrder, change the Ahead arm's guard
// `if cmp, ok := CompareImageRefs(...); !ok || cmp <= 0` to `if cmp, ok := ...; cmp < 0` — i.e. the
// plausible-looking implementation that treats an UNORDERABLE pair as ahead. The three floating-tag
// sub-tests below („a floating tag …", „a different image entirely", „a digest pin") then fail with
// Ahead, which is the verdict that would suppress a real „Frissítés elérhető" on 23 of the catalog's
// 66 pins. Reverted.
func TestR524_CatalogOrder(t *testing.T) {
cases := []struct {
name string
stack Stack
want UpdateOrder
}{
{
name: "level — every service matches",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.5")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
want: UpdateOrderCurrent,
},
{
name: "behind — the catalog is newer",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.5")}, map[string]string{"web": "privatebin/pdo:2.0.6"}),
want: UpdateOrderBehind,
},
{
name: "AHEAD — THE BIGNIGHT CASE: the box updated, the catalog was reverted",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
want: UpdateOrderAhead,
},
{
name: "ahead across a major — still ahead, still no downgrade",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:3.0.0")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
want: UpdateOrderAhead,
},
{
name: "ahead on a TWO-PART tag (mariadb:11.6 style)",
stack: coStack(map[string]InstalledImage{"db": oi("mariadb:11.7")}, map[string]string{"db": "mariadb:11.6"}),
want: UpdateOrderAhead,
},
{
name: "MIXED — one newer, one older — is BEHIND, never ahead",
stack: coStack(
map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6"), "db": oi("mariadb:11.4")},
map[string]string{"web": "privatebin/pdo:2.0.5", "db": "mariadb:11.6"}),
want: UpdateOrderBehind,
},
{
name: "a floating tag cannot be ordered — behind, as before",
stack: coStack(map[string]InstalledImage{"db": oi("postgres:16-alpine")}, map[string]string{"db": "postgres:15-alpine"}),
want: UpdateOrderBehind,
},
{
name: "a different image entirely — the numbers compare, the comparison is meaningless",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "alpine:3.20"}),
want: UpdateOrderBehind,
},
{
name: "a digest pin carries no order",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo@sha256:aaaa")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
want: UpdateOrderBehind,
},
{
name: "a service was ADDED by the template — behind, not an order at all",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "privatebin/pdo:2.0.5", "db": "mariadb:11.6"}),
want: UpdateOrderBehind,
},
{
name: "NO RECORD AT ALL — unknown, and never current",
stack: coStack(nil, map[string]string{"web": "privatebin/pdo:2.0.5"}),
want: UpdateOrderUnknown,
},
{
name: "no readable catalog template — unknown",
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, nil),
want: UpdateOrderUnknown,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := CatalogOrder(c.stack); got != c.want {
t.Errorf("CatalogOrder = %v, want %v", got, c.want)
}
})
}
}
// TestR524_ProtectedAndUndeployedAreUnknown keeps the three carried-over guards honest: they were in
// web.compareInstalledToTemplate before the move and a move is exactly when a guard gets dropped.
func TestR524_ProtectedAndUndeployedAreUnknown(t *testing.T) {
base := coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "privatebin/pdo:2.0.5"})
if CatalogOrder(base) != UpdateOrderAhead {
t.Fatalf("the base case must be Ahead, or this test proves nothing")
}
for _, c := range []struct {
name string
mut func(*Stack)
}{
{"not deployed", func(s *Stack) { s.Deployed = false }},
{"protected infra", func(s *Stack) { s.Protected = true }},
{"orphaned", func(s *Stack) { s.Orphaned = true }},
} {
t.Run(c.name, func(t *testing.T) {
st := base
c.mut(&st)
if got := CatalogOrder(st); got != UpdateOrderUnknown {
t.Errorf("CatalogOrder = %v, want Unknown", got)
}
})
}
}
// TestR524_CompareImageRefs pins the normaliser in front of util.Version.Compare, where the traps
// live: a registry port that looks like a tag, a two-part tag, a leading v, a zero-padded component.
func TestR524_CompareImageRefs(t *testing.T) {
cases := []struct {
a, b string
wantCmp int
wantOK bool
}{
{"privatebin/pdo:2.0.6", "privatebin/pdo:2.0.5", 1, true},
{"privatebin/pdo:2.0.5", "privatebin/pdo:2.0.6", -1, true},
{"privatebin/pdo:2.0.5", "privatebin/pdo:2.0.5", 0, true},
{"lscr.io/linuxserver/bookstack:v26.05.2", "lscr.io/linuxserver/bookstack:25.02.2", 1, true},
// 26.05.2 must beat 26.5.1 and NOT lose to it on the zero: Atoi("05") is 5.
{"x/y:26.05.2", "x/y:26.5.1", 1, true},
{"mariadb:11.7", "mariadb:11.6", 1, true},
// A two-part tag pads to .0, so 11.6 is older than 11.6.1 and level with itself.
{"mariadb:11.6", "mariadb:11.6.1", -1, true},
// A registry PORT is not a tag.
{"gitea.dooplex.hu:3000/admin/app", "gitea.dooplex.hu:3000/admin/app", 0, false},
{"postgres:16-alpine", "postgres:15-alpine", 0, false},
{"redis:7-alpine", "redis:7-alpine", 0, false},
{"app:latest", "app:2.0.0", 0, false},
{"app:20260915", "app:20260914", 0, false},
{"app@sha256:aa", "app:2.0.0", 0, false},
{"alpine:3.20", "privatebin/pdo:2.0.5", 0, false},
{"app", "app:2.0.0", 0, false},
// Real catalog shapes. The suffix must be IDENTICAL for the numbers to be compared.
{"nextcloud:31.0.15-apache", "nextcloud:31.0.14-apache", 1, true},
{"nextcloud:31.0.14-apache", "nextcloud:31.0.14", 0, false},
{"x/y:26.05.2-ls311", "x/y:26.05.2-ls310", 0, false},
{"postgis/postgis:16-3.5-alpine", "postgis/postgis:15-3.5-alpine", 0, false},
{"kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.56.0", 0, false},
}
for _, c := range cases {
t.Run(c.a+" vs "+c.b, func(t *testing.T) {
cmp, ok := CompareImageRefs(c.a, c.b)
if ok != c.wantOK {
t.Fatalf("orderable = %v, want %v", ok, c.wantOK)
}
if ok && cmp != c.wantCmp {
t.Errorf("cmp = %d, want %d", cmp, c.wantCmp)
}
})
}
}
// ── The refusal: the guarded Update never moves a pin backwards (R-524) ──────────────────────────
// TestR524_PreflightRefusesDowngrade is the CONSEQUENCE test, not the mechanism test: the question
// is not "does CatalogOrder say Ahead" (TestR524_CatalogOrder asks that) but "does the button
// refuse". R-97b's Scenario F is the reason the two are separate — the mechanism was proven and the
// consequence was still broken.
//
// COMPANION RED-PROOF (run 2026-09-21): delete the `CatalogOrder(*st) == UpdateOrderAhead` block from
// UpdatePreflight. The `ahead` sub-test then fails with `ref = <nil>` — the update is allowed, and
// the next thing it does is advance the pin to the older image. Reverted.
func TestR524_PreflightRefusesDowngrade(t *testing.T) {
cases := []struct {
name string
installed string
catalog string
wantReason string // "" = must be allowed
}{
{"ahead — the downgrade is refused", "nextcloud:31.0.15-apache", "nextcloud:31.0.14-apache", "downgrade"},
{"behind — the ordinary update is allowed", "nextcloud:31.0.13-apache", "nextcloud:31.0.14-apache", ""},
{"level — allowed (a same-version update is the repair path)", "nextcloud:31.0.14-apache", "nextcloud:31.0.14-apache", ""},
{"unorderable — allowed, exactly as before v0.260.0", "nextcloud:31-apache", "nextcloud:30-apache", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m, _, _, _ := newSlice4Manager(t)
st := m.stacks["nextcloud"]
st.AppConfig.InstalledImages = map[string]InstalledImage{"web": oi(c.installed)}
st.CatalogImages = map[string]string{"web": c.catalog}
ref := m.UpdatePreflight("nextcloud")
if c.wantReason == "" {
if ref != nil {
t.Fatalf("this update must be allowed, got refusal %q: %s", ref.Reason, ref.Message)
}
return
}
if ref == nil {
t.Fatalf("this update must be REFUSED with reason %q, got nil", c.wantReason)
}
if ref.Reason != c.wantReason {
t.Fatalf("reason = %q, want %q (message %q)", ref.Reason, c.wantReason, ref.Message)
}
// The refusal carries its bundle key, so api.Router.errText renders it in the
// household's language. Without the Cause it would be Hungarian on an English page —
// the R-589 failure in a new place.
if ref.Cause == nil {
t.Error("the downgrade refusal must carry its key as a Cause, not only a Hungarian literal")
}
if !strings.Contains(ref.Message, "katal") {
t.Errorf("the Hungarian fallback must name the catalog, got %q", ref.Message)
}
})
}
}
+7
View File
@@ -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 {
+21 -1
View File
@@ -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)
}
+46 -40
View File
@@ -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"),