package stacks import ( "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" ) // R-863 (v0.294.0) — THE NIGHT'S EXACT SHAPE (2026-10-04, a fresh box): the household's first install is inside // `compose up`; compose has pulled `mariadb@sha256:…` (stored untagged, `mariadb:`) and not yet created the // container; the controller's one-time image clean-up fires (3 minutes after its first start). v0.293.0 deleted // the image — no container, installed app or undo named it — and `compose up` failed one second later. // COMPANION RED-PROOF: drop the dockerexec.TryImageCleanup check in deleteUnkeptImages → the clean-up deletes // sha256:MDB and the install fails ("the install failed"). func TestR863_OneTimeCleanupDuringFirstInstallKeepsThePulledImage(t *testing.T) { m := gateManager(t, "display_name: Book\ndeploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n") m.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "data") cat := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", "bookstack") must(t, os.MkdirAll(cat, 0o755)) must(t, os.WriteFile(filepath.Join(cat, "docker-compose.yml"), []byte("services:\n db:\n image: mariadb:11.4@sha256:mdb\n"), 0o644)) f := &fakeImages{containers: map[string]string{}} var fmu sync.Mutex prev := imageDocker imageDocker = func(args ...string) (string, error) { fmu.Lock(); defer fmu.Unlock(); return f.run(args...) } t.Cleanup(func() { imageDocker = prev }) var cleanupDone bool m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) { if len(args) == 0 || args[0] != "up" { return "", nil } // compose pulls by digest: the image exists, untagged, named by no container yet fmu.Lock() f.imgs = append(f.imgs, localImage{ID: "sha256:MDB", Repo: "mariadb", Tag: "", Digest: "sha256:mdb", Size: "334MB"}) fmu.Unlock() // the one-time clean-up fires NOW, from its own goroutine, as at 19:45:04 res := make(chan bool, 1) go func() { _, done := m.RunImageRetentionOnce(); res <- done }() select { case cleanupDone = <-res: case <-time.After(10 * time.Second): return "", fmt.Errorf("the clean-up blocked") } // compose creates the container from the pulled image — if it is still there fmu.Lock() defer fmu.Unlock() for _, im := range f.imgs { if im.ID == "sha256:MDB" { f.containers["c-db"] = "sha256:MDB" return "", nil } } return "", fmt.Errorf("exit code 1\nstderr: Error response from daemon: No such image: mariadb@sha256:mdb") } done := make(chan bool, 1) m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok }) if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil { t.Fatal(err) } select { case ok := <-done: if !ok { t.Fatalf("the install failed: the clean-up deleted the image compose had just pulled (rmi %v)", f.rmi) } case <-time.After(20 * time.Second): t.Fatal("the deploy never ended") } if len(f.rmi) != 0 { t.Fatalf("the clean-up deleted during the install: %v", f.rmi) } if cleanupDone { t.Fatal("the clean-up reported done although it did not run — its marker would end it for good") } if _, err := os.Stat(m.imageRetentionMarker()); err == nil { t.Fatal("marker written for a pass that did not run") } // After the install the retried clean-up runs, and the app's image is kept (a container names it). if _, done := m.RunImageRetentionOnce(); !done { t.Fatal("the retried clean-up did not run once nothing was pulling") } if strings.Contains(strings.Join(f.rmi, ","), "sha256:MDB") { t.Fatal("the installed app's database image was deleted") } } // Image-pulling verbs hold the lock; others do not (a `ps` or `down` must never hold off a clean-up). func TestR863_OnlyPullingVerbsHoldTheLock(t *testing.T) { for _, c := range []struct { args []string want bool }{ {[]string{"up", "-d"}, true}, {[]string{"compose", "up", "-d", "--remove-orphans"}, true}, {[]string{"pull"}, true}, {[]string{"-p", "x", "create"}, true}, {[]string{"run", "--rm", "x"}, true}, {[]string{"ps"}, false}, {[]string{"down"}, false}, {[]string{"stop"}, false}, {[]string{"-p", "up", "down"}, false}, } { if got := imagePullingForTest(c.args); got != c.want { t.Errorf("%v: pulling=%v, want %v", c.args, got, c.want) } } } func imagePullingForTest(args []string) bool { return dockerexec.ImagePulling(args) }