diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c66c5..1dd06cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## v0.266.0 — a failed install removes what it started (2026-09-23, R-649) + +**MinAgent: 0.131.0** (unchanged). No new strings. No hub change. + +- **Operator ruling 2026-09-23 (R-649):** when the deploy's `compose up -d` fails for its own reasons (e.g. a + dependency whose healthcheck never passes after its container started), `runComposeDeploy` now runs + `compose down` before recording „not deployed" — so „not installed" never stands over running + containers (R-634's promise, completed). `down` WITHOUT `-v`: named volumes are kept. A failed `down` is + logged; the household's Remove clears what is left (v0.262.0 `halfStateEvidence`). +- Tests: `TestR649_AFailedInstallRemovesWhatItStarted` (stub compose, PATH = the stub only), + `TestR649_ASuccessfulInstallIsNotTakenDown`. Red-proof: the `down` removed → `calls=["up -d"]`. + ## v0.265.0 — the cause of "runs but not installed", a held app that says so, a louder OOM storm (2026-09-23, R-634, R-625, R-636, R-647) **MinAgent: 0.131.0** (unchanged). **Needs hub v0.121.0** (deployed first). New strings: yes (hu + en). diff --git a/controller/README.md b/controller/README.md index 7a3b43c..53bdb95 100644 --- a/controller/README.md +++ b/controller/README.md @@ -656,6 +656,11 @@ sentence, in the reader's language) and no Update button is rendered; the API st held update's error is stored as the key `update.error.held` and rendered per reader, so a reader in the other language than the box reads the hold in theirs (R-647). +**A failed install removes what it started (v0.266.0, R-649, operator ruling 2026-09-23).** When the +deploy's `compose up -d` fails, the controller runs `compose down` (WITHOUT `-v`: named volumes stay, so a +reinstall after „keep my data" finds its data) before the record reads „not deployed". „Failed" therefore +means nothing runs; the household presses Install again. A failed `down` is logged; Remove clears the rest. + **Deploys are not interrupted (v0.265.0, R-634).** A deploy still running is not in any backup leg's app list, the volume leg asks again right before it stops an app, and `StopStack` / `StartStack` refuse a deploying stack (`ErrStackDeploying`) for every caller. Measured cause: the whole-box backup's `compose down` diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 05cfdb4..7fa42f6 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -422,6 +422,17 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, if composeErr != nil { m.logger.Printf("[ERROR] [stacks] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr) + // R-649 (v0.266.0, operator ruling 2026-09-23): a failed install REMOVES what it started, so + // „not installed" never stands over running containers (R-634's promise, for the deploy's OWN + // failures — e.g. a dependency whose healthcheck never passes after its container started). + // `down` WITHOUT -v: containers and the network go, named volumes stay — a reinstall of an app + // removed with „keep my data" must find its data again. A failed `down` is logged and the record + // still reads not-deployed; the household's Remove clears what is left (halfStateEvidence). + if _, downErr := m.composeExecWithEnv(stackDir, env, "down"); downErr != nil { + m.logger.Printf("[ERROR] [stacks] Stack %s: removing what the failed deploy started ALSO failed: %v — containers may remain; Remove clears them", name, downErr) + } else { + m.logger.Printf("[INFO] [stacks] Stack %s: the failed deploy's containers were removed (volumes kept) — R-649", name) + } // Revert in-memory and disk state m.mu.Lock() if s, ok := m.stacks[name]; ok { diff --git a/controller/internal/stacks/r649_failed_deploy_cleanup_test.go b/controller/internal/stacks/r649_failed_deploy_cleanup_test.go new file mode 100644 index 0000000..6d78f23 --- /dev/null +++ b/controller/internal/stacks/r649_failed_deploy_cleanup_test.go @@ -0,0 +1,69 @@ +package stacks + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// R-649 (v0.266.0, operator ruling 2026-09-23) — a failed install removes the containers it started. +// The stub compose binary FAILS `up` and records every call, so the test sees what the deploy ran +// across the real process boundary. +// +// COMPANION RED-PROOF (REPORT.md): delete the `down` call in runComposeDeploy's failure branch — the +// recorded calls are only [up -d] and this fails. +func TestR649_AFailedInstallRemovesWhatItStarted(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the stub compose binary is a shell script") + } + m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n") + bin := t.TempDir() + calls := filepath.Join(t.TempDir(), "calls") + script := "#!/bin/sh\necho \"$*\" >> " + calls + "\ncase \"$*\" in *up*) exit 1;; esac\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) // ONLY the stub: no real docker can ever be reached from here (R-650) + m.composeCmd = "docker-compose" + m.execFn = func(string, ...string) (string, error) { return "", nil } + + m.runComposeDeploy("bookstack", dir, map[string]string{}, &AppConfig{Deployed: true}) + + b, _ := os.ReadFile(calls) + got := strings.Split(strings.TrimSpace(string(b)), "\n") + if len(got) != 2 || !strings.Contains(got[0], "up -d") || !strings.HasSuffix(got[1], "down") { + t.Fatalf("a failed install must run `down` after its failed `up`; calls=%q", got) + } + if strings.Contains(got[1], "-v") || strings.Contains(got[1], "--volumes") { + t.Fatalf("the cleanup must KEEP named volumes (a reinstall finds its data); got %q", got[1]) + } + if cfg := LoadAppConfig(dir); cfg != nil && cfg.Deployed { + t.Fatal("the record must read not deployed") + } +} + +// A successful install never runs `down` (control: the cleanup is on the failure branch only). +func TestR649_ASuccessfulInstallIsNotTakenDown(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the stub compose binary is a shell script") + } + m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n") + bin := t.TempDir() + calls := filepath.Join(t.TempDir(), "calls") + script := "#!/bin/sh\necho \"$*\" >> " + calls + "\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + m.composeCmd = "docker-compose" + m.execFn = func(string, ...string) (string, error) { return "", nil } + + m.runComposeDeploy("bookstack", dir, map[string]string{}, &AppConfig{Deployed: true}) + + b, _ := os.ReadFile(calls) + if strings.Contains(string(b), "down") { + t.Fatalf("a successful install must not be taken down; calls=%q", string(b)) + } +}