controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -864,7 +864,8 @@ func main() {
|
||||
alertMgr.SetDeadAppAlerts(dead)
|
||||
notifier.NotifyAppStartFailures(states)
|
||||
// R-514: a worker OOM-killed inside a running container leaves the app „Fut". Surface it.
|
||||
if ooms, oerr := stackMgr.ScanOOMKilled(); oerr != nil {
|
||||
ooms, oerr := stackMgr.ScanOOMKilled()
|
||||
if oerr != nil {
|
||||
logger.Printf("[WARN] [deadapp] OOM scan failed: %v", oerr)
|
||||
} else {
|
||||
for _, o := range ooms {
|
||||
@@ -872,6 +873,35 @@ func main() {
|
||||
notifier.NotifyAppOOM(o.Stack, o.Container, o.StartedAt, o.Kills, o.MemLimit, o.Peak)
|
||||
}
|
||||
}
|
||||
// v0.269.0 (`09` §3 decision 28, R-667): a crash loop or an OOM storm is STOPPED by the box.
|
||||
stopUnhealthyApps(logger, unhealthyDeps{
|
||||
verdicts: stackMgr.ObserveUnhealthy,
|
||||
suppressed: func() map[string]bool {
|
||||
return unionSuppressed(unionSuppressed(quiesceLoop.SuppressedStacks(), appStopGuard.SuppressedStacks()), stackMgr.UpdatingStacks())
|
||||
},
|
||||
busy: func(name string) (bool, string) {
|
||||
if backupMgr == nil {
|
||||
return false, ""
|
||||
}
|
||||
return backupMgr.UpdateBusy(name)
|
||||
},
|
||||
holdKind: func(name string) string {
|
||||
if backupMgr == nil {
|
||||
return ""
|
||||
}
|
||||
return backupMgr.HoldKind(name)
|
||||
},
|
||||
stop: stackMgr.StopStack,
|
||||
hold: func(name, kind string, at time.Time) (int, error) {
|
||||
if backupMgr == nil {
|
||||
return 0, fmt.Errorf("backup is not enabled on this box — the stop cannot be held")
|
||||
}
|
||||
return backupMgr.HoldUnhealthy(name, kind, at)
|
||||
},
|
||||
notify: func(v stacks.UnhealthyVerdict, trip int) {
|
||||
notifier.NotifyAppStoppedUnhealthy(v.Stack, v.Kind, v.Count, v.Window, trip)
|
||||
},
|
||||
}, ooms, time.Now())
|
||||
deadAppScans++
|
||||
noteDeadAppScan(logger, deadAppScans, len(states), len(dead))
|
||||
noteUnknownIntentSuppressions(logger, states)
|
||||
@@ -3586,6 +3616,14 @@ func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at time.Time, r
|
||||
return err
|
||||
}
|
||||
|
||||
// HoldKind names the hold in force (v0.269.0) — the page offers Start for an unhealthy stop.
|
||||
func (a *updateGuardsAdapter) HoldKind(name string) string {
|
||||
if a.b == nil {
|
||||
return ""
|
||||
}
|
||||
return a.b.HoldKind(name)
|
||||
}
|
||||
|
||||
// HoldNoWholeCopy is the page's half of R-659: a hold naming no copy gets no restore button.
|
||||
func (a *updateGuardsAdapter) HoldNoWholeCopy(name string) bool {
|
||||
if a.b == nil {
|
||||
@@ -3593,3 +3631,54 @@ func (a *updateGuardsAdapter) HoldNoWholeCopy(name string) bool {
|
||||
}
|
||||
return a.b.HoldNoWholeCopy(name)
|
||||
}
|
||||
|
||||
// ── Decision 28 (v0.269.0): the box stops an app in a crash loop or an out-of-memory storm ──────────
|
||||
|
||||
// unhealthyDeps are the seams of stopUnhealthyApps — every one wired in main to the real component.
|
||||
type unhealthyDeps struct {
|
||||
verdicts func(now time.Time, ooms []stacks.OOMContainer) []stacks.UnhealthyVerdict
|
||||
suppressed func() map[string]bool
|
||||
busy func(name string) (bool, string)
|
||||
holdKind func(name string) string
|
||||
stop func(name string) error
|
||||
hold func(name, kind string, at time.Time) (int, error)
|
||||
notify func(v stacks.UnhealthyVerdict, trip int)
|
||||
}
|
||||
|
||||
// stopUnhealthyApps acts on the detector's verdicts. NEVER during a deploy or an update (the detector
|
||||
// skips those), a backup's or a restore's own stop, an app-data operation or a whole-guest quiesce (the
|
||||
// suppression sets, `08` §5), or on an app already held. Order: stop, then the hold (so no start path
|
||||
// revives it), then the events. Returns the apps stopped.
|
||||
func stopUnhealthyApps(logger *log.Logger, d unhealthyDeps, ooms []stacks.OOMContainer, now time.Time) []string {
|
||||
var stopped []string
|
||||
vs := d.verdicts(now, ooms)
|
||||
if len(vs) == 0 {
|
||||
return nil
|
||||
}
|
||||
sup := d.suppressed()
|
||||
for _, v := range vs {
|
||||
switch {
|
||||
case sup[v.Stack]:
|
||||
logger.Printf("[INFO] [deadapp] %s crossed the %s threshold (%d in %s) while a backup/quiesce/update holds it — NOT stopped", v.Stack, v.Kind, v.Count, v.Window)
|
||||
continue
|
||||
case d.holdKind(v.Stack) != "":
|
||||
continue
|
||||
}
|
||||
if busy, why := d.busy(v.Stack); busy {
|
||||
logger.Printf("[INFO] [deadapp] %s crossed the %s threshold but %s — NOT stopped", v.Stack, v.Kind, why)
|
||||
continue
|
||||
}
|
||||
logger.Printf("[WARN] [deadapp] %s: %s — %d in %s; STOPPING it (decision 28)", v.Stack, v.Kind, v.Count, v.Window)
|
||||
if err := d.stop(v.Stack); err != nil {
|
||||
logger.Printf("[ERROR] [deadapp] stopping %s failed: %v — not held, not announced", v.Stack, err)
|
||||
continue
|
||||
}
|
||||
trip, err := d.hold(v.Stack, v.Kind, now)
|
||||
if err != nil {
|
||||
logger.Printf("[ERROR] [deadapp] %s: %v", v.Stack, err)
|
||||
}
|
||||
d.notify(v, trip)
|
||||
stopped = append(stopped, v.Stack)
|
||||
}
|
||||
return stopped
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user