diff --git a/CHANGELOG.md b/CHANGELOG.md index d27eae1..cba8af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +## v0.234.0 — the label now appears on an app nobody has touched (2026-09-03, update arc slice 1b) + +**Found by the operator on demo-felhom the morning after v0.233.0, and it is a real gap, not a +misunderstanding:** OpenGist had been up 15 hours, was running exactly what the catalog pins, and +showed **no badge at all**. v0.233.0 wrote the record only from the four bring-up paths, so an app +nobody restarts carried no record — and therefore no label — **indefinitely**. On a quiet box that is +every app, which is the box we most want to be able to see. v0.233.0's own architecture note called +this a known limitation; a day of it showed the limitation was the feature not working. + +### `Manager.BackfillInstalledImages` — seed the absences, once, at startup + +`controller/internal/stacks/installed.go`, called from `cmd/controller/main.go` beside +`BackfillDesiredState` and before the boot reconciler. + +**It READS. It starts nothing, restarts nothing, upgrades nothing and writes no compose file.** That +is what makes a backfill safe here, and it is the same shape R-166's desired-state backfill already +uses — with one deliberate difference: + +- **It never overwrites an existing record.** The bring-up paths own updates; this only fills gaps. + An app that already has a record is not even observed. +- **It REFUSES to seed a partial observation, and that is the whole reason this is not a three-line + loop.** `compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding what can + be seen on a degraded or crash-looping app would render „Frissítés elérhető" over an app that is + perfectly current — **a confident wrong answer, which is worse than the silence it replaces.** The + bring-up paths do not have this problem: they run right after a SUCCESSFUL `compose up -d`, where a + missing container is real news and already logs a WARN. A backfill meets whatever state a box is in + at boot, so it is stricter. An app it cannot observe COMPLETELY keeps no record — unknown, which + renders nothing, which is the honest answer. +- Protected and undeployed stacks are skipped, and the summary line is a **positive observable**: + `N recorded, M already had a record, K left unrecorded`. + +**Nothing else changed.** No behaviour, no new endpoint, no auto-update, buttons byte-identical. + +### A test of mine was a calendar bomb, and it went off overnight + +`TestGroupD_BadgeRendersOnBothSurfaces` hardcoded `catalog_since: "2026-07-18"` **and** asserted +`"Frissítés elérhető — 46 napja"`. The pure tests inject a clock; **the RENDER path calls the funcmap +entry, which uses `time.Now()`.** So the test was green on the day it was written (2026-09-02) and +**red the next morning** — 47 days, not 46. It is now derived: the fixture's `catalog_since` is +computed as *today minus 46 days*, so it asserts the real number through the real clock and cannot +rot. **Filed as R-457** — six other test files mix a hardcoded date with `time.Now()` and are named +there as unchecked candidates, not accused. + +### Tests + ++5 (1724 → 1729). 28 packages green, 0 FAIL. **Companion red-proof (run 2026-09-03):** delete the +`observationCoversTemplate` guard and `TestGroupG_BackfillRefusesAPartialObservation` fails with +*"backfilled 1, want 0 — a partial observation must NOT be seeded"*. Reverted. + +**Wiring:** `TestGroupG_BackfillIsWiredAtStartup` walks the **AST** of `cmd/controller/main.go` for +the call and asserts its ORDER — after the desired-state backfill, before the boot reconciler — because +a backfill nothing invokes seeds nothing, and a `strings.Contains` would match a commented-out call. + ## v0.233.0 — the box writes down what it installed, and one label says whether it is current (2026-09-02, update arc slices 1 & 2) **Neither slice changes any behaviour.** The Frissítés button, the restart path, the sync and the boot diff --git a/CONTEXT.md b/CONTEXT.md index 417518f..00b0bbd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,30 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-02 (v0.233.0 — update arc slices 1 & 2: the installed-images record + the update badge) +Last updated: 2026-09-03 (v0.234.0 — the installed-images backfill, so the badge appears on an app nobody touched) + +> **2026-09-03 — v0.234.0. ONE GAP CLOSED, ONE TEST DEFECT OF MY OWN.** +> +> **1. A RECORD THAT ONLY THE BRING-UP PATHS WRITE NEVER REACHES A QUIET BOX.** v0.233.0 shipped with +> "the record appears after the next lifecycle action" written down as a known limitation. One day +> later the operator looked at demo-felhom and saw OpenGist — up 15 hours, running exactly the catalog +> pin, **no badge at all**. The limitation WAS the feature not working. `BackfillInstalledImages` now +> seeds the absences at startup by READING containers. **The general lesson: a feature that only fills +> itself in on an event nobody triggers is, on the quiet installations, not shipped.** +> +> **2. THE BACKFILL IS STRICTER THAN THE BRING-UP PATHS, AND THE ASYMMETRY IS THE DESIGN.** The badge +> reads a service-count mismatch as BEHIND. The bring-up recorder runs right after a SUCCESSFUL +> `up -d`, where a missing container is real news; a backfill meets a box in whatever state it is in, +> so a partial seed would render „Frissítés elérhető" over an app that is perfectly current. It +> therefore refuses to seed anything it cannot observe COMPLETELY. **Same data, two writers, two +> different admission rules — do not "make them consistent".** +> +> **3. A TEST THAT HARDCODES A DATE AND ASSERTS AN AGE IS GREEN ONLY ON THE DAY IT IS WRITTEN.** +> `TestGroupD_BadgeRendersOnBothSurfaces` pinned `catalog_since: "2026-07-18"` and the string +> "46 napja". The pure tests inject a clock; the RENDER path goes through the funcmap and reads +> `time.Now()`. It passed on 2026-09-02 and was red on 2026-09-03. Now derived from the same clock the +> code reads. **R-457** names six other test files that mix a literal date with `time.Now()` — as +> unchecked candidates, not accusations. > **2026-09-02 — v0.233.0. TWO DECISIONS, AND ONE LIMITATION THAT IS NOT A DEFECT.** > diff --git a/REUSE.md b/REUSE.md index 7003616..7034e19 100644 --- a/REUSE.md +++ b/REUSE.md @@ -120,6 +120,7 @@ | `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll | | `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix | | `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/`UpdateStack`/`runComposeDeploy`. **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither | +| `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" | | `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 | diff --git a/controller/README.md b/controller/README.md index 5160873..97449d5 100644 --- a/controller/README.md +++ b/controller/README.md @@ -496,6 +496,13 @@ installed_images: - **NOT called from `StartStackServices`** — that path starts only the database service for the R-47 restore window, and a partial record would overwrite a complete one. - **Absent means UNKNOWN and never means current.** Every `app.yaml` predating v0.233.0 has no entry. +- **Seeded at startup for apps nobody touches (v0.234.0).** `Manager.BackfillInstalledImages` runs + once at boot, beside the desired-state backfill, and records what every deployed app is ALREADY on. + It only READS containers — it starts nothing and writes no compose file. It **never overwrites an + existing record**, and it **refuses to seed a partial observation**: `updateBadge` reads a + service-count mismatch as BEHIND, so a degraded app seeded from what is visible would show + „Frissítés elérhető" while being perfectly current. Without this, v0.233.0's label never appeared on + an app that simply ran (found on demo-felhom, 2026-09-03). - Its own docker seam (`Manager.installedExecFn`) carries a **context and a 30 s timeout**, which `composeExecCustomEnv`/`execCommand` do not — a bookkeeping read must not be able to wedge a lifecycle action. diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index c3f9f83..59b02fa 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -435,6 +435,18 @@ func main() { // the defect. Runs after the two recoveries so a just-restarted app is counted as running. stackMgr.BackfillDesiredState() + // --- v0.234.0: installed-images backfill (complete observations only) --- + // v0.233.0 recorded what an app installed only from the four bring-up paths, so an app nobody + // restarts carried no record — and therefore no „Naprakész"/„Frissítés elérhető" badge — for as + // long as it went untouched. On a quiet box that is EVERY app, which is the box we most want to + // be able to see. This seeds the absences by READING the containers: it starts nothing, restarts + // nothing and writes no compose file. It never overwrites an existing record, and it refuses to + // seed an app it cannot observe COMPLETELY — a partial record renders as "behind" on an app that + // is current, and a confident wrong answer is worse than the silence it replaces. + // Placed here, beside the desired-state backfill and after the two recoveries, for the same + // reason: a just-restarted app is observed in its settled state. + stackMgr.BackfillInstalledImages() + // --- R-52: boot desired-state reconciliation --- // A deployed app that missed its boot start used to stay down until a human noticed (F5: immich // and calibre-web sat Exited for ~18 h while ten siblings came back). One bounded start-once diff --git a/controller/internal/stacks/installed.go b/controller/internal/stacks/installed.go index 79da45a..41a5df6 100644 --- a/controller/internal/stacks/installed.go +++ b/controller/internal/stacks/installed.go @@ -436,3 +436,107 @@ func summariseInstalled(m map[string]InstalledImage) string { } return strings.Join(parts, ", ") } + +// BackfillInstalledImages records what every deployed app is ALREADY running, for apps that have no +// record yet. Call ONCE at startup, after ScanStacks and after the recoveries. +// +// ── WHY THIS EXISTS AT ALL ─────────────────────────────────────────────────────────────────── +// +// v0.233.0 wrote the record only from the four bring-up paths, so an app nobody restarts carried no +// record — and no badge — INDEFINITELY. On a quiet box that is every app, which is the box we most +// want to be able to see. The operator found it on demo-felhom the day after the release: OpenGist, +// up 15 hours, running exactly what the catalog pins, and showing nothing at all. +// +// Reading a container is a pure OBSERVATION: it starts nothing, restarts nothing, upgrades nothing +// and writes no compose file. That is what makes a backfill safe here and is why it is the same +// shape as BackfillDesiredState — with one deliberate difference, below. +// +// ── NEVER OVERWRITES AN EXISTING RECORD ────────────────────────────────────────────────────── +// +// Apps with a record are skipped entirely. The bring-up paths own updates; this only seeds absences. +// +// ── AND IT REFUSES TO SEED A PARTIAL OBSERVATION ───────────────────────────────────────────── +// +// THE TRAP, and it is the whole reason this is not a three-line loop: web.compareInstalledToTemplate +// reads a service-count mismatch as BEHIND. A crash-looping or degraded app can have fewer live +// containers than the template has services, so seeding what we can see would render +// "Frissítés elérhető" over an app that is perfectly current — a confident WRONG answer to the +// customer, which is worse than the silence it replaces. +// +// The bring-up paths do not have this problem: they run immediately after a SUCCESSFUL `compose up +// -d`, where a missing container is real news and is already logged as a WARN. A backfill runs over +// whatever state a box happens to be in at boot, so it must be stricter. An app it cannot observe +// COMPLETELY is left with no record — unknown, which renders nothing, which is the honest answer. +func (m *Manager) BackfillInstalledImages() int { + backfilled, skippedHaveRecord, skippedIncomplete := 0, 0, 0 + + for _, s := range m.GetStacks() { + if !s.Deployed || s.Protected || s.Deploying { + continue + } + if s.AppConfig != nil && len(s.AppConfig.InstalledImages) > 0 { + skippedHaveRecord++ + continue + } + stackDir := filepath.Dir(s.ComposePath) + + tpl, err := ParseComposeImages(s.ComposePath) + if err != nil || len(tpl) == 0 { + // Cannot tell what a complete observation would even BE. Leave it unknown. + skippedIncomplete++ + continue + } + observed, err := m.observeInstalledImages(stackDir, m.stackEnv(stackDir)) + if err != nil { + m.logger.Printf("[WARN] [stacks] installed-images backfill: %s: %v", s.Name, err) + skippedIncomplete++ + continue + } + if !observationCoversTemplate(observed, tpl) { + // Stopped, degraded, crash-looping, or mid-anything. See the comment above: a partial + // seed would render as "behind" on an app that is current. + skippedIncomplete++ + continue + } + + cfg := LoadAppConfig(stackDir) + if cfg == nil { + skippedIncomplete++ + continue + } + cfg.InstalledImages = observed + meta := LoadMetadata(stackDir) + if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { + m.logger.Printf("[ERROR] [stacks] installed-images backfill: %s: %v", s.Name, err) + continue + } + m.mu.Lock() + if st, ok := m.stacks[s.Name]; ok && st.AppConfig != nil { + st.AppConfig.InstalledImages = observed + } + m.mu.Unlock() + backfilled++ + m.logger.Printf("[INFO] [stacks] installed-images backfill: %s recorded %d service(s) (%s)", + s.Name, len(observed), summariseInstalled(observed)) + } + + // A POSITIVE OBSERVABLE EITHER WAY (standing rule 3): "0 backfilled" and "the backfill never ran" + // must not look the same in a log. + m.logger.Printf("[INFO] [stacks] installed-images backfill: %d app(s) recorded, %d already had a record, %d left unrecorded (could not be observed completely — unknown, which renders nothing)", + backfilled, skippedHaveRecord, skippedIncomplete) + return backfilled +} + +// observationCoversTemplate reports whether EVERY compose service the template declares was observed. +// Extra observed services are fine (a stray container is not a missing one); a missing one is not. +func observationCoversTemplate(observed map[string]InstalledImage, tpl map[string]string) bool { + if len(observed) == 0 { + return false + } + for svc := range tpl { + if _, ok := observed[svc]; !ok { + return false + } + } + return true +} diff --git a/controller/internal/stacks/installed_test.go b/controller/internal/stacks/installed_test.go index ea59efd..ad57f2b 100644 --- a/controller/internal/stacks/installed_test.go +++ b/controller/internal/stacks/installed_test.go @@ -515,3 +515,189 @@ volumes: t.Error("an unreadable file must be an ERROR — cannot-tell must never read as no-images") } } + +// --- GROUP G: the startup backfill (v0.234.0) --- +// +// v0.233.0 wrote the record only from the four bring-up paths, so an app nobody restarts showed no +// badge indefinitely. Found live on demo-felhom the day after the release: OpenGist, up 15 hours, +// running exactly what the catalog pins, and showing nothing. + +// newBackfillManager registers `names` as deployed stacks under one temp root. +func newBackfillManager(t *testing.T, specs map[string]string) (*Manager, map[string]string) { + t.Helper() + root := t.TempDir() + cfg := &config.Config{} + cfg.Paths.StacksDir = root + m := &Manager{ + cfg: cfg, logger: log.New(io.Discard, "", 0), composeCmd: "docker compose", + encKey: []byte("0123456789abcdef0123456789abcdef"), + stacks: map[string]*Stack{}, + } + dirs := map[string]string{} + for name, compose := range specs { + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("deployed: true\nenv: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + m.stacks[name] = &Stack{ + Name: name, Deployed: true, + ComposePath: filepath.Join(dir, "docker-compose.yml"), + AppConfig: LoadAppConfig(dir), + } + dirs[name] = dir + } + return m, dirs +} + +// TestGroupG_BackfillSeedsAnUntouchedApp is the case the operator reported: a deployed app that has +// simply been running, with no record and therefore no badge. +func TestGroupG_BackfillSeedsAnUntouchedApp(t *testing.T) { + m, dirs := newBackfillManager(t, map[string]string{ + "opengist": "services:\n opengist:\n image: ghcr.io/thomiceli/opengist:1.13\n", + }) + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"opengist","Service":"opengist"}`, + []fakeContainer{{id: "aaa111", ref: "ghcr.io/thomiceli/opengist:1.13", imageID: "sha256:og"}}, + map[string]string{"sha256:og": "ghcr.io/thomiceli/opengist@sha256:seeded"}) + + if n := m.BackfillInstalledImages(); n != 1 { + t.Fatalf("backfilled %d, want 1", n) + } + got := readInstalled(t, dirs["opengist"]).InstalledImages + if len(got) != 1 || got["opengist"].Digest != "sha256:seeded" { + t.Fatalf("app.yaml holds %+v", got) + } + // And the in-memory view, so the badge does not wait for the next ScanStacks. + if s, _ := m.GetStack("opengist"); s.AppConfig.InstalledImages["opengist"].Digest != "sha256:seeded" { + t.Error("the in-memory AppConfig must be seeded too") + } +} + +// TestGroupG_BackfillRefusesAPartialObservation is THE reason this is not a three-line loop. +// +// compareInstalledToTemplate reads a service-count mismatch as BEHIND. A degraded or crash-looping +// app has fewer live containers than its template has services, so seeding what can be seen would +// render „Frissítés elérhető" over an app that is perfectly current — a confident WRONG answer, +// which is worse than the silence it replaces. +// +// COMPANION RED-PROOF (run 2026-09-03): delete the `observationCoversTemplate` guard from +// BackfillInstalledImages. This test then fails with "backfilled 1, want 0" and the follow-up +// assertion shows a 1-of-2 record on disk — the exact shape that renders a false "update available". +// Reverted. +func TestGroupG_BackfillRefusesAPartialObservation(t *testing.T) { + m, dirs := newBackfillManager(t, map[string]string{ + "bookstack": threeServiceCompose, + }) + cs, digs := threeContainers() + // Only TWO of the three services are observable — the `cache` container is gone. + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"bookstack","Service":"web"} +{"ID":"bbb222","Name":"bookstack-db","Service":"db"}`, cs, digs) + + if n := m.BackfillInstalledImages(); n != 0 { + t.Fatalf("backfilled %d, want 0 — a partial observation must NOT be seeded", n) + } + if got := readInstalled(t, dirs["bookstack"]).InstalledImages; len(got) != 0 { + t.Fatalf("app.yaml must carry NO record rather than a partial one, got %+v", got) + } +} + +// TestGroupG_BackfillNeverOverwritesAnExistingRecord — the bring-up paths own updates; this only +// seeds absences. Overwriting would let a boot re-stamp a record the lifecycle paths had just moved. +func TestGroupG_BackfillNeverOverwritesAnExistingRecord(t *testing.T) { + m, dirs := newBackfillManager(t, map[string]string{ + "app": "services:\n web:\n image: nginx:1.27\n", + }) + existing := `deployed: true +env: {} +installed_images: + web: + ref: nginx:1.26 + digest: sha256:original + at: "2026-08-01T00:00:00Z" +` + if err := os.WriteFile(filepath.Join(dirs["app"], "app.yaml"), []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + m.stacks["app"].AppConfig = LoadAppConfig(dirs["app"]) + m.installedExecFn = func(context.Context, string, []string, string, ...string) (string, error) { + t.Fatal("an app that already has a record must not even be OBSERVED") + return "", nil + } + if n := m.BackfillInstalledImages(); n != 0 { + t.Fatalf("backfilled %d, want 0", n) + } + if got := readInstalled(t, dirs["app"]).InstalledImages["web"]; got.Digest != "sha256:original" || got.At != "2026-08-01T00:00:00Z" { + t.Fatalf("the existing record was disturbed: %+v", got) + } +} + +// TestGroupG_BackfillSkipsProtectedAndUndeployed — infra is not the customer's to update, and an +// undeployed template has nothing running to read. +func TestGroupG_BackfillSkipsProtectedAndUndeployed(t *testing.T) { + m, _ := newBackfillManager(t, map[string]string{ + "traefik": "services:\n traefik:\n image: traefik:v3\n", + "unused": "services:\n web:\n image: nginx:1.27\n", + }) + m.stacks["traefik"].Protected = true + m.stacks["unused"].Deployed = false + m.installedExecFn = func(context.Context, string, []string, string, ...string) (string, error) { + t.Fatal("neither a protected nor an undeployed stack may be observed") + return "", nil + } + if n := m.BackfillInstalledImages(); n != 0 { + t.Fatalf("backfilled %d, want 0", n) + } +} + +// TestGroupG_BackfillIsWiredAtStartup — the seam-discipline half. BackfillInstalledImages cannot be +// driven from this package's tests through main(), so the call is proven by walking the AST of the +// production entry point, NOT by a strings.Contains that a commented-out call would satisfy. +// +// It also asserts the ORDER against its sibling: both backfills run before the boot reconciler, so a +// just-recovered app is observed in its settled state. +func TestGroupG_BackfillIsWiredAtStartup(t *testing.T) { + src := filepath.Join("..", "..", "cmd", "controller", "main.go") + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, src, nil, 0) + if err != nil { + t.Skipf("cmd/controller is gitignored in some checkouts: %v", err) + } + var backfillPos, desiredPos, reconPos int + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch sel.Sel.Name { + case "BackfillInstalledImages": + backfillPos = fset.Position(call.Pos()).Line + case "BackfillDesiredState": + desiredPos = fset.Position(call.Pos()).Line + case "runBootReconcile": + if reconPos == 0 { + reconPos = fset.Position(call.Pos()).Line + } + } + return true + }) + if backfillPos == 0 { + t.Fatal("BackfillInstalledImages is never called from cmd/controller — a backfill nothing invokes seeds nothing") + } + if desiredPos == 0 || backfillPos <= desiredPos { + t.Errorf("the installed-images backfill (line %d) must run after the desired-state one (line %d)", backfillPos, desiredPos) + } + if reconPos != 0 && backfillPos > reconPos { + t.Errorf("the backfill (line %d) must run BEFORE the boot reconciler (line %d)", backfillPos, reconPos) + } +} diff --git a/controller/internal/web/updatebadge_test.go b/controller/internal/web/updatebadge_test.go index 791b15f..2439e47 100644 --- a/controller/internal/web/updatebadge_test.go +++ b/controller/internal/web/updatebadge_test.go @@ -1,6 +1,7 @@ package web import ( + "fmt" "strings" "testing" "time" @@ -175,11 +176,21 @@ func ubStacksData(st stacks.Stack) map[string]interface{} { // COMPANION RED-PROOF (run 2026-09-02): delete the {{template "meta_badge" (updateBadge …)}} line // from stacks.html and the "app list" sub-test fails; delete it from app_info.html and the "app page" // sub-test fails. Reverted. +// +// THE CLOCK, and why `catalog_since` is computed rather than written down: the templates call the +// funcmap entry `updateBadge`, which uses time.Now() — the injected `badgeNow` reaches only the pure +// tests. A hardcoded date plus a hardcoded age is therefore a test that passes on the day it is +// written and FAILS THE NEXT MORNING, which is exactly what this one did (written 2026-09-02 +// asserting "46 napja", red on 2026-09-03). Derive the date from the same clock the code will read. func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) { + const behindDays = 46 + since := time.Now().UTC().AddDate(0, 0, -behindDays).Format("2006-01-02") + wantBehind := fmt.Sprintf("Frissítés elérhető — %d napja", behindDays) + tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"} - behind := ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18") - current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18") - legacy := ubStack(nil, tpl, "2026-07-18") + behind := ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, since) + current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, since) + legacy := ubStack(nil, tpl, since) for _, surface := range []struct { name string @@ -191,7 +202,7 @@ func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) { } { t.Run(surface.name, func(t *testing.T) { h := renderBackupPage(t, surface.tmpl, surface.data(behind)) - if !strings.Contains(h, "Frissítés elérhető — 46 napja") { + if !strings.Contains(h, wantBehind) { t.Errorf("the behind badge is missing from %s", surface.tmpl) } h = renderBackupPage(t, surface.tmpl, surface.data(current))