package main import ( "go/ast" "io" "log" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // `09` ยง3 decision 28 (v0.269.0) โ€” the box stops a crash loop / OOM storm; never inside a backup's, // a quiesce's or an update's own stop, never twice, never an app already held. func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) { var calls []string d := unhealthyDeps{ verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict { return []stacks.UnhealthyVerdict{ {Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute}, {Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7}, {Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7}, {Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20}, } }, suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} }, busy: func(n string) (bool, string) { return n == "busy", "a restore is running" }, holdKind: func(n string) string { if n == "held" { return "update_failed" } return "" }, stop: func(n string) error { calls = append(calls, "stop:"+n); return nil }, hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil }, notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) }, } stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now()) if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" { t.Fatalf("calls = %v โ€” want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls) } if len(stopped) != 1 || stopped[0] != "gokapi" { t.Fatalf("stopped = %v", stopped) } } // A failed stop is neither held nor announced (the household would read "we stopped it" over a running app). func TestD28_AFailedStopIsNotAnnounced(t *testing.T) { var calls []string d := unhealthyDeps{ verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict { return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}} }, suppressed: func() map[string]bool { return nil }, busy: func(string) (bool, string) { return false, "" }, holdKind: func(string) string { return "" }, stop: func(string) error { return io.ErrUnexpectedEOF }, hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil }, notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") }, } stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now()) if len(calls) != 0 { t.Fatalf("calls = %v after a failed stop", calls) } } // The wiring: main's dead-app loop calls stopUnhealthyApps with the detector. func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) { found, withDetector := false, false ast.Inspect(mainBody(t), func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" { found = true ast.Inspect(call, func(m ast.Node) bool { if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" { withDetector = true } return true }) } return true }) if !found || !withDetector { t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v โ€” decision 28 is built and never run", found, withDetector) } }