From 8025304acc0a67375bc10898d34b98d2f32d8f00 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 2 Sep 2026 20:18:01 +0200 Subject: [PATCH] v0.233.0: record what each compose service actually installed, and badge whether it is current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no auto-update, the three lifecycle buttons byte-identical. Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each entry carrying ref + repo digest + first-seen timestamp. Written by Manager.recordInstalledImages after a successful compose up from StartStack, RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never from docker-compose.yml: the syncer overwrites a deployed app's compose on a 15-minute cycle and the two disagreed for 25 minutes in the spike's own measurement. A failed write NEVER refuses the action - the deliberate opposite of SetDesiredState, because this is an observation and that is an intent. Not called from StartStackServices (the R-47 DB-only window). Its own docker seam with a context and a 30s timeout, which neither existing exec helper has. Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the recorded ref per service against what the current template pins and returns a *MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS. NO RECORD RENDERS NOTHING: absent means unknown and never means current. No version number reaches the customer and no registry is queried. Known limitation, filed not hidden: 23 catalog pins float, so those apps can read Naprakesz when the image behind the tag has moved. +17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real RestartStack plus an AST walk of the four call sites. Three companion red-proofs run and reverted. --- CHANGELOG.md | 81 +++ CONTEXT.md | 30 +- REUSE.md | 4 + controller/README.md | 58 ++ controller/internal/stacks/deploy.go | 40 +- controller/internal/stacks/installed.go | 438 +++++++++++++++ controller/internal/stacks/installed_test.go | 517 ++++++++++++++++++ controller/internal/stacks/manager.go | 44 +- controller/internal/stacks/metadata.go | 51 ++ controller/internal/web/funcmap.go | 6 + .../internal/web/templates/app_info.html | 1 + controller/internal/web/templates/stacks.html | 1 + controller/internal/web/updatebadge.go | 107 ++++ controller/internal/web/updatebadge_test.go | 268 +++++++++ 14 files changed, 1637 insertions(+), 9 deletions(-) create mode 100644 controller/internal/stacks/installed.go create mode 100644 controller/internal/stacks/installed_test.go create mode 100644 controller/internal/web/updatebadge.go create mode 100644 controller/internal/web/updatebadge_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39720dd..d27eae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,84 @@ +## 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 +reconciler are byte-identical. This adds a RECORD and a LABEL, because the behaviour work (slice 3) +is easier to judge once the fleet's real state is visible. Opened by +`felhom.eu/documentation/audits/SPIKE-app-update-2026-09-01.md`; the reasoning now lives in +`felhom.eu/documentation/architecture/09-update-architecture.md` (**R-438**, an absence that was itself +a finding). + +### Slice 1 — `app.yaml` gains `installed_images` + +`AppConfig.InstalledImages map[string]InstalledImage`, keyed by **compose SERVICE name**, each entry +carrying `ref`, `digest` and `at`. Written by `Manager.recordInstalledImages` +(new `controller/internal/stacks/installed.go`) after a successful compose up from `StartStack`, +`RestartStack`, `UpdateStack` and `runComposeDeploy`. + +- **Read from the CONTAINER, never from `docker-compose.yml`.** That file is the value that has + already moved — the syncer overwrites a deployed app's compose on a 15-minute cycle with no + deployed check, and the two disagreed for 25 minutes in the spike's own measurement. + `checkLocalImages` (a line scan of that file) is deliberately not reused. +- **A failed write NEVER refuses the action**, and that is the deliberate opposite of + `SetDesiredState`. Intent refused, observation logged: refusing to start a customer's app because a + note could not be written trades a real outage for a bookkeeping gap. One ERROR line, app stays up. +- **Not called from `StartStackServices`** — the R-47 DB-only window would overwrite a complete + record with a partial one. +- **An unchanged observation does not rewrite `app.yaml`** (the `SetDesiredState` rule — that file + holds encrypted secrets), and `at` is carried forward so it answers "running since", not + "last looked at". +- Its own docker seam, `Manager.installedExecFn`, with a **context and a 30 s timeout** — + `composeExecCustomEnv`/`execCommand` have neither, and REUSE.md's trap table says so. +- `ParseComposeImages` is a real yaml.v3 `services:` parse, never a line scan (immich's top-level + `immich_ml_cache:` has exactly the shape a scan misreads as a service). + +### Slice 2 — one Hungarian badge, and no version number anywhere + +`.felhom.yml` gains optional `catalog_since: "YYYY-MM-DD"` (`Metadata.CatalogSince`, backfilled across +all 53 catalog apps in `app-catalog-felhom.eu@69761cf`). `web.updateBadge` +(new `controller/internal/web/updatebadge.go`) compares the RECORDED reference per service against +what the CURRENT template pins, and returns a `*MetaBadge` rendered by the existing `meta_badge` +partial on `app_info.html` and `stacks.html` — **no new markup and no new CSS**, which is what +`metabadge.go`'s own comment asked of its second user. + +| state | badge | +|---|---| +| every service matches | „Naprakész" (`tag-ok`) | +| any service differs, age known | „Frissítés elérhető — N napja" (`tag-warn`) | +| any service differs, age unknown | „Frissítés elérhető" | +| **no record, or template unreadable** | **nothing rendered** | + +- **NO RECORD MEANS UNKNOWN AND NEVER MEANS CURRENT** — the R-166 lesson applied to an observation. + Every `app.yaml` written before this version has no entry, so a fall-through to „Naprakész" would + have told the whole fleet their months-old apps were current. Red-proved. +- **No version number reaches the customer** (operator ruling: a household cannot act on `26.05.2`). +- **No registry is queried** — a box must not need eight upstream registries to render a page. +- `catalog_since` is tolerant in the `lifecycle` style: absent, empty, malformed **or future-dated** + all degrade to a badge with no age plus one WARN. The future case is not pedantry — a box whose + clock lags the catalog would otherwise print „-3 napja". + +### Known limitation, stated rather than hidden + +For the **23 floating pins** (`postgres:16-alpine`, `mariadb:11.6`, …) the reference can be identical +while the image behind it has moved upstream — measured live in the spike §5, where `mariadb:11.4` and +`mariadb:12.3` had both already moved. **Those apps read „Naprakész" when they may not be.** Closing it +needs a registry query and a digest comparison; filed as a register row, not left implicit. + +### Tests + ++17 test functions (1707 → 1724). New: `controller/internal/stacks/installed_test.go`, +`controller/internal/web/updatebadge_test.go`. Full suite green, 28 packages, 0 FAIL. + +- **Wiring, through the REAL caller:** `TestGroupE_RestartStackReachesTheRecorder` drives + `RestartStack` end to end (only the compose and `docker ps` process boundaries are stubbed), and + `TestGroupE_EveryBringUpPathCallsTheRecorder` walks the **AST** of `manager.go`/`deploy.go` for the + four call sites — a `strings.Contains` would match a commented-out call, which is the exact shape of + the seam-built-but-never-wired class. +- **Companion red-proofs, all three run and reverted (2026-09-02):** (1) making a recording failure + refuse the action → `TestGroupC` fails with "restart must SUCCEED"; (2) the no-record case falling + through to `updateCurrent` → three sub-tests fail with a „Naprakész" badge on an app nobody measured; + (3) deleting the `{{template "meta_badge" (updateBadge …)}}` line from each template → the matching + render sub-test fails. + ## the decoy sweep — can this gate be fooled by a label? (2026-09-01, R-421) — NOT A RELEASE **No product code, no version bump, no image, no golden.** A scripts change is not a release. diff --git a/CONTEXT.md b/CONTEXT.md index 0bc122e..417518f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,35 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-01 (v0.232.0 — R-411/R-408/R-407 the lock family, R-414 reachability, R-412a wording) +Last updated: 2026-09-02 (v0.233.0 — update arc slices 1 & 2: the installed-images record + the update badge) + +> **2026-09-02 — v0.233.0. TWO DECISIONS, AND ONE LIMITATION THAT IS NOT A DEFECT.** +> +> **1. `installed_images` is an OBSERVATION, so a failed write NEVER refuses the action — deliberately +> the opposite of `desired_state`.** `SetDesiredState` refuses the act when the record fails, because +> intent that could not be recorded recreates the exact ambiguity R-166 closed. `recordInstalledImages` +> does the reverse: refusing to start a customer's app because we could not write down which version +> it is trades a real outage for a bookkeeping gap. **The rule is "refuse on intent, log on +> observation", and the reason is in both code comments** so neither gets "made consistent" later. +> +> **2. THE RECORD READS THE CONTAINER, NEVER THE COMPOSE FILE — and the file is not a lesser source, +> it is a WRONG one.** The catalog syncer overwrites a deployed app's `docker-compose.yml` on a +> 15-minute cycle with no deployed check at all; the spike measured the file saying `v2.8.5` while the +> container ran `v2.8.6` for 25 minutes. Anything derived from that file answers "what will happen +> next time something runs `up -d`", which is a different question from "what is running". +> +> **3. THE LIMITATION, STATED: 23 catalog pins FLOAT, so „Naprakész" CAN BE FALSE.** The comparison is +> reference-to-reference and queries no registry (a box must not need eight upstream registries to +> render a page). For `postgres:16-alpine`, `mariadb:11.6` and 21 others the ref can be identical while +> the image behind it has moved — the spike caught `mariadb:11.4` and `mariadb:12.3` already moved. +> **This is a known gap with a register row, not an oversight.** Digest-level comparison needs a +> registry query and is deferred. +> +> **Nothing about updating changed.** No behaviour, no new endpoint, no auto-update, and the three +> lifecycle buttons are byte-identical (`TestScenarioE_TheUpdateButtonIsUntouched`). The behaviour work +> is slice 3 and needs an operator ruling. The whole arc's reasoning now has a home: +> `felhom.eu/documentation/architecture/09-update-architecture.md` (R-438 — its absence was a finding). + > **2026-09-01 — v0.232.0. THREE RULINGS.** > diff --git a/REUSE.md b/REUSE.md index ddffff0..7003616 100644 --- a/REUSE.md +++ b/REUSE.md @@ -119,6 +119,9 @@ | `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` | | `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 | +| `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 | | `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates | | `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet | @@ -274,6 +277,7 @@ | `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) | | `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed | | `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart | +| `Manager.installedExecFn` (func seam, v0.233.0) | controller/internal/stacks/manager.go (field) + installed.go | nil → `defaultExecRunner` (`exec.CommandContext`, 30 s) | the installed-images recorder's ONLY process boundary — `docker compose ps` + `docker inspect` + `docker image inspect` | Deliberately NOT `execFn`/`composeExecCustomEnv`: those are already load-bearing elsewhere and **neither carries a context or a timeout**, and a bookkeeping read must never wedge a lifecycle action. Injected in controller/internal/stacks/installed_test.go, which FAILS the test on an argv it does not recognise, so a change to the commands issued cannot pass silently. The wiring test stubs the compose binary on PATH instead, so `RestartStack` is reached for real | | `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect ` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) | | `Manager.offboxStreamRunner` + `SetOffboxStreamRunner` | controller/internal/backup/offbox_progress.go | nil → `defaultOffboxStreamRunner` (real `restic`, stdout scanned live) | streaming sibling of `offboxRunner`; fakes emit canned `--json` status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo | | `Manager.offsitePreDumpFn` + `SetOffsitePreDumpFn` (R-44, v0.148.0) | controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) | nil → `runDBDumpsInternal` under the SAME running flag | THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields `[capture dump]` | diff --git a/controller/README.md b/controller/README.md index 0bfc58a..5160873 100644 --- a/controller/README.md +++ b/controller/README.md @@ -467,6 +467,64 @@ through them: `EffectiveLifecycle()`, `CanInstall()`, `IsAbandoned()`. `lifecycleBadge` funcmap entry. R-56's difficulty labels are intended as a sibling funcmap function returning the same `*MetaBadge` — no new markup or CSS. +#### What is installed, and is it current? (v0.233.0 — update arc slices 1 & 2) + +Two additions, and **neither changes how an update behaves**. Slice 1 is a record; slice 2 is a label. + +**Slice 1 — `app.yaml` gains `installed_images`.** After every successful `compose up` from +`StartStack`, `RestartStack`, `UpdateStack` and the deploy path, `Manager.recordInstalledImages` +(`internal/stacks/installed.go`) reads what each container is ACTUALLY running and writes it down, +**keyed by compose SERVICE name**: + +```yaml +installed_images: + web: + ref: lscr.io/linuxserver/bookstack:26.05.2 + digest: sha256:aaaa… # the only identifier that cannot move; "" if never pulled + at: "2026-09-02T18:41:03Z" # when this ref+digest was FIRST seen for this service +``` + +- **Read from the CONTAINER, never from `docker-compose.yml`.** That file is the value that has + already moved: the catalog syncer overwrites a deployed app's compose file on a 15-minute cycle + with no deployed check, and file and container can disagree indefinitely (measured live, + `SPIKE-app-update-2026-09-01` §3). `Manager.checkLocalImages` is a line scan of that file and is + deliberately NOT reused. +- **A failed write NEVER refuses the action** — the deliberate opposite of `SetDesiredState`. + `desired_state` is the customer's INTENT, so an act whose intent could not be recorded is refused; + `installed_images` is an OBSERVATION, and refusing to start an app because a note could not be + written would trade a real outage for a bookkeeping gap. Logged at ERROR and the app stays up. +- **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. +- 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. + +**Slice 2 — one badge, and no version number.** `.felhom.yml` gains optional +`catalog_since: "YYYY-MM-DD"` (`Metadata.CatalogSince` + `CatalogSinceAge`), the date the catalog last +moved that app's pins. `web.updateBadge` compares the recorded reference for each service against what +the current template pins and returns a `*MetaBadge` rendered by the existing `meta_badge` partial — +**no new markup, no new CSS**, which is exactly what `metabadge.go`'s comment asks of its second user. + +| state | badge | +|---|---| +| every service matches the template | „Naprakész" (`tag-ok`) | +| 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** | + +- **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. +- **No registry is queried.** A customer's box must not need eight upstream registries to render a + page. **Known limitation:** for the 23 floating pins (`postgres:16-alpine`, `mariadb:11.6`, …) the + reference can be identical while the image behind it has moved, so those apps can read „Naprakész" + when they may not be. Digest-level comparison needs a registry query and is deferred. +- **Information only.** The badge is wired to no action; `Frissítés`/`Újraindítás`/`Leállítás` are + byte-identical to before (`TestScenarioE_TheUpdateButtonIsUntouched`). + +Reasoning and the seven-slice plan: `felhom.eu/documentation/architecture/09-update-architecture.md`. + #### App Info Pages Each app can define rich metadata in `.felhom.yml`: diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 186a3a6..47f938c 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -121,6 +121,39 @@ type AppConfig struct { // the primitive would make a nightly backup indistinguishable from the customer pressing Stop, // which is the exact confusion this field exists to end. Writers: SetDesiredState's callers. DesiredState string `yaml:"desired_state,omitempty" json:"desired_state,omitempty"` + // InstalledImages records what each compose service is ACTUALLY RUNNING, read from the + // containers after a successful compose up — never from docker-compose.yml, which the catalog + // syncer overwrites on a 15-minute cycle with no deployed check at all (measured live: + // SPIKE-app-update-2026-09-01 §3, where the file said v2.8.5 while the container ran v2.8.6 for + // 25 minutes). The file is the value that has already moved; the container is the fact. + // + // Keyed by COMPOSE SERVICE NAME, not container name: the service name is what the compose file + // and the catalog template both key on, so it is the only key a comparison can be made against. + // + // ABSENT MEANS UNKNOWN AND NEVER MEANS CURRENT (the R-166 rule, applied to an observation + // instead of an intent). Every app.yaml written before v0.233.0 has no entry here, so absent is + // the common value on upgrade; a reader that treated it as "up to date" would tell every + // customer on the fleet that their months-old app is current. + // + // WRITTEN BY: Manager.recordInstalledImages ONLY, from StartStack / RestartStack / UpdateStack + // and the deploy path. READ BY: web.updateBadge (v0.233.0). Nothing takes a DECISION from it. + InstalledImages map[string]InstalledImage `yaml:"installed_images,omitempty" json:"installed_images,omitempty"` +} + +// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages. +type InstalledImage struct { + // Ref is the reference the container was created FROM, i.e. docker inspect .Config.Image — + // e.g. "lscr.io/linuxserver/bookstack:26.05.2". This is what the template pins and what the + // comparison uses. + Ref string `yaml:"ref" json:"ref"` + // Digest is the repo digest of the image behind that reference — the only identifier that + // cannot move. Empty for an image that was never pulled from a registry (a locally built or + // imported image has no RepoDigests); an empty digest is recorded, never a skipped entry. + Digest string `yaml:"digest,omitempty" json:"digest,omitempty"` + // At is RFC3339 UTC: when this exact Ref+Digest pair was FIRST observed for this service. It is + // deliberately NOT re-stamped on every restart — an unchanged observation must not rewrite + // app.yaml (the SetDesiredState rule), and "running since" is more useful than "last looked at". + At string `yaml:"at" json:"at"` } // DeployRequest contains the user-provided values from the deploy form. @@ -443,8 +476,13 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, } m.mu.Unlock() - // Post-deploy container state check (async, non-blocking) + // Record what this deploy actually installed, per compose service (v0.233.0). Runs AFTER the + // SaveAppConfig above so it loads an app.yaml that already reads deployed=true. A failure here + // never fails the deploy — see recordInstalledImages. deployEnv := m.stackEnv(stackDir) + m.recordInstalledImages(name, stackDir, deployEnv) + + // Post-deploy container state check (async, non-blocking) m.logPostStartStatus(name, stackDir, deployEnv) _ = m.RefreshStatus() diff --git a/controller/internal/stacks/installed.go b/controller/internal/stacks/installed.go new file mode 100644 index 0000000..79da45a --- /dev/null +++ b/controller/internal/stacks/installed.go @@ -0,0 +1,438 @@ +package stacks + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// installedRecordTimeout bounds every docker call this file makes. +// +// REUSE.md's trap table says it in terms: composeExecCustomEnv and execCommand have NO context and +// NO timeout, so a hung docker CLI blocks forever. That is tolerable for the compose `up` a customer +// is waiting on; it is NOT tolerable here, because this runs AFTER every successful start, restart, +// update and deploy purely to write a note down. A bookkeeping read must never be able to wedge a +// lifecycle action. Three short reads share this budget generously. +const installedRecordTimeout = 30 * time.Second + +// execRunner is this file's process boundary — the one seam the installed-images recorder uses. +// +// It is deliberately its OWN seam rather than Manager.execFn / composeExecCustomEnv: those two are +// already load-bearing for refreshStatusLocked and for the compose lifecycle, and both lack the +// context this code needs. dir "" means "do not chdir"; env nil means inherit os.Environ(). +// nil in production (defaultExecRunner); tests script argv → output and never touch docker. +type execRunner func(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) + +func defaultExecRunner(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + if env != nil { + cmd.Env = env + } + out, err := cmd.Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = truncateStr(string(ee.Stderr), 500) + } + return string(out), fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr) + } + return string(out), nil +} + +func (m *Manager) runInstalled(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) { + if m.installedExecFn != nil { + return m.installedExecFn(ctx, dir, env, name, args...) + } + return defaultExecRunner(ctx, dir, env, name, args...) +} + +// composeArgv splits the configured compose command into an argv prefix, mirroring +// composeExecCustomEnv's own docker-compose-v1-vs-v2 branch. One rule, two callers. +func (m *Manager) composeArgv(args ...string) (string, []string) { + if m.composeCmd == "docker compose" { + return "docker", append([]string{"compose"}, args...) + } + return "docker-compose", args +} + +// --- The template side: what the compose FILE currently pins, per service --- + +// composeImagesDoc is the minimal view of a compose file needed here. +// +// A REAL YAML parse and never a line scan, for the reason dbservices.go's composeServicesDoc already +// records: immich's top-level `immich_ml_cache:` volume key has exactly the shape a naive scan +// misreads as a service. Manager.checkLocalImages IS such a line scan and is deliberately not reused +// — it also cannot say which service an image belongs to, which is the whole comparison. +type composeImagesDoc struct { + Services map[string]struct { + Image string `yaml:"image"` + } `yaml:"services"` +} + +// ParseComposeImages returns compose SERVICE name -> the image reference the file pins for it. +// +// Services with no `image:` (a `build:`-only service — none in the catalog today) are omitted rather +// than recorded as an empty pin, because "" would compare equal to nothing useful. An unreadable or +// unparseable file returns an error: CANNOT-TELL must never read as "no images", or an app whose +// compose file is briefly mid-write would render as up to date. +func ParseComposeImages(composePath string) (map[string]string, error) { + data, err := os.ReadFile(composePath) + if err != nil { + return nil, fmt.Errorf("reading compose file: %w", err) + } + var doc composeImagesDoc + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) + } + out := make(map[string]string, len(doc.Services)) + for svc, def := range doc.Services { + if strings.TrimSpace(def.Image) == "" { + continue + } + out[svc] = strings.TrimSpace(def.Image) + } + return out, nil +} + +// --- The container side: what is ACTUALLY running --- + +// composePSEntry is the subset of `docker compose ps --format json` this needs. +type composePSEntry struct { + ID string `json:"ID"` + Name string `json:"Name"` + Service string `json:"Service"` +} + +// parseComposePS tolerates BOTH shapes compose v2 has emitted for `ps --format json`: a single JSON +// array (compose < 2.21) and newline-delimited objects (2.21+). Neither shape is guessed at from a +// version string — the output is tried as an array first and falls back to per-line objects, so an +// upgrade of the docker CLI underneath a customer's box cannot silently stop the recording. +func parseComposePS(out string) ([]composePSEntry, error) { + trimmed := strings.TrimSpace(out) + if trimmed == "" { + return nil, nil + } + if strings.HasPrefix(trimmed, "[") { + var arr []composePSEntry + if err := json.Unmarshal([]byte(trimmed), &arr); err != nil { + return nil, fmt.Errorf("parsing compose ps JSON array: %w", err) + } + return arr, nil + } + var entries []composePSEntry + for _, line := range strings.Split(trimmed, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var e composePSEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + return nil, fmt.Errorf("parsing compose ps JSON line: %w", err) + } + entries = append(entries, e) + } + return entries, nil +} + +// containerFacts is one container's two identifiers as docker reports them. +type containerFacts struct { + ref string // .Config.Image — the reference the container was CREATED FROM + imageID string // .Image — the local image id it actually resolved to +} + +const inspectSep = "\x1f" // ASCII unit separator: cannot occur in an image ref or an id + +// observeInstalledImages reads what every compose service of this stack is running. +// +// Three short docker reads, in order: which containers belong to which service; what reference and +// image id each container carries; and what repo digest each of those images has. Nothing is read +// from docker-compose.yml — that file is the value the syncer has already moved. +func (m *Manager) observeInstalledImages(stackDir string, env []string) (map[string]InstalledImage, error) { + ctx, cancel := context.WithTimeout(context.Background(), installedRecordTimeout) + defer cancel() + + bin, argv := m.composeArgv("ps", "-a", "--format", "json") + psOut, err := m.runInstalled(ctx, stackDir, env, bin, argv...) + if err != nil { + return nil, fmt.Errorf("listing containers: %w", err) + } + entries, err := parseComposePS(psOut) + if err != nil { + return nil, err + } + + // service -> container id, deterministic when a service has replicas (none in the catalog, but + // a compose `deploy.replicas` would produce several; take the first by name so two runs agree). + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + svcContainer := make(map[string]string) + var ids []string + for _, e := range entries { + if e.Service == "" || e.ID == "" { + continue + } + if _, seen := svcContainer[e.Service]; seen { + continue + } + svcContainer[e.Service] = e.ID + ids = append(ids, e.ID) + } + if len(ids) == 0 { + return map[string]InstalledImage{}, nil + } + + facts, err := m.inspectContainers(ctx, ids) + if err != nil { + return nil, err + } + digests, err := m.inspectImageDigests(ctx, facts) + if err != nil { + // A missing digest is a recorded empty string, never a failed recording — see the edge-case + // table. Log and carry on with refs only. + m.logger.Printf("[WARN] [stacks] installed-images: reading repo digests failed, recording refs only: %v", err) + digests = map[string]string{} + } + + now := time.Now().UTC().Format(time.RFC3339) + out := make(map[string]InstalledImage, len(svcContainer)) + for svc, id := range svcContainer { + f, ok := facts[id] + if !ok { + continue + } + out[svc] = InstalledImage{ + Ref: f.ref, + Digest: pickDigest(f.ref, digests[f.imageID]), + At: now, + } + } + return out, nil +} + +func (m *Manager) inspectContainers(ctx context.Context, ids []string) (map[string]containerFacts, error) { + args := append([]string{"inspect", "--type", "container", + "--format", "{{.Id}}" + inspectSep + "{{.Config.Image}}" + inspectSep + "{{.Image}}"}, ids...) + out, err := m.runInstalled(ctx, "", nil, "docker", args...) + if err != nil { + return nil, fmt.Errorf("inspecting containers: %w", err) + } + facts := make(map[string]containerFacts, len(ids)) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + parts := strings.Split(strings.TrimSpace(line), inspectSep) + if len(parts) != 3 { + continue + } + facts[parts[0]] = containerFacts{ref: parts[1], imageID: parts[2]} + // docker accepts short ids on the way in and returns full ones on the way out; key both so + // the caller's compose-ps id (12 hex) finds its row. + if len(parts[0]) > 12 { + facts[parts[0][:12]] = containerFacts{ref: parts[1], imageID: parts[2]} + } + } + return facts, nil +} + +// inspectImageDigests maps image id -> its RepoDigests, joined by a space. Empty when an image has +// none (built or imported locally, never pulled) — recorded as an empty digest, not as a failure. +func (m *Manager) inspectImageDigests(ctx context.Context, facts map[string]containerFacts) (map[string]string, error) { + seen := map[string]bool{} + var imgs []string + for _, f := range facts { + if f.imageID != "" && !seen[f.imageID] { + seen[f.imageID] = true + imgs = append(imgs, f.imageID) + } + } + if len(imgs) == 0 { + return map[string]string{}, nil + } + sort.Strings(imgs) + args := append([]string{"image", "inspect", + "--format", "{{.Id}}" + inspectSep + "{{range $i, $d := .RepoDigests}}{{if $i}} {{end}}{{$d}}{{end}}"}, imgs...) + out, err := m.runInstalled(ctx, "", nil, "docker", args...) + if err != nil { + return nil, fmt.Errorf("inspecting images: %w", err) + } + digests := make(map[string]string, len(imgs)) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), inspectSep, 2) + if len(parts) != 2 { + continue + } + digests[parts[0]] = strings.TrimSpace(parts[1]) + } + return digests, nil +} + +// pickDigest turns a RepoDigests list into the ONE sha256 that belongs to the ref we asked for. +// +// A local image can carry several repo digests (the same bytes tagged from two registries), and +// picking the wrong one would record a digest for a repository this app never used. Match on the +// repository part of the ref first; fall back to the single entry when there is exactly one; give up +// (empty) rather than guess between several unrelated ones. +func pickDigest(ref, repoDigests string) string { + fields := strings.Fields(repoDigests) + if len(fields) == 0 { + return "" + } + repo := refRepository(ref) + for _, rd := range fields { + at := strings.LastIndex(rd, "@") + if at < 0 { + continue + } + if repo != "" && rd[:at] == repo { + return rd[at+1:] + } + } + if len(fields) == 1 { + if at := strings.LastIndex(fields[0], "@"); at >= 0 { + return fields[0][at+1:] + } + } + return "" +} + +// refRepository strips the tag and/or digest from an image reference, leaving the repository. +// Careful with a registry port ("registry:5000/app:1.2"): only a colon AFTER the last slash is a tag. +func refRepository(ref string) string { + if at := strings.LastIndex(ref, "@"); at >= 0 { + ref = ref[:at] + } + slash := strings.LastIndex(ref, "/") + if colon := strings.LastIndex(ref, ":"); colon > slash { + ref = ref[:colon] + } + return ref +} + +// --- The write --- + +// sameInstalled compares two records on Ref and Digest ONLY, deliberately ignoring At. +// Including At would make every restart a change, and app.yaml would be rewritten — with its +// encrypted secrets — on every lifecycle action for no new information. +func sameInstalled(a, b map[string]InstalledImage) bool { + if len(a) != len(b) { + return false + } + for k, va := range a { + vb, ok := b[k] + if !ok || va.Ref != vb.Ref || va.Digest != vb.Digest { + return false + } + } + return true +} + +// recordInstalledImages writes what this stack is ACTUALLY running into its app.yaml. +// +// ── WHY A FAILURE HERE NEVER REFUSES THE ACTION ────────────────────────────────────────────── +// +// This is deliberately the OPPOSITE of SetDesiredState, and the difference is what the field means. +// `desired_state` is the customer's INTENT: performing an act whose intent could not be recorded +// recreates exactly the ambiguity R-166 closed, so a failed write there correctly refuses the act. +// `installed_images` is an OBSERVATION. Refusing to start a customer's app because we could not write +// down which version it is would trade a real outage for a bookkeeping gap. So: log at ERROR, loudly, +// naming the app — and return. The app stays up. +// +// Called after a SUCCESSFUL compose up from StartStack, RestartStack, UpdateStack and +// runComposeDeploy. NOT from StartStackServices: that path starts only the database service for the +// R-47 restore window, and recording a partial stack there would overwrite a complete record with an +// incomplete one. +func (m *Manager) recordInstalledImages(name, stackDir string, env []string) { + cfg := LoadAppConfig(stackDir) + if cfg == nil { + // No app.yaml: an infra/protected stack, or nothing deployed here. Nothing to record on. + if m.isDebug() { + m.logger.Printf("[DEBUG] [stacks] installed-images %s: no app.yaml — nothing to record", name) + } + return + } + + observed, err := m.observeInstalledImages(stackDir, env) + if err != nil { + m.logger.Printf("[ERROR] [stacks] installed-images %s: could not observe running images (the app is unaffected): %v", name, err) + return + } + if len(observed) == 0 { + m.logger.Printf("[WARN] [stacks] installed-images %s: no containers observed — nothing recorded, previous record left intact", name) + return + } + + // A partial read is recorded AND said out loud, never written silently: a record that quietly + // lost a service would read as a complete answer to "what is this app running". + if tpl, terr := ParseComposeImages(filepath.Join(stackDir, "docker-compose.yml")); terr == nil && len(tpl) > len(observed) { + var missing []string + for svc := range tpl { + if _, ok := observed[svc]; !ok { + missing = append(missing, svc) + } + } + sort.Strings(missing) + m.logger.Printf("[WARN] [stacks] installed-images %s: recorded %d of %d compose service(s) — not observed: %s", + name, len(observed), len(tpl), strings.Join(missing, ", ")) + } + + // Carry forward the first-seen timestamp of every entry whose ref+digest is unchanged, so `at` + // answers "running since" rather than "last looked at". + for svc, prev := range cfg.InstalledImages { + if cur, ok := observed[svc]; ok && cur.Ref == prev.Ref && cur.Digest == prev.Digest && prev.At != "" { + cur.At = prev.At + observed[svc] = cur + } + } + + if sameInstalled(cfg.InstalledImages, observed) { + if m.isDebug() { + m.logger.Printf("[DEBUG] [stacks] installed-images %s: unchanged (%d service(s)) — app.yaml not rewritten", name, len(observed)) + } + return + } + + cfg.InstalledImages = observed + meta := LoadMetadata(stackDir) + if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { + m.logger.Printf("[ERROR] [stacks] installed-images %s: recording failed, the app is running and unaffected: %v", name, err) + return + } + m.logger.Printf("[INFO] [stacks] installed-images %s: recorded %d service(s) (%s)", name, len(observed), summariseInstalled(observed)) + + // Keep the in-memory view in step so the badge does not lag a full ScanStacks behind the file. + m.mu.Lock() + if s, ok := m.stacks[name]; ok && s.AppConfig != nil { + s.AppConfig.InstalledImages = observed + } + m.mu.Unlock() +} + +// summariseInstalled renders the record for ONE log line. Image refs and digests only — this file +// never logs anything out of app.yaml's env map, which holds encrypted secrets. +func summariseInstalled(m map[string]InstalledImage) string { + svcs := make([]string, 0, len(m)) + for svc := range m { + svcs = append(svcs, svc) + } + sort.Strings(svcs) + parts := make([]string, 0, len(svcs)) + for _, svc := range svcs { + d := m[svc].Digest + if len(d) > 19 { + d = d[:19] + "…" + } + if d == "" { + d = "no digest" + } + parts = append(parts, fmt.Sprintf("%s=%s (%s)", svc, m[svc].Ref, d)) + } + return strings.Join(parts, ", ") +} diff --git a/controller/internal/stacks/installed_test.go b/controller/internal/stacks/installed_test.go new file mode 100644 index 0000000..ea59efd --- /dev/null +++ b/controller/internal/stacks/installed_test.go @@ -0,0 +1,517 @@ +package stacks + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gopkg.in/yaml.v3" +) + +// Slice 1 (v0.233.0) — the box writes down what it ACTUALLY installed. +// +// Every assertion here reads app.yaml BACK OFF DISK and checks the entries, their count and their +// digests. "recordInstalledImages returned" proves nothing: the whole feature is a durable record. + +// --- the docker seam --- + +// fakeContainer is one scripted container: what `docker inspect` will say about it. +type fakeContainer struct { + id string + ref string + imageID string +} + +// scriptedInstalledDocker returns an execRunner that answers the recorder's three reads from canned data and +// never touches a daemon. It fails the test on an argv it does not recognise, so a change to the +// commands the recorder issues cannot pass silently. +func scriptedInstalledDocker(t *testing.T, psOut string, containers []fakeContainer, digests map[string]string) execRunner { + t.Helper() + return func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) { + // Accept BOTH compose spellings — composeArgv emits `docker compose ps` or `docker-compose + // ps` depending on the configured command, and the wiring tests use the latter. + if name == "docker" && len(args) >= 1 && args[0] == "compose" { + args = args[1:] + name = "docker-compose" + } + switch { + case name == "docker-compose" && len(args) >= 1 && args[0] == "ps": + return psOut, nil + case name == "docker" && len(args) >= 1 && args[0] == "inspect": + var b strings.Builder + for _, want := range args { + for _, c := range containers { + if c.id == want { + fmt.Fprintf(&b, "%s%s%s%s%s\n", c.id, inspectSep, c.ref, inspectSep, c.imageID) + } + } + } + return b.String(), nil + case name == "docker" && len(args) >= 2 && args[0] == "image" && args[1] == "inspect": + var b strings.Builder + for _, want := range args { + if d, ok := digests[want]; ok { + fmt.Fprintf(&b, "%s%s%s\n", want, inspectSep, d) + } + } + return b.String(), nil + } + t.Fatalf("unexpected command in test: %s %v", name, args) + return "", nil + } +} + +const threeServiceCompose = `services: + web: + image: lscr.io/linuxserver/bookstack:26.05.2 + db: + image: mariadb:12.3 + cache: + image: redis:7-alpine +volumes: + bookstack_config: +` + +// newInstalledManager builds a Manager over one real stack directory. Real FS, because the thing +// under test is a file write. +func newInstalledManager(t *testing.T, compose, appYAML string) (*Manager, string) { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, "bookstack") + 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 appYAML != "" { + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil { + t.Fatal(err) + } + } + 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{ + "bookstack": {Name: "bookstack", ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true}, + }, + } + // Mirror ScanStacks: the in-memory stack carries the loaded app.yaml and the template's pins. + m.stacks["bookstack"].AppConfig = LoadAppConfig(dir) + if imgs, err := ParseComposeImages(filepath.Join(dir, "docker-compose.yml")); err == nil { + m.stacks["bookstack"].TemplateImages = imgs + } + return m, dir +} + +func readInstalled(t *testing.T, dir string) *AppConfig { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "app.yaml")) + if err != nil { + t.Fatal(err) + } + cfg := &AppConfig{} + if err := yaml.Unmarshal(b, cfg); err != nil { + t.Fatal(err) + } + return cfg +} + +const ndjsonPS = `{"ID":"aaa111","Name":"bookstack","Service":"web"} +{"ID":"bbb222","Name":"bookstack-db","Service":"db"} +{"ID":"ccc333","Name":"bookstack-cache","Service":"cache"}` + +func threeContainers() ([]fakeContainer, map[string]string) { + return []fakeContainer{ + {id: "aaa111", ref: "lscr.io/linuxserver/bookstack:26.05.2", imageID: "sha256:img-web"}, + {id: "bbb222", ref: "mariadb:12.3", imageID: "sha256:img-db"}, + {id: "ccc333", ref: "redis:7-alpine", imageID: "sha256:img-cache"}, + }, map[string]string{ + "sha256:img-web": "lscr.io/linuxserver/bookstack@sha256:aaaaaaaa", + "sha256:img-db": "mariadb@sha256:bbbbbbbb", + "sha256:img-cache": "redis@sha256:cccccccc", + } +} + +// --- GROUP A: one entry PER COMPOSE SERVICE, with digests --- + +// TestGroupA_RecordsOneEntryPerService is the case that matters: a MULTI-container app. The wrong +// implementation records one entry for the whole stack, and it would pass any single-service test. +func TestGroupA_RecordsOneEntryPerService(t *testing.T) { + m, dir := newInstalledManager(t, threeServiceCompose, "deployed: true\nenv: {}\n") + cs, digs := threeContainers() + m.installedExecFn = scriptedInstalledDocker(t, ndjsonPS, cs, digs) + + before := time.Now().UTC().Add(-time.Second) + m.recordInstalledImages("bookstack", dir, nil) + + got := readInstalled(t, dir).InstalledImages + if len(got) != 3 { + t.Fatalf("recorded %d entries, want ONE PER COMPOSE SERVICE (3): %+v", len(got), got) + } + want := map[string][2]string{ + "web": {"lscr.io/linuxserver/bookstack:26.05.2", "sha256:aaaaaaaa"}, + "db": {"mariadb:12.3", "sha256:bbbbbbbb"}, + "cache": {"redis:7-alpine", "sha256:cccccccc"}, + } + for svc, w := range want { + e, ok := got[svc] + if !ok { + t.Fatalf("service %q missing — entries must be keyed by COMPOSE SERVICE NAME, got %+v", svc, got) + } + if e.Ref != w[0] { + t.Errorf("%s ref = %q, want %q", svc, e.Ref, w[0]) + } + if e.Digest != w[1] { + t.Errorf("%s digest = %q, want %q — the digest is the only identifier that cannot lie", svc, e.Digest, w[1]) + } + ts, err := time.Parse(time.RFC3339, e.At) + if err != nil { + t.Errorf("%s at = %q, not RFC3339: %v", svc, e.At, err) + } else if ts.Before(before) { + t.Errorf("%s at = %v, older than the run that produced it", svc, ts) + } + } + // The record must NOT have been assembled from the compose file: prove it by checking the + // deployed marker survived the copy-and-overlay save. + if !readInstalled(t, dir).Deployed { + t.Error("the save dropped deployed=true — SaveAppConfig must stay copy-and-overlay") + } +} + +// TestGroupA_ImageWithNoRepoDigestRecordsAnEmptyDigest — a locally built or imported image has no +// RepoDigests. The entry is still recorded, with an empty digest: skipping it would silently lose a +// service from the record. +func TestGroupA_ImageWithNoRepoDigestRecordsAnEmptyDigest(t *testing.T) { + m, dir := newInstalledManager(t, "services:\n web:\n image: local/built:dev\n", "deployed: true\nenv: {}\n") + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"w","Service":"web"}`, + []fakeContainer{{id: "aaa111", ref: "local/built:dev", imageID: "sha256:local"}}, + map[string]string{"sha256:local": ""}) + + m.recordInstalledImages("app", dir, nil) + got := readInstalled(t, dir).InstalledImages + if len(got) != 1 { + t.Fatalf("an image with no repo digest must still be RECORDED, got %+v", got) + } + if got["web"].Ref != "local/built:dev" || got["web"].Digest != "" { + t.Fatalf("want ref recorded and digest empty, got %+v", got["web"]) + } +} + +// TestGroupA_MissingContainerRecordsWhatExists — the edge-case table: record what is there, and say +// the count out loud. A partial record written silently would read as a complete answer. +func TestGroupA_MissingContainerRecordsWhatExists(t *testing.T) { + var logs strings.Builder + m, dir := newInstalledManager(t, threeServiceCompose, "deployed: true\nenv: {}\n") + m.logger = log.New(&logs, "", 0) + cs, digs := threeContainers() + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"bookstack","Service":"web"} +{"ID":"bbb222","Name":"bookstack-db","Service":"db"}`, cs, digs) + + m.recordInstalledImages("bookstack", dir, nil) + got := readInstalled(t, dir).InstalledImages + if len(got) != 2 { + t.Fatalf("want the 2 observed services recorded, got %+v", got) + } + if !strings.Contains(logs.String(), "recorded 2 of 3") || !strings.Contains(logs.String(), "cache") { + t.Fatalf("a partial read must be said out loud, naming what is missing. Log was:\n%s", logs.String()) + } +} + +// --- GROUP B: the record follows the CONTAINER, not the file --- + +// TestGroupB_RecordFollowsTheContainerNotTheFile is the reason this feature exists. The compose file +// and the running container can disagree indefinitely (measured: SPIKE §3 — 25 minutes). Here the +// FILE says one thing and the CONTAINER another; the record must carry the container's answer. +// +// It also pins the re-record half of Scenario B: an existing record for the OLD image is replaced, +// not left standing. A record that goes stale is worse than none, because it will be trusted. +func TestGroupB_RecordFollowsTheContainerNotTheFile(t *testing.T) { + const old = `deployed: true +env: {} +installed_images: + web: + ref: ghcr.io/alam00000/bentopdf:v2.8.5 + digest: sha256:oldoldold + at: "2026-09-01T17:36:35Z" +` + // The FILE pins v2.8.5 — exactly the post-sync state the spike measured. + m, dir := newInstalledManager(t, "services:\n web:\n image: ghcr.io/alam00000/bentopdf:v2.8.5\n", old) + // The CONTAINER runs v2.8.6. + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"bentopdf","Service":"web"}`, + []fakeContainer{{id: "aaa111", ref: "ghcr.io/alam00000/bentopdf:v2.8.6", imageID: "sha256:new"}}, + map[string]string{"sha256:new": "ghcr.io/alam00000/bentopdf@sha256:newnewnew"}) + + m.recordInstalledImages("bentopdf", dir, nil) + + got := readInstalled(t, dir).InstalledImages["web"] + if got.Ref != "ghcr.io/alam00000/bentopdf:v2.8.6" { + t.Fatalf("ref = %q — the record must read the CONTAINER; the file is the value that has already moved", got.Ref) + } + if got.Digest != "sha256:newnewnew" { + t.Fatalf("digest = %q, want the new one — a record that goes stale is worse than none", got.Digest) + } + if got.At == "2026-09-01T17:36:35Z" { + t.Fatal("`at` must be re-stamped when the image CHANGES") + } +} + +// TestGroupB_UnchangedObservationDoesNotRewriteAppYAML — the SetDesiredState rule. app.yaml holds +// encrypted secrets; rewriting it on every restart for no new information is pure risk. `at` is +// therefore also carried forward, so it answers "running since" and not "last looked at". +func TestGroupB_UnchangedObservationDoesNotRewriteAppYAML(t *testing.T) { + const same = `deployed: true +env: {} +installed_images: + web: + ref: nginx:1.27 + digest: sha256:keepme + at: "2026-08-01T00:00:00Z" +` + m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", same) + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"n","Service":"web"}`, + []fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}}, + map[string]string{"sha256:i": "nginx@sha256:keepme"}) + + path := filepath.Join(dir, "app.yaml") + st0, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + m.recordInstalledImages("app", dir, nil) + st1, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !st0.ModTime().Equal(st1.ModTime()) || st0.Size() != st1.Size() { + t.Error("an unchanged observation must not rewrite app.yaml") + } + if got := readInstalled(t, dir).InstalledImages["web"].At; got != "2026-08-01T00:00:00Z" { + t.Errorf("at = %q — the first-seen timestamp must be carried forward, not re-stamped", got) + } +} + +// --- GROUP C: recording fails, the ACTION still succeeds --- + +// TestGroupC_UnwritableAppYAMLDoesNotFailTheAction is the deliberate opposite of SetDesiredState. +// `desired_state` is INTENT and a failed write correctly refuses the act. `installed_images` is an +// OBSERVATION: refusing to restart a customer's app because we could not write down which version it +// is would trade a real outage for a bookkeeping gap. +// +// COMPANION RED-PROOF (run 2026-09-02): give recordInstalledImages an `error` return and make +// RestartStack `return` it on failure. This test then fails with "restart must SUCCEED" — i.e. the +// customer's app refuses to start because a note could not be written. Reverted. +func TestGroupC_UnwritableAppYAMLDoesNotFailTheAction(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root ignores directory permissions — this test cannot make a write fail") + } + var logs strings.Builder + m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n") + m.logger = log.New(&logs, "", 0) + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"n","Service":"web"}`, + []fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}}, + map[string]string{"sha256:i": "nginx@sha256:d"}) + withFakeCompose(t, m) + + // Read-only stack dir: SaveAppConfig's tmp+rename cannot create its temp file. + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + if err := m.RestartStack("bookstack"); err != nil { + t.Fatalf("restart must SUCCEED even when the record cannot be written: %v", err) + } + out := logs.String() + if !strings.Contains(out, "[ERROR]") || !strings.Contains(out, "installed-images bookstack") { + t.Fatalf("the failure must be logged at ERROR, naming the app. Log was:\n%s", out) + } + if !strings.Contains(out, "unaffected") { + t.Errorf("the ERROR line should say the app is unaffected, so it is not read as an outage. Log was:\n%s", out) + } +} + +// --- GROUP E: the WIRING — reached through the REAL caller --- + +// withFakeCompose puts a stub `docker-compose` on PATH and points the manager at it, so a REAL +// RestartStack can run to completion without a docker daemon. It is the compose process boundary +// that is faked, not the recorder — the recorder is reached exactly as production reaches it. +func withFakeCompose(t *testing.T, m *Manager) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("the stub compose binary is a shell script") + } + bin := t.TempDir() + script := "#!/bin/sh\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + m.composeCmd = "docker-compose" + // refreshStatusLocked's `docker ps` — the OTHER, pre-existing seam. + m.execFn = func(string, ...string) (string, error) { return "", nil } +} + +// TestGroupE_RestartStackReachesTheRecorder is the seam-discipline test. Three shipped defects in +// three days were injected-seam tests that proved a component whose caller never invoked it, so at +// least one test must reach recordInstalledImages through a REAL production caller. RestartStack is +// invoked here in full; only the compose and `docker ps` process boundaries are stubbed. +func TestGroupE_RestartStackReachesTheRecorder(t *testing.T) { + m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n") + m.installedExecFn = scriptedInstalledDocker(t, + `{"ID":"aaa111","Name":"n","Service":"web"}`, + []fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}}, + map[string]string{"sha256:i": "nginx@sha256:wired"}) + withFakeCompose(t, m) + + if err := m.RestartStack("bookstack"); err != nil { + t.Fatalf("restart: %v", err) + } + got := readInstalled(t, dir).InstalledImages + if len(got) != 1 || got["web"].Digest != "sha256:wired" { + t.Fatalf("RestartStack did not reach the recorder — app.yaml holds %+v", got) + } + // And the in-memory view is in step, so the badge does not lag a ScanStacks behind the file. + if s, ok := m.GetStack("bookstack"); !ok || s.AppConfig == nil || s.AppConfig.InstalledImages["web"].Digest != "sha256:wired" { + t.Error("the in-memory AppConfig must be updated too") + } +} + +// TestGroupE_EveryBringUpPathCallsTheRecorder walks the AST of the production sources for the four +// paths that cannot each be driven to completion from a unit test. +// +// An AST walk, NOT a strings.Contains: a commented-out call still contains the string, and that is +// exactly the shape a "seam built but never wired" defect takes. It also asserts the NEGATIVE — +// StartStackServices must NOT call it, because that path starts only the database service for the +// R-47 window and would overwrite a complete record with an incomplete one. +func TestGroupE_EveryBringUpPathCallsTheRecorder(t *testing.T) { + callers := map[string]bool{} // enclosing func name -> calls recordInstalledImages + fset := token.NewFileSet() + for _, src := range []string{"manager.go", "deploy.go"} { + f, err := parser.ParseFile(fset, src, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok { + continue + } + found := false + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "recordInstalledImages" { + found = true + } + return true + }) + if found { + callers[fn.Name.Name] = true + } + } + } + for _, want := range []string{"StartStack", "RestartStack", "UpdateStack", "runComposeDeploy"} { + if !callers[want] { + t.Errorf("%s does not call recordInstalledImages — a bring-up path that records nothing leaves a stale record standing", want) + } + } + if callers["StartStackServices"] { + t.Error("StartStackServices must NOT record: it starts only the DB service for the R-47 window, and a partial record would overwrite a complete one") + } +} + +// --- parsing units --- + +func TestParseComposePS_BothShapes(t *testing.T) { + arr := `[{"ID":"a","Name":"n1","Service":"web"},{"ID":"b","Name":"n2","Service":"db"}]` + for name, in := range map[string]string{"ndjson": ndjsonPS, "array": arr} { + got, err := parseComposePS(in) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(got) < 2 || got[0].Service == "" { + t.Fatalf("%s: parsed %+v", name, got) + } + } + if got, err := parseComposePS(" "); err != nil || got != nil { + t.Errorf("empty output must be an empty list, not an error: %v %v", got, err) + } + if _, err := parseComposePS("not json"); err == nil { + t.Error("unparseable output must be an ERROR — cannot-tell must never read as no-containers") + } +} + +func TestPickDigestAndRefRepository(t *testing.T) { + cases := []struct{ ref, digests, want string }{ + {"mariadb:12.3", "mariadb@sha256:aaa", "sha256:aaa"}, + {"mariadb:12.3", "", ""}, + // Two repos, same bytes: pick the one this app's ref names, never the other. + {"mariadb:12.3", "mirror.example/mariadb@sha256:zzz mariadb@sha256:aaa", "sha256:aaa"}, + // A registry PORT is not a tag. + {"registry:5000/app:1.2", "registry:5000/app@sha256:bbb", "sha256:bbb"}, + // Sole entry, repo does not match: fall back rather than lose the digest. + {"weird:1", "other@sha256:ccc", "sha256:ccc"}, + // Several unrelated entries and none matches: give up rather than guess. + {"weird:1", "a@sha256:1 b@sha256:2", ""}, + } + for _, c := range cases { + if got := pickDigest(c.ref, c.digests); got != c.want { + t.Errorf("pickDigest(%q, %q) = %q, want %q", c.ref, c.digests, got, c.want) + } + } + if got := refRepository("registry:5000/app:1.2"); got != "registry:5000/app" { + t.Errorf("refRepository dropped a registry port: %q", got) + } +} + +// TestParseComposeImages_RealYAMLParse pins the reason this is not a line scan: immich's top-level +// volume keys have exactly the shape a naive scan misreads as a service. +func TestParseComposeImages_RealYAMLParse(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "docker-compose.yml") + body := `services: + immich-server: + image: ghcr.io/immich-app/immich-server:v2.0.1 + immich-db: + image: ghcr.io/immich-app/postgres:16 +volumes: + immich_ml_cache: + immich_postgres_data: +` + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + got, err := ParseComposeImages(p) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("parsed %d services, want 2 — a top-level volume key is NOT a service: %+v", len(got), got) + } + if _, err := ParseComposeImages(filepath.Join(dir, "nope.yml")); err == nil { + t.Error("an unreadable file must be an ERROR — cannot-tell must never read as no-images") + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 25b8997..155d4d4 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -150,6 +150,13 @@ type Stack struct { // forgetting costs at most one threshold window, whereas persisting could carry a stale // "this app is crash-looping" verdict across the restart that fixed it. RestartingSince time.Time `json:"restarting_since,omitempty"` + // TemplateImages is what the stack's CURRENT docker-compose.yml pins, per compose service — + // i.e. what the catalog says this app should be running right now. Refreshed by ScanStacks for + // deployed, non-protected apps only; nil for everything else and nil when the file cannot be + // parsed. Nil means CANNOT-TELL and never means "matches": web.updateBadge renders nothing. + // Not persisted — it is a read of a file the syncer owns, and re-reading is cheaper than a + // second copy that can go stale. + TemplateImages map[string]string `json:"template_images,omitempty"` } // Manager handles all docker compose stack operations. @@ -200,6 +207,11 @@ type Manager struct { // Debug dump network section); nil in production. One seam for all guest-net reads — tests // script canned `ip`/resolv.conf outputs per argv and never touch docker. guestNetExecFn func(args ...string) (string, error) + // installedExecFn is the installed-images recorder's OWN process boundary (installed.go); nil in + // production (defaultExecRunner). Separate from execFn deliberately: this one carries a context + // and a timeout, which execFn/composeExecCustomEnv do not, and a bookkeeping read must never be + // able to wedge a lifecycle action. Tests script argv -> output and never touch docker. + installedExecFn execRunner } // SetSambaRunProbe injects the samba liveness probe. Exported for the same reason @@ -502,6 +514,19 @@ func (m *Manager) ScanStacks() error { m.logger.Printf("[DEBUG] [stacks] ScanStacks: found stack %q deployed=%v composePath=%s", name, deployed, composePath) } + // What the CURRENT template pins, for the update badge. Deployed non-protected apps only: + // an undeployed template has nothing to compare against, and infra stacks are not the + // customer's to update. A parse failure leaves this nil, which reads as CANNOT-TELL. + var tplImages map[string]string + if deployed && !m.cfg.IsProtectedStack(name) { + imgs, ierr := ParseComposeImages(composePath) + if ierr != nil { + m.logger.Printf("[WARN] [stacks] ScanStacks: cannot read image pins from %s: %v", composePath, ierr) + } else { + tplImages = imgs + } + } + if existing, ok := m.stacks[name]; ok { existing.ComposePath = composePath existing.Meta = meta @@ -511,16 +536,18 @@ func (m *Manager) ScanStacks() error { if !existing.Deploying { existing.Deployed = deployed existing.AppConfig = appCfg + existing.TemplateImages = tplImages } } else { m.stacks[name] = &Stack{ - Name: name, - Meta: meta, - ComposePath: composePath, - State: StateNotDeployed, - Deployed: deployed, - Protected: m.cfg.IsProtectedStack(name), - AppConfig: appCfg, + Name: name, + Meta: meta, + ComposePath: composePath, + State: StateNotDeployed, + Deployed: deployed, + Protected: m.cfg.IsProtectedStack(name), + AppConfig: appCfg, + TemplateImages: tplImages, } } } @@ -1052,6 +1079,7 @@ func (m *Manager) StartStack(name string) error { } m.logger.Printf("[INFO] [stacks] Stack %s started successfully (took %.1fs)", name, time.Since(start).Seconds()) + m.recordInstalledImages(name, dir, env) m.logPostStartStatus(name, dir, env) // Clear stale health probe so refreshStatus won't re-apply an old unhealthy override. @@ -1155,6 +1183,7 @@ func (m *Manager) RestartStack(name string) error { } m.logger.Printf("[INFO] [stacks] Stack %s restarted successfully (took %.1fs)", name, time.Since(start).Seconds()) + m.recordInstalledImages(name, dir, env) m.logPostStartStatus(name, dir, env) // Clear stale health probe so refreshStatus won't re-apply an old unhealthy override. @@ -1193,6 +1222,7 @@ func (m *Manager) UpdateStack(name string) error { } m.logger.Printf("[INFO] [stacks] Stack %s updated successfully (took %.1fs)", name, time.Since(start).Seconds()) + m.recordInstalledImages(name, dir, env) m.logPostStartStatus(name, dir, env) return m.RefreshStatus() } diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 6053d2b..4305d99 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gopkg.in/yaml.v3" @@ -30,6 +31,15 @@ type Metadata struct { // An UNKNOWN value degrades to available with one WARN (see LoadMetadata) — a typo in a catalog // push must never brick a template. Lifecycle string `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"` + // CatalogSince is the date (YYYY-MM-DD) on which THIS CATALOG last changed the app's pinned + // images. It is not a version and it is not an upstream release date — it answers only + // "how long has a newer pin been sitting in the catalog", which is the one thing a household + // can act on. The customer never sees a version string anywhere (operator ruling, 2026-09-02). + // + // OPTIONAL and TOLERANT in the Lifecycle style: absent, empty, malformed or dated in the FUTURE + // all degrade to "no age known" with one WARN, and the badge simply renders without an age. A + // catalog push must never be able to brick a template. + CatalogSince string `yaml:"catalog_since,omitempty" json:"catalog_since,omitempty"` // OpenPath is appended to the app's public URL for the "Megnyitás" (open) link, for apps whose UI // isn't at "/" (e.g. Gokapi → "/admin"). Empty = bare root. Must start with "/". OpenPath string `yaml:"open_path,omitempty" json:"open_path,omitempty"` @@ -256,6 +266,37 @@ func (m Metadata) CanInstall() bool { return m.EffectiveLifecycle() == Lifecycle // IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice. func (m Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned } +// catalogSinceLayout is the ONE accepted form. Deliberately a single strict layout rather than a +// list of tolerated ones: a date this code half-guesses at would print a confident "45 napja" from +// a value nobody checked. +const catalogSinceLayout = "2006-01-02" + +// CatalogSinceAge returns how many WHOLE DAYS ago this app's pins last moved in the catalog, and +// whether that age is knowable at all. VALUE receiver, for the reason stated above CanInstall. +// +// FALSE — the age is unknown — for every degraded case: absent, empty, unparseable, and a date in +// the FUTURE. The future case is not pedantry: a box whose clock is behind the catalog's would +// otherwise render "-3 napja", which is worse than saying nothing. `now` is injected so the rule is +// a testable contract and not a property of the clock. +func (m Metadata) CatalogSinceAge(now time.Time) (int, bool) { + if strings.TrimSpace(m.CatalogSince) == "" { + return 0, false + } + since, err := time.Parse(catalogSinceLayout, strings.TrimSpace(m.CatalogSince)) + if err != nil { + return 0, false + } + // Compare CALENDAR DAYS, not elapsed hours: "yesterday" must read as 1 napja whether it is now + // 00:30 or 23:30, and a duration division answers 0 for one of those. + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + sinceDay := time.Date(since.Year(), since.Month(), since.Day(), 0, 0, 0, 0, time.UTC) + days := int(today.Sub(sinceDay).Hours() / 24) + if days < 0 { + return 0, false + } + return days, true +} + // LoadMetadata reads .felhom.yml from a stack directory. // Returns default metadata if the file doesn't exist. func LoadMetadata(stackDir string) Metadata { @@ -300,6 +341,16 @@ func LoadMetadata(stackDir string) Metadata { dirName, meta.Lifecycle, LifecycleAvailable, LifecycleAvailable, LifecycleHidden, LifecycleAbandoned) } + // catalog_since: warn ONCE per load on a value that is present but unusable, then let + // CatalogSinceAge degrade it to "no age known". Same placement and same reason as Lifecycle + // above — one line per load, not one per render. + if raw := strings.TrimSpace(meta.CatalogSince); raw != "" { + if _, ok := meta.CatalogSinceAge(time.Now().UTC()); !ok { + log.Printf("[WARN] [stacks] %s: unusable catalog_since %q in .felhom.yml (want YYYY-MM-DD, not in the future) — the update badge will show no age", + dirName, raw) + } + } + // Default healthcheck fields if meta.HealthCheck != nil { if meta.HealthCheck.Interval == "" { diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index d5a5384..4fa0036 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -473,6 +473,12 @@ func (s *Server) templateFuncMap() template.FuncMap { // there is nothing to say. Pair it with the `meta_badge` partial, which no-ops on nil. // R-56's difficulty badge is meant to be a sibling entry returning the same *MetaBadge. "lifecycleBadge": lifecycleBadge, + // updateBadge is the SECOND *MetaBadge-returning entry the type was built for: it says + // whether a deployed app is running what the catalog currently pins, and for how long it + // has not been. Pair it with the same `meta_badge` partial. Renders NOTHING when there is + // no record — absent means unknown, never "up to date". Information only: it is wired to + // no action and changes no button. + "updateBadge": updateBadge, // canInstall reports whether a catalog template may be OFFERED for a new install. The // server-side deploy gate uses the same stacks.Metadata.CanInstall, so the button and the // endpoint can never disagree. diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 1a7534a..d9772c2 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -11,6 +11,7 @@ {{stateLabel .Stack.State}} {{if .Stack.Orphaned}}Elavult{{end}} {{template "meta_badge" (lifecycleBadge .Meta)}} + {{template "meta_badge" (updateBadge .Stack)}} {{if .EffectiveSubdomain}}Megnyitás ↗{{end}} Napló {{if .Stack.Orphaned}} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 9c2320a..7542959 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -40,6 +40,7 @@ {{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} {{template "meta_badge" (lifecycleBadge .Meta)}} + {{template "meta_badge" (updateBadge .)}} {{$ms := index $.MissingStorage .Name}}{{if $ms}}Hiányzó tárhely: {{$ms}}{{end}} {{$ns := index $.NetworkStubs .Name}}{{if $ns}}Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja{{end}} {{$nw := index $.NetworkWarnings .Name}}{{if $nw}}Hálózati tárhely nem elérhető: {{$nw}}{{end}} diff --git a/controller/internal/web/updatebadge.go b/controller/internal/web/updatebadge.go new file mode 100644 index 0000000..ad71915 --- /dev/null +++ b/controller/internal/web/updatebadge.go @@ -0,0 +1,107 @@ +package web + +import ( + "fmt" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// updateState is the three-way 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): +// +// 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. +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 +) + +// 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 compose file now pins. +// +// 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. +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. + return updateUnknown + } + if s.AppConfig == nil || len(s.AppConfig.InstalledImages) == 0 { + return updateUnknown // legacy app.yaml — no record was ever written + } + if len(s.TemplateImages) == 0 { + return updateUnknown // the compose file could not be read or pins nothing + } + if len(s.AppConfig.InstalledImages) != len(s.TemplateImages) { + // 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.TemplateImages { + 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 +// property of the clock. updateBadge (the funcmap entry) is the one-line wrapper. +// +// 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. +func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge { + switch compareInstalledToTemplate(s) { + case updateCurrent: + return &MetaBadge{ + Label: "Naprakész", + Class: "tag-ok", + Title: "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.", + } + case updateBehind: + label := "Frissítés elérhető" + if days, ok := s.Meta.CatalogSinceAge(now); ok { + if days == 0 { + label += " — ma" + } else { + label += fmt.Sprintf(" — %d napja", days) + } + } + return &MetaBadge{ + Label: label, + Class: "tag-warn", + 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.", + } + default: + // UNKNOWN renders NOTHING. Not a grey "ismeretlen" pill: a badge on an app we cannot judge + // is a question the customer cannot answer, and the record fills itself in on the next + // restart or update anyway. + return nil + } +} + +// updateBadge is the funcmap entry. NO version number appears in any string it produces — the +// operator ruled that a household cannot act on "26.05.2", only on "you are behind, by this long". +// Version strings stay in the logs, the API and the hub. +// +// It is INFORMATION ONLY. It is wired to no action, and the Frissítés button is untouched. +func updateBadge(s stacks.Stack) *MetaBadge { return updateBadgeAt(s, time.Now().UTC()) } diff --git a/controller/internal/web/updatebadge_test.go b/controller/internal/web/updatebadge_test.go new file mode 100644 index 0000000..791b15f --- /dev/null +++ b/controller/internal/web/updatebadge_test.go @@ -0,0 +1,268 @@ +package web + +import ( + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// Slice 2 (v0.233.0) — the badge. FOUR states, and the fourth is the load-bearing one: +// NO RECORD RENDERS NOTHING. An app with no record showing "Naprakész" is the R-166 failure in a +// new place — absent means UNKNOWN and never means current. + +var badgeNow = time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + +// ubStack builds a deployed app whose record and template pins are stated explicitly. +func ubStack(installed map[string]stacks.InstalledImage, template map[string]string, since string) stacks.Stack { + return stacks.Stack{ + Name: "bookstack", + Deployed: true, + State: stacks.StateRunning, + Meta: stacks.Metadata{DisplayName: "BookStack", Slug: "bookstack", CatalogSince: since}, + AppConfig: &stacks.AppConfig{Deployed: true, InstalledImages: installed}, + TemplateImages: template, + } +} + +func rec(ref string) stacks.InstalledImage { + return stacks.InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"} +} + +// --- GROUP D: the four states --- + +// TestGroupD_FourStates. +// +// COMPANION RED-PROOF (run 2026-09-02): in compareInstalledToTemplate, change the +// `len(s.AppConfig.InstalledImages) == 0` guard to fall through to updateCurrent instead of +// updateUnknown — i.e. the trivial implementation that treats "we never wrote it down" as +// "up to date". The `no record at all` sub-test then fails with a "Naprakész" badge on an app +// nobody has ever measured. Reverted. +func TestGroupD_FourStates(t *testing.T) { + tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2", "db": "mariadb:12.3"} + + cases := []struct { + name string + stack stacks.Stack + wantBadge bool + wantLabel string + wantClass string + }{ + { + name: "current", + stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, tpl, "2026-07-18"), + wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok", + }, + { + name: "behind, age known", + stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, "2026-07-18"), + wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn", + }, + { + name: "behind, age unknown", + stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, ""), + wantBadge: true, wantLabel: "Frissítés elérhető", wantClass: "tag-warn", + }, + { + name: "behind, catalog moved TODAY", + 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", + }, + { + name: "NO RECORD AT ALL (legacy app.yaml) — nothing rendered", + stack: ubStack(nil, tpl, "2026-07-18"), + wantBadge: false, + }, + { + name: "a service was ADDED by the template", + stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"), + wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn", + }, + { + name: "template unreadable — nothing rendered", + stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, nil, "2026-07-18"), + wantBadge: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + b := updateBadgeAt(c.stack, badgeNow) + if (b != nil) != c.wantBadge { + t.Fatalf("badge = %+v, want present=%v", b, c.wantBadge) + } + if b == nil { + return + } + if b.Label != c.wantLabel { + t.Errorf("label = %q, want %q", b.Label, c.wantLabel) + } + if b.Class != c.wantClass { + t.Errorf("class = %q, want %q", b.Class, c.wantClass) + } + if b.Title == "" { + t.Error("a badge that is only a word is a riddle — it must carry an explanation") + } + }) + } +} + +// TestGroupD_NoVersionNumberIsEverShown — the operator ruled it: a household cannot act on +// "26.05.2". Version strings stay in the logs, the API and the hub. +func TestGroupD_NoVersionNumberIsEverShown(t *testing.T) { + tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"} + for _, s := range []stacks.Stack{ + ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"), + ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18"), + } { + b := updateBadgeAt(s, badgeNow) + if b == nil { + t.Fatal("expected a badge") + } + for _, forbidden := range []string{"26.05.2", "25.02.2", "bookstack:", "mariadb", "sha256"} { + if strings.Contains(b.Label+b.Title, forbidden) { + t.Errorf("badge text leaks %q: label=%q title=%q", forbidden, b.Label, b.Title) + } + } + } +} + +// TestGroupD_NothingIsBadgedThatCannotBeJudged — undeployed, protected and orphaned apps. +func TestGroupD_NothingIsBadgedThatCannotBeJudged(t *testing.T) { + tpl := map[string]string{"web": "nginx:1.27"} + inst := map[string]stacks.InstalledImage{"web": rec("nginx:1.26")} + for name, mutate := range map[string]func(*stacks.Stack){ + "not deployed": func(s *stacks.Stack) { s.Deployed = false }, + "protected": func(s *stacks.Stack) { s.Protected = true }, + "orphaned": func(s *stacks.Stack) { s.Orphaned = true }, + } { + s := ubStack(inst, tpl, "2026-07-18") + mutate(&s) + if b := updateBadgeAt(s, badgeNow); b != nil { + t.Errorf("%s: rendered %q — there is nothing to be current WITH", name, b.Label) + } + } +} + +// --- GROUP D, rendered: the PRODUCTION templates --- + +func ubAppInfoData(st stacks.Stack) map[string]interface{} { + return map[string]interface{}{ + "Page": "stacks", "Title": st.Meta.DisplayName, + "Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo, + "HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain, + "Domain": "demo-felhom.eu", + } +} + +func ubStacksData(st stacks.Stack) map[string]interface{} { + return map[string]interface{}{ + "Page": "stacks", "Title": "Alkalmazások", + "Stacks": []stacks.Stack{st}, + "MissingStorage": map[string]string{}, + "NetworkWarnings": map[string]string{}, + "NetworkStubs": map[string]string{}, + "StorageLabels": map[string]string{}, + "Subdomains": map[string]string{}, + } +} + +// TestGroupD_BadgeRendersOnBothSurfaces renders the REAL templates, which is the only thing that +// catches a template-time failure: `.Stack` reaches app_info as a *stacks.Stack, and a funcmap entry +// taking a VALUE has to be reachable from it. A compile-clean funcmap that 500s at render is exactly +// how v0.158.0's pointer-receiver defect shipped. +// +// 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. +func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) { + 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") + + for _, surface := range []struct { + name string + data func(stacks.Stack) map[string]interface{} + tmpl string + }{ + {"app page", ubAppInfoData, "app_info"}, + {"app list", ubStacksData, "stacks"}, + } { + 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") { + t.Errorf("the behind badge is missing from %s", surface.tmpl) + } + h = renderBackupPage(t, surface.tmpl, surface.data(current)) + if !strings.Contains(h, "Naprakész") { + t.Errorf("the current badge is missing from %s", surface.tmpl) + } + // The negative half. Without it, an implementation that badges everything passes. + h = renderBackupPage(t, surface.tmpl, surface.data(legacy)) + if strings.Contains(h, "Naprakész") || strings.Contains(h, "Frissítés elérhető") { + t.Errorf("an app with NO RECORD must be badged with NOTHING on %s", surface.tmpl) + } + }) + } +} + +// --- SCENARIO E: nothing about updating changed --- + +// TestScenarioE_TheUpdateButtonIsUntouched. This slice is information only. The badge must be wired +// to no action, and the three lifecycle buttons must render exactly as they did before it existed. +func TestScenarioE_TheUpdateButtonIsUntouched(t *testing.T) { + tpl := map[string]string{"web": "nginx:1.27"} + states := map[string]stacks.Stack{ + "current": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")}, tpl, "2026-07-18"), + "behind": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, "2026-07-18"), + "behind/na": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, ""), + "no record": ubStack(nil, tpl, "2026-07-18"), + } + for name, st := range states { + h := renderBackupPage(t, "stacks", ubStacksData(st)) + for _, want := range []string{ + `stackAction(event, 'bookstack', 'update')`, + `stackAction(event, 'bookstack', 'restart')`, + `stackAction(event, 'bookstack', 'stop')`, + } { + if !strings.Contains(h, want) { + t.Errorf("%s: the %q button changed — this slice must change no behaviour", name, want) + } + } + // No new action may hang off the badge. + if strings.Contains(h, "updateBadgeAction") || strings.Contains(h, "'autoupdate'") { + t.Errorf("%s: the badge must be wired to NOTHING", name) + } + } +} + +// --- GROUP F: catalog_since tolerance --- + +// TestGroupF_CatalogSinceTolerance — absent, empty, malformed and FUTURE all degrade to "no age +// known" and never brick the badge. The future case is not pedantry: a box whose clock is behind the +// catalog would otherwise print "-3 napja". +func TestGroupF_CatalogSinceTolerance(t *testing.T) { + for _, since := range []string{"", " ", "tegnap", "18/07/2026", "2026-13-45", "2026-12-31"} { + m := stacks.Metadata{CatalogSince: since} + if days, ok := m.CatalogSinceAge(badgeNow); ok { + t.Errorf("catalog_since %q must be UNUSABLE, got %d napja", since, days) + } + tpl := map[string]string{"web": "nginx:1.27"} + s := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, since) + b := updateBadgeAt(s, badgeNow) + if b == nil { + t.Fatalf("catalog_since %q: the badge must still render, just without an age", since) + } + if b.Label != "Frissítés elérhető" { + t.Errorf("catalog_since %q: label = %q, want the age-less form", since, b.Label) + } + } + // And the usable cases. + for since, want := range map[string]int{"2026-09-02": 0, "2026-09-01": 1, "2026-07-18": 46} { + got, ok := stacks.Metadata{CatalogSince: since}.CatalogSinceAge(badgeNow) + if !ok || got != want { + t.Errorf("catalog_since %q → (%d, %v), want (%d, true)", since, got, ok, want) + } + } +}