From 38d28b5b624cd5f3d65566a25dc2a015a799c1f1 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 3 Sep 2026 11:56:43 +0200 Subject: [PATCH] v0.234.0: seed installed_images at startup, so the label appears on an app nobody touched The operator looked at demo-felhom the morning after v0.233.0 and found OpenGist - up 15 hours, running exactly the catalog pin - showing 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 indefinitely. On a quiet box that is every app, which is the box we most want to see. The known limitation WAS the feature not working. BackfillInstalledImages runs once at startup, beside BackfillDesiredState and before the boot reconciler. It READS containers: starts nothing, restarts nothing, writes no compose file. It never overwrites an existing record. And it REFUSES to seed a partial observation, which is why this is not a three-line loop: the badge reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible would render 'Frissites elerheto' over an app that is perfectly 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. Same data, two writers, two admission rules - deliberately. Also fixes a calendar bomb of mine: the render test hardcoded catalog_since and the string '46 napja', but the render path reads time.Now(), so it was green on the day it was written and red the next morning. Now derived. Filed as R-457 with six other candidate files named as unchecked, not accused. +5 tests (1724 -> 1729), 28 packages green. Red-proof of the partial guard run and reverted; the wiring and its ORDER pinned by an AST walk. --- CHANGELOG.md | 53 ++++++ CONTEXT.md | 25 ++- REUSE.md | 1 + controller/README.md | 7 + controller/cmd/controller/main.go | 12 ++ controller/internal/stacks/installed.go | 104 +++++++++++ controller/internal/stacks/installed_test.go | 186 +++++++++++++++++++ controller/internal/web/updatebadge_test.go | 19 +- 8 files changed, 402 insertions(+), 5 deletions(-) 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))