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