Files
felhom-controller/controller/cmd/controller/r660_held_not_down_test.go
T

88 lines
3.0 KiB
Go

package main
import (
"go/ast"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own
// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and
// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact.
type fakeHeld map[string]bool
func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f }
// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds.
// A genuinely exited app beside it must still alarm (the over-correction guard).
//
// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app
// was reported DOWN".
func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) {
sts := []stacks.Stack{
stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update
stack("romm", stacks.StateExited, true, false), // genuinely broken
}
// A held app's intent is "running" — the household never asked for the stop.
for i := range sts {
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
}
dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now())
down := downByName(states)
if down["nextcloud"] || deadNames(dead)["nextcloud"] {
t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)")
}
if !down["romm"] {
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault")
}
// Control: WITHOUT the held set the same app does alarm — so the suppression is what decides.
_, states = classifyRunStates(sts, nil, nil, time.Now())
if !downByName(states)["nextcloud"] {
t.Fatal("control failed: the fixture's app does not alarm even without the suppression")
}
}
// The wiring: main passes the backup manager, and the scan unions its held set.
func TestR660_ScanIsGivenTheHeldSet(t *testing.T) {
_, f, _ := slice4CallLines(t)
reads := false
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" {
reads = true
}
}
return true
})
}
if !reads {
t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)")
}
withB := 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 == "scanDeployedAppRunStates" {
for _, a := range call.Args {
if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" {
withB = true
}
}
}
return true
})
if !withB {
t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read")
}
}