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") } }