v0.294.0: off-site clean-up guard follows the policy's own constants (R-867); no image clean-up while compose pulls (R-863); stderr tail (R-864); move-aside destination logged (R-869)
gates / gates (push) Successful in 28s
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
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user