7861bf9dde
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
110 lines
4.4 KiB
Go
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) }
|