Files

110 lines
4.4 KiB
Go

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:<none>`) 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: "<none>", 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) }