Files
felhom-controller/controller/internal/stacks/installed_test.go
T
admin 38d28b5b62
gates / gates (push) Successful in 13s
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.
2026-09-03 11:56:43 +02:00

704 lines
28 KiB
Go

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")
}
}
// --- 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)
}
}