285dd1032f
R-51: aggregateState's mixed branch returned StateRunning ("partial"), so a stack whose
MAIN container was dead behind live helpers alerted on nothing — immich-server sat Exited
for 18 h, 100 % unreachable, no banner and no app_start_failed (audit F4). New
StateDegraded: a DOWN member whose docker restart policy is always/unless-stopped is a
fault (degraded, a down state); no/on-failure is a finished one-shot and stays benign; an
unreadable policy fails CLOSED. The unhealthy/restarting/paused/unknown exclusions are
byte-identical — folding unhealthy into down is the flapping fix-3 avoided.
R-52: new internal/bootrecon — one bounded start-once sweep at startup (2 attempts, 30 s
apart) for apps an interrupted boot left behind, inside the 90 s boot grace so a success
is silent and a failure still alerts. A zero-container stack is NEVER touched: the UI's
Stop is compose down, so a deliberate stop survives a reboot.
Both features carry a production-path wiring test (the v0.154.0 / v0.91.0 inert-seam
class). The main() assertion is an AST walk, not strings.Contains — the substring version
passed its own red-proof, because a commented-out call still contains the string.
Red-proofs run and restored: mix branch reverted -> "running" on the immich fixture;
boot hook commented out -> wiring test fails; zero-container gate dropped -> the
user-stopped app gets started.
NOTE: controller/cmd/controller/ is matched by .gitignore's `controller` entry, so new
files there need `git add -f` (and ripgrep silently skips main.go without --no-ignore).
280 lines
11 KiB
Go
280 lines
11 KiB
Go
package stacks
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// R-51 (v0.156.0). The live defect these tests pin: on 2026-07-20 `immich-server` sat Exited for
|
|
// 18 hours behind three running helpers, the stack aggregated to StateRunning ("partial"), and
|
|
// because StateRunning is not a down state NOTHING fired — no dashboard banner, no
|
|
// `app_start_failed` hub event — while single-container Calibre-Web, down for the same reason,
|
|
// alerted in 90 s. Evidence: felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md
|
|
// finding F4.
|
|
//
|
|
// RED-PROOF (recorded in REPORT.md): with the mix branch reverted to its pre-v0.156.0 body
|
|
//
|
|
// if running > 0 { return StateRunning }
|
|
//
|
|
// TestAggregateState_DeadSupervisedMemberIsDegraded fails with
|
|
// "aggregateState = running, want degraded", which is exactly the shape the audit observed.
|
|
|
|
// immichLike is the F4 fixture: the primary Exited, the helpers up.
|
|
func immichLike() []ContainerInfo {
|
|
return []ContainerInfo{
|
|
{Name: "immich-server", State: StateExited, Status: "Exited (137) 18 hours ago"},
|
|
{Name: "immich-machine-learning", State: StateRunning, Status: "Up 18 hours"},
|
|
{Name: "immich-redis", State: StateRunning, Status: "Up 18 hours (healthy)"},
|
|
{Name: "immich-postgres", State: StateRunning, Status: "Up 18 hours (healthy)"},
|
|
}
|
|
}
|
|
|
|
// policyMap builds a lookup over a name→policy table; an unlisted name reads as UNKNOWN ("").
|
|
func policyMap(t *testing.T, m map[string]string) restartPolicyLookup {
|
|
t.Helper()
|
|
return func(name string) string { return m[name] }
|
|
}
|
|
|
|
func TestAggregateState_DeadSupervisedMemberIsDegraded(t *testing.T) {
|
|
got := aggregateState(immichLike(), policyMap(t, map[string]string{
|
|
"immich-server": "unless-stopped",
|
|
"immich-machine-learning": "unless-stopped",
|
|
"immich-redis": "unless-stopped",
|
|
"immich-postgres": "unless-stopped",
|
|
}))
|
|
if got != StateDegraded {
|
|
t.Fatalf("aggregateState = %q, want %q (a dead supervised primary must not read as running)", got, StateDegraded)
|
|
}
|
|
if !IsDownState(got) {
|
|
t.Fatalf("IsDownState(%q) = false — the whole point of R-51 is that this state alerts", got)
|
|
}
|
|
}
|
|
|
|
// Scenario B: a one-shot init/migrate container that has legitimately finished must NOT alarm.
|
|
func TestAggregateState_OneShotExitedMemberIsBenign(t *testing.T) {
|
|
for _, policy := range []string{"no", "on-failure", ""} {
|
|
name := policy
|
|
if name == "" {
|
|
name = "(absent)"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
containers := []ContainerInfo{
|
|
{Name: "app-migrate", State: StateExited, Status: "Exited (0) 2 minutes ago"},
|
|
{Name: "app-web", State: StateRunning, Status: "Up 2 minutes"},
|
|
}
|
|
got := aggregateState(containers, policyMap(t, map[string]string{
|
|
"app-migrate": policy,
|
|
"app-web": "unless-stopped",
|
|
}))
|
|
want := StateRunning
|
|
if policy == "" {
|
|
// UNKNOWN is deliberately fail-CLOSED — see supervisedPolicy. An absent policy in
|
|
// the compose file resolves to Docker's "no" at inspect time, so the "" case here
|
|
// is the INSPECT-FAILED case, not the no-restart-policy case.
|
|
want = StateDegraded
|
|
}
|
|
if got != want {
|
|
t.Fatalf("policy %q: aggregateState = %q, want %q", policy, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The unchanged branches: R-51 must not move any state the pre-existing aggregation produced.
|
|
func TestAggregateState_UnchangedBranches(t *testing.T) {
|
|
all := policyMap(t, map[string]string{"a": "unless-stopped", "b": "unless-stopped"})
|
|
cases := []struct {
|
|
name string
|
|
containers []ContainerInfo
|
|
want ContainerState
|
|
}{
|
|
{"no containers", nil, StateNotDeployed},
|
|
{"all running", []ContainerInfo{{Name: "a", State: StateRunning}, {Name: "b", State: StateRunning}}, StateRunning},
|
|
{"all stopped", []ContainerInfo{{Name: "a", State: StateExited}, {Name: "b", State: StateStopped}}, StateStopped},
|
|
{"any unhealthy wins", []ContainerInfo{{Name: "a", State: StateUnhealthy}, {Name: "b", State: StateExited}}, StateUnhealthy},
|
|
{"any starting wins over exited", []ContainerInfo{{Name: "a", State: StateStarting}, {Name: "b", State: StateExited}}, StateStarting},
|
|
{"any restarting wins over exited", []ContainerInfo{{Name: "a", State: StateRestarting}, {Name: "b", State: StateExited}}, StateRestarting},
|
|
{"single container exited", []ContainerInfo{{Name: "a", State: StateExited}}, StateStopped},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := aggregateState(tc.containers, all); got != tc.want {
|
|
t.Fatalf("aggregateState = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The unhealthy/restarting/paused/unknown exclusions are the fix-3 contract (downstate_test.go owns
|
|
// them). This asserts the ONE addition, so a future reader can see R-51 widened the set by exactly
|
|
// one state and by nothing else.
|
|
func TestIsDownState_DegradedIsTheOnlyAddition(t *testing.T) {
|
|
if !IsDownState(StateDegraded) {
|
|
t.Fatalf("IsDownState(degraded) = false, want true")
|
|
}
|
|
for _, s := range []ContainerState{StateUnhealthy, StateRestarting, StatePaused, StateUnknown} {
|
|
if IsDownState(s) {
|
|
t.Fatalf("IsDownState(%q) = true — R-51 must not touch the fix-3 exclusions", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- production-path wiring test (§9 rule 6) ---------------------------------------------------
|
|
//
|
|
// Proves the chain the box actually runs: RefreshStatus → docker ps → aggregateState → docker
|
|
// inspect. An aggregateState-only test proves the function, not the caller.
|
|
|
|
type scriptedDocker struct {
|
|
mu sync.Mutex
|
|
ps string
|
|
policies map[string]string
|
|
inspects []string // every container name inspected, in order
|
|
}
|
|
|
|
func (s *scriptedDocker) exec(name string, args ...string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if name != "docker" {
|
|
return "", fmt.Errorf("unexpected command %q", name)
|
|
}
|
|
switch {
|
|
case len(args) > 0 && args[0] == "ps":
|
|
return s.ps, nil
|
|
case len(args) > 0 && args[0] == "inspect":
|
|
target := args[len(args)-1]
|
|
s.inspects = append(s.inspects, target)
|
|
p, ok := s.policies[target]
|
|
if !ok {
|
|
return "", fmt.Errorf("no such container: %s", target)
|
|
}
|
|
return p + "\n", nil
|
|
}
|
|
return "", fmt.Errorf("unexpected docker args %v", args)
|
|
}
|
|
|
|
func (s *scriptedDocker) inspectCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.inspects)
|
|
}
|
|
|
|
func psLine(name, state, status, project string) string {
|
|
return strings.Join([]string{name, "img:1", state, status, project}, "\t")
|
|
}
|
|
|
|
func TestRefreshStatus_WiresDegradedThroughTheRealPath(t *testing.T) {
|
|
dock := &scriptedDocker{
|
|
ps: strings.Join([]string{
|
|
psLine("immich-server", "exited", "Exited (137) 18 hours ago", "immich"),
|
|
psLine("immich-redis", "running", "Up 18 hours (healthy)", "immich"),
|
|
psLine("calibre-web", "running", "Up 18 hours", "calibre-web"),
|
|
}, "\n"),
|
|
policies: map[string]string{"immich-server": "unless-stopped"},
|
|
}
|
|
m := &Manager{
|
|
cfg: &config.Config{},
|
|
logger: log.New(io.Discard, "", 0),
|
|
execFn: dock.exec,
|
|
stacks: map[string]*Stack{
|
|
"immich": {Name: "immich", Deployed: true},
|
|
"calibre-web": {Name: "calibre-web", Deployed: true},
|
|
},
|
|
}
|
|
|
|
if err := m.RefreshStatus(); err != nil {
|
|
t.Fatalf("RefreshStatus: %v", err)
|
|
}
|
|
if got := m.stacks["immich"].State; got != StateDegraded {
|
|
t.Fatalf("immich state = %q, want %q (the F4 shape must reach the stack map)", got, StateDegraded)
|
|
}
|
|
if got := m.stacks["calibre-web"].State; got != StateRunning {
|
|
t.Fatalf("calibre-web state = %q, want %q — a healthy app must be untouched", got, StateRunning)
|
|
}
|
|
|
|
// Only the DOWN member of the MIXED stack is inspected: never the running members, never the
|
|
// healthy stack. An inspect per container per 10 s refresh would be a real docker load.
|
|
if n := dock.inspectCount(); n != 1 {
|
|
t.Fatalf("docker inspect called %d times, want exactly 1 (%v)", n, dock.inspects)
|
|
}
|
|
|
|
// Second refresh: the answer comes from the cache, so the inspect count must NOT move.
|
|
if err := m.RefreshStatus(); err != nil {
|
|
t.Fatalf("RefreshStatus (2nd): %v", err)
|
|
}
|
|
if n := dock.inspectCount(); n != 1 {
|
|
t.Fatalf("docker inspect called %d times after a second refresh, want 1 — the cache is not being used", n)
|
|
}
|
|
if got := m.stacks["immich"].State; got != StateDegraded {
|
|
t.Fatalf("immich state after 2nd refresh = %q, want %q", got, StateDegraded)
|
|
}
|
|
}
|
|
|
|
// A container that vanishes must not leave its policy behind — an unbounded cache in a process that
|
|
// runs for months is a slow leak, and a stale entry would answer for a recreated container.
|
|
func TestRestartPolicyCache_PrunesVanishedContainers(t *testing.T) {
|
|
dock := &scriptedDocker{
|
|
ps: strings.Join([]string{
|
|
psLine("app-init", "exited", "Exited (0) 1 minute ago", "app"),
|
|
psLine("app-web", "running", "Up 1 minute", "app"),
|
|
}, "\n"),
|
|
policies: map[string]string{"app-init": "no"},
|
|
}
|
|
m := &Manager{
|
|
cfg: &config.Config{},
|
|
logger: log.New(io.Discard, "", 0),
|
|
execFn: dock.exec,
|
|
stacks: map[string]*Stack{"app": {Name: "app", Deployed: true}},
|
|
}
|
|
if err := m.RefreshStatus(); err != nil {
|
|
t.Fatalf("RefreshStatus: %v", err)
|
|
}
|
|
if got := m.stacks["app"].State; got != StateRunning {
|
|
t.Fatalf("state = %q, want running (a finished one-shot must not alarm)", got)
|
|
}
|
|
if len(m.restartPolicyCache) != 1 {
|
|
t.Fatalf("cache size = %d, want 1", len(m.restartPolicyCache))
|
|
}
|
|
|
|
// The one-shot container is reaped; only the web container remains.
|
|
dock.ps = psLine("app-web", "running", "Up 5 minutes", "app")
|
|
if err := m.RefreshStatus(); err != nil {
|
|
t.Fatalf("RefreshStatus (2nd): %v", err)
|
|
}
|
|
if len(m.restartPolicyCache) != 0 {
|
|
t.Fatalf("cache size = %d after the container vanished, want 0: %v", len(m.restartPolicyCache), m.restartPolicyCache)
|
|
}
|
|
}
|
|
|
|
// An inspect failure must not silence the alarm — see supervisedPolicy's fail-closed rationale.
|
|
func TestRefreshStatus_InspectFailureStillDegrades(t *testing.T) {
|
|
dock := &scriptedDocker{
|
|
ps: strings.Join([]string{
|
|
psLine("immich-server", "exited", "Exited (137) 1 hour ago", "immich"),
|
|
psLine("immich-redis", "running", "Up 1 hour", "immich"),
|
|
}, "\n"),
|
|
policies: map[string]string{}, // every inspect fails
|
|
}
|
|
m := &Manager{
|
|
cfg: &config.Config{},
|
|
logger: log.New(io.Discard, "", 0),
|
|
execFn: dock.exec,
|
|
stacks: map[string]*Stack{"immich": {Name: "immich", Deployed: true}},
|
|
}
|
|
if err := m.RefreshStatus(); err != nil {
|
|
t.Fatalf("RefreshStatus: %v", err)
|
|
}
|
|
if got := m.stacks["immich"].State; got != StateDegraded {
|
|
t.Fatalf("state = %q, want %q — an unreadable policy must not lose the alarm", got, StateDegraded)
|
|
}
|
|
// A failed inspect is deliberately NOT cached, so the next cycle retries.
|
|
if len(m.restartPolicyCache) != 0 {
|
|
t.Fatalf("failed inspect was cached: %v", m.restartPolicyCache)
|
|
}
|
|
}
|