controller v0.266.0: a failed install removes what it started (R-649, operator ruling)
gates / gates (push) Successful in 27s

compose down (volumes kept) before the record reads not deployed.

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:
2026-09-23 18:06:29 +02:00
parent 411a43f8ef
commit 964ae7538f
4 changed files with 97 additions and 0 deletions
+5
View File
@@ -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`
+11
View File
@@ -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 {
@@ -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))
}
}