206b0357d1
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
88 lines
3.0 KiB
Go
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")
|
|
}
|
|
}
|