From 8f8a64cad7a5403a20fb9d9fbecd1f497db498df Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 21 Sep 2026 12:48:55 +0200 Subject: [PATCH] =?UTF-8?q?v0.260.0=20=E2=80=94=20a=20box=20ahead=20of=20t?= =?UTF-8?q?he=20catalog=20reads=20=E2=80=9ENaprak=C3=A9sz",=20and=20the=20?= =?UTF-8?q?pin=20never=20moves=20backwards=20(R-524)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 50 ++++ REUSE.md | 1 + controller/README.md | 13 + controller/internal/api/router.go | 8 +- controller/internal/i18n/locales/en.json | 2 + controller/internal/i18n/locales/hu.json | 2 + controller/internal/stacks/update.go | 19 ++ controller/internal/stacks/updateorder.go | 180 +++++++++++++ .../internal/stacks/updateorder_test.go | 239 ++++++++++++++++++ controller/internal/web/i18n_web.go | 7 + controller/internal/web/i18n_wiring_test.go | 22 +- controller/internal/web/updatebadge.go | 86 ++++--- controller/internal/web/updatebadge_test.go | 8 + controller/scripts/i18n_go_keys.json | 2 + 14 files changed, 597 insertions(+), 42 deletions(-) create mode 100644 controller/internal/stacks/updateorder.go create mode 100644 controller/internal/stacks/updateorder_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5360a..d018cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## v0.260.0 — a box AHEAD of the catalog reads „Naprakész", and the pin never moves backwards (2026-09-21, R-524) + +**MinAgent: 0.131.0** (unchanged). Hungarian pages byte-identical; the two new sentences are BORN AS +KEYS in both bundles and the Go-parity gate accounts for them. + +**The defect was measured, not imagined.** BIGNIGHT Phase 6, 2026-09-15: privatebin was updated +2.0.5 → 2.0.6 through the guarded Update, the catalog was then reverted to 2.0.5, and the box read +the tag „**Frissítés elérhető — ma**" with the title inviting the household to press Frissítés. The +comparison asked only "does the installed reference DIFFER from the catalog's?", so a catalog revert +— an operator act on our side — presented itself to a customer as an update, and the guarded Update +behind it would have advanced the pin 2.0.6 → 2.0.5, onto a datadir the newer version may already +have migrated, with §4's ruling saying that cannot be undone. + +- **`stacks.CatalogOrder` — the comparison gains a FOURTH answer, and moves house.** Unknown / + Current / Behind / **Ahead**. It now lives in `internal/stacks/updateorder.go` rather than in + `web`, because TWO callers must reach the same verdict — the badge and the update's refusal — and + a comparison implemented twice is a comparison that drifts. `web.compareInstalledToTemplate` is a + thin wrapper; every property it carried is carried still (absent means UNKNOWN and never + „Naprakész"; it reads `CatalogImages` and never `TemplateImages`; it queries NO registry). +- **The badge.** An app ahead of the catalog reads „Naprakész" / "Up to date" with `tag-ok` — the + same word and the same class as level, because there is nothing for the household to do — and a + title that says why (`badge.update.ahead.title`, both bundles). No version number reaches the + customer, as before. +- **The refusal.** `UpdatePreflight` returns `downgrade` (409) with + „Ez a változat újabb a katalógusban lévőnél — visszalépés csak az üzemeltető kérésére.", logged + with both image maps. **The API renders update refusals through `errText` now**, so a refusal + carrying a key reaches an English household in English — without that line the new key would have + been a seam built and never wired, which is a documented failure class in this repo. +- **Ahead is the NARROW arm, deliberately.** Every differing service must be orderable AND newer. + One service older, one tag unorderable, and the answer falls back to Behind — i.e. to exactly the + behaviour of v0.233.0..v0.259.0. This gate can BLOCK an update, so it errs towards letting one run. +- **Ordering is `util.Version.Compare` and nothing else** (the house rule: one comparator). The new + code is a tag NORMALISER in front of it: `X.Y` and `X.Y.Z`, optional leading `v`, a two-part tag + padded with `.0`, and a trailing suffix that must be IDENTICAL on both sides — so + `nextcloud:31.0.14-apache → 31.0.15-apache` orders, while `26.05.2-ls310 → 26.05.2-ls311`, + `postgres:16-alpine`, `kimai/kimai2:apache-2.57.0`, a date stamp and a digest pin do not. + **Measured against the real catalog**, not invented: 8 of the 66 pins float and `apache-2.57.0` + puts its version at the back. +- **Three red-proofs, each seen to fail.** (1) Make the Ahead arm return Behind → + `TestR524_PreflightRefusesDowngrade` fails with the update ALLOWED. (2) Treat an unorderable pair + as ahead → the floating-tag, different-image and digest-pin cases fail, which is the verdict that + would suppress a real „Frissítés elérhető" on the floating pins. (3) Delete the ahead arm from + `localeFuncs` → the English badge test fails. + +**R-589 was NOT open, whatever the register said.** The row (P3-LOW, READY) claims the badge builds +from four raw Hungarian literals with no key. Those literals are real and DELIBERATE — they are the +parity guarantee — and the English form has been rebuilt from the bundle in `localeFuncs` since +**v0.258.0**, proven live on a fresh box the same day ("Up to date", English drill item 9). The row +is stale, not the code; it is closed in this session with that citation. + ## v0.259.0 — the claim page and the backup warnings answer in the reader's language (2026-09-21, R-596/R-598) **MinAgent: 0.131.0** (unchanged). The Hungarian pages are byte-identical; the Go-parity gate diff --git a/REUSE.md b/REUSE.md index d9ab9b7..02c899d 100644 --- a/REUSE.md +++ b/REUSE.md @@ -144,6 +144,7 @@ | `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved | | `Manager.BackfillInstalledImages` (v0.234.0) | controller/internal/stacks/installed.go | `() int` | seeding `installed_images` for apps that have NO record — call ONCE at startup | Beside `BackfillDesiredState` in `cmd/controller/main.go`, after it and BEFORE the boot reconciler (pinned by an AST-walking test that asserts the ORDER). **READS only** — starts nothing, writes no compose file. **Never overwrites an existing record** (an app that has one is not even observed). **REFUSES a partial observation** (`observationCoversTemplate`): `web.compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible renders „Frissítés elérhető" over an app that is current. The bring-up paths may write a partial because they follow a SUCCESSFUL `up -d` where a gap is real news; a backfill meets any state and must be stricter | | `stacks.ParseComposeImages` (v0.233.0) | controller/internal/stacks/installed.go | `(composePath string) (map[string]string, error)` | compose SERVICE name -> the image the FILE pins; feeds `Stack.TemplateImages` and the update badge | yaml.v3 `services:` MAP parse, never a line scan (same reason as `DBServiceNames`). An error means CANNOT-TELL — `ScanStacks` leaves `TemplateImages` nil and the badge renders NOTHING, never "current" | +| `stacks.CatalogOrder` / `CompareImageRefs` (v0.260.0, R-524) | controller/internal/stacks/updateorder.go | `(Stack) UpdateOrder` — Unknown/Current/Behind/**Ahead** | THE one "how does this app stand against the catalog?" verdict | **Both the badge AND `Manager.UpdatePreflight`'s `downgrade` refusal read it — never re-implement the comparison.** `web.compareInstalledToTemplate` is a thin wrapper. Ahead is NARROW: every differing service must be orderable AND newer, else Behind. Ordering is `util.Version.Compare` behind a tag normaliser (`X.Y`/`X.Y.Z`, optional `v`, suffix must be IDENTICAL on both sides) — **never add a second comparator**. Queries NO registry; absent record = Unknown, never „Naprakész" | | `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the 23 floating pins the ref can match while the image has moved, so those read „Naprakész" when they may not be | | `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks | | `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates | diff --git a/controller/README.md b/controller/README.md index f9e3170..a5ec2a2 100644 --- a/controller/README.md +++ b/controller/README.md @@ -517,10 +517,23 @@ the current template pins and returns a `*MetaBadge` rendered by the existing `m | state | badge | |---|---| | every service matches the template | „Naprakész" (`tag-ok`) | +| **every differing service is provably NEWER than the catalog** (v0.260.0) | **„Naprakész" (`tag-ok`)**, with a title saying the app is ahead | | any service differs, `catalog_since` usable | „Frissítés elérhető — N napja" (`tag-warn`) | | any service differs, `catalog_since` absent/malformed/future | „Frissítés elérhető" | | **no record, or the template cannot be read** | **nothing is rendered** | +**The fourth row is v0.260.0 (R-524), and it is also a REFUSAL.** The comparison lives in +`stacks.CatalogOrder` (`internal/stacks/updateorder.go`) — Unknown / Current / Behind / **Ahead** — +and `web.compareInstalledToTemplate` is a thin wrapper over it, because the badge and +`Manager.UpdatePreflight` must reach the same verdict. A box AHEAD of the catalog (the catalog was +reverted under it) reads „Naprakész" and its Update is **refused** with reason `downgrade`: +„Ez a változat újabb a katalógusban lévőnél — visszalépés csak az üzemeltető kérésére." +**Ahead is narrow on purpose:** EVERY differing service must be orderable and newer, or the answer +falls back to Behind. Ordering is `util.Version.Compare` (the one comparator) behind a tag +normaliser — `X.Y`/`X.Y.Z`, optional leading `v`, and a trailing suffix that must be IDENTICAL on +both sides, so `31.0.14-apache → 31.0.15-apache` orders while `postgres:16-alpine`, +`26.05.2-ls310 → -ls311`, `apache-2.57.0`, a date stamp and a digest pin do not. + - **No version string is shown to the customer anywhere** — operator ruling, 2026-09-02: a household cannot act on `26.05.2`, only on "you are behind, and by this long". Versions stay in the logs, the API and the hub. diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index f820f08..3a29b67 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -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 } } diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 51ed2ed..6a0ab0f 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -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.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 5cb1646..8f177f7 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -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.", diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index d4d12c7..a8b6e07 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -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.) diff --git a/controller/internal/stacks/updateorder.go b/controller/internal/stacks/updateorder.go new file mode 100644 index 0000000..a13cc4c --- /dev/null +++ b/controller/internal/stacks/updateorder.go @@ -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 +} diff --git a/controller/internal/stacks/updateorder_test.go b/controller/internal/stacks/updateorder_test.go new file mode 100644 index 0000000..145440b --- /dev/null +++ b/controller/internal/stacks/updateorder_test.go @@ -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 = ` — 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) + } + }) + } +} diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index 92440f8..fe5d533 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -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 { diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go index b481f94..467e583 100644 --- a/controller/internal/web/i18n_wiring_test.go +++ b/controller/internal/web/i18n_wiring_test.go @@ -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) } diff --git a/controller/internal/web/updatebadge.go b/controller/internal/web/updatebadge.go index 01cceaf..7480128 100644 --- a/controller/internal/web/updatebadge.go +++ b/controller/internal/web/updatebadge.go @@ -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 { diff --git a/controller/internal/web/updatebadge_test.go b/controller/internal/web/updatebadge_test.go index 454ee4a..ff5432e 100644 --- a/controller/internal/web/updatebadge_test.go +++ b/controller/internal/web/updatebadge_test.go @@ -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"), diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index e3eb4a2..b7962b4 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -1,6 +1,8 @@ { "_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.", "_preexisting": { + "badge.update.ahead.title": "BORN AS A KEY, v0.260.0 (R-524) -- a NEW sentence, never a Go literal, so there is nothing in the base capture to measure it against. Pinned in both languages by TestUpdateBadgeFollowsTheLanguage.", + "err.stacks.update_downgrade": "BORN AS A KEY, v0.260.0 (R-524) -- the downgrade refusal. A NEW sentence, never a Go literal. Pinned by TestR524_PreflightRefusesDowngrade, which asserts the refusal carries a Cause so api.Router.errText can render it.", "func.state.running": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", "func.state.starting": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", "func.state.deploying": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",