package quiesce import ( "context" "errors" "sync" "testing" ) // F-CRIT-1 cause 1 (Campaign 8): `restartAll` used to return nothing, so a failed restart was // logged and dropped and no caller could ever learn a customer's app had not come back. // // Scenario D — the outcome must reach the caller. // stacksWithStartFailures is a Stacks whose StartStack fails for named stacks. type stacksWithStartFailures struct { mu sync.Mutex running []string startErr map[string]error started []string stopped []string } func (f *stacksWithStartFailures) RunningAppStacks() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.running...) } func (f *stacksWithStartFailures) StopStack(name string) error { f.mu.Lock() defer f.mu.Unlock() f.stopped = append(f.stopped, name) return nil } func (f *stacksWithStartFailures) StartStack(name string) error { f.mu.Lock() defer f.mu.Unlock() f.started = append(f.started, name) if f.startErr != nil { return f.startErr[name] } return nil } // Scenario D — restartAll RETURNS the stacks that would not start. // // RED-PROOF: revert restartAll to `func (l *Loop) restartAll(stacks []string)` with the error only // logged → this file does not compile ("l.restartAll(...) used as value"), which is the loudest // possible form of "the caller cannot learn". func TestRestartAll_ReturnsTheStacksThatFailed(t *testing.T) { boom := errors.New("compose up: exit code 1") st := &stacksWithStartFailures{startErr: map[string]error{"immich": boom}} l := testLoop(t, &fakeBackend{}, st) failed := l.restartAll([]string{"bookstack", "immich", "docmost"}) if len(failed) != 1 || failed[0] != "immich" { t.Fatalf("restartAll returned %v, want exactly [immich]", failed) } // every stack must still have been ATTEMPTED — one failure must not abort the rest if len(st.started) != 3 { t.Errorf("attempted %d restarts, want 3 — a failure must not stop the loop restarting the others", len(st.started)) } } // Scenario A (cause 1) — a failed restart is REMEMBERED on the Loop, so the classifier can see it. // // RED-PROOF: drop the `l.noteRestartOutcome(...)` call at the unquiesce site → FailedRestarts() is // empty and this fails with "want immich to be recorded as a failed restart". func TestFailedRestarts_RecordedAfterACycle(t *testing.T) { boom := errors.New("compose up: exit code 1") st := &stacksWithStartFailures{ running: []string{"bookstack", "immich"}, startErr: map[string]error{"immich": boom}, } be := &fakeBackend{due: true, phases: []string{"done"}} l := testLoop(t, be, st) if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } failed := l.FailedRestarts() if !failed["immich"] { t.Errorf("want immich to be recorded as a failed restart, got %v", failed) } if failed["bookstack"] { t.Errorf("bookstack restarted fine and must NOT be recorded as failed, got %v", failed) } } // The flag must CLEAR when a later cycle restarts the stack successfully — otherwise a fixed app // carries its old failure forever and would alarm the next time it is legitimately stopped. func TestFailedRestarts_ClearedOnALaterSuccess(t *testing.T) { boom := errors.New("compose up: exit code 1") st := &stacksWithStartFailures{ running: []string{"immich"}, startErr: map[string]error{"immich": boom}, } be := &fakeBackend{due: true, phases: []string{"done"}} l := testLoop(t, be, st) if err := l.runOnce(context.Background()); err != nil { t.Fatalf("cycle 1: %v", err) } if !l.FailedRestarts()["immich"] { t.Fatal("precondition: immich should be flagged after the failing cycle") } // the operator fixes the app; the next cycle restarts it cleanly st.mu.Lock() st.startErr = nil st.mu.Unlock() if err := l.runOnce(context.Background()); err != nil { t.Fatalf("cycle 2: %v", err) } if l.FailedRestarts()["immich"] { t.Error("the flag survived a SUCCESSFUL restart — a fixed app would carry its old failure forever") } } // FailedRestarts must be nil-safe on a nil *Loop, for the same reason SuppressedStacks is: an // unprovisioned guest has no loop and the caller must not need a branch. func TestFailedRestarts_NilSafe(t *testing.T) { var l *Loop if got := l.FailedRestarts(); got != nil { t.Errorf("nil Loop returned %v, want nil", got) } }