controller v0.266.0: a failed install removes what it started (R-649, operator ruling)
gates / gates (push) Successful in 27s
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:
@@ -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)
|
## 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).
|
**MinAgent: 0.131.0** (unchanged). **Needs hub v0.121.0** (deployed first). New strings: yes (hu + en).
|
||||||
|
|||||||
@@ -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
|
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).
|
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
|
**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
|
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`
|
deploying stack (`ErrStackDeploying`) for every caller. Measured cause: the whole-box backup's `compose down`
|
||||||
|
|||||||
@@ -422,6 +422,17 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
|
|||||||
|
|
||||||
if composeErr != nil {
|
if composeErr != nil {
|
||||||
m.logger.Printf("[ERROR] [stacks] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr)
|
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
|
// Revert in-memory and disk state
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if s, ok := m.stacks[name]; ok {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user