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

81 lines
3.8 KiB
Go

package main
import (
"os"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1350 (measured 2026-10-29 14:57 on Tester 1): the operator cleared gokapi's crash-loop hold from the hub, the app
// stayed stopped (the clear starts nothing), and 30 s later the dead-app scan sent „Telepített alkalmazás nem fut:
// Gokapi" — the hold had kept the app out of the scan, and the household's recorded wish was still „running".
// The fix: the clear records that the stop is WANTED (desired state „stopped") for an app that is not up, BEFORE the
// hold is lifted; a later Start records „running" and ends it.
// The consequence, through the scan's own derivation: a stopped app whose wish is „running" is DOWN (the alarm); the
// same app with the wish „stopped" is not.
func TestR1350_AStopWantedIsNotDown(t *testing.T) {
st := func(desired string) []stacks.Stack {
return []stacks.Stack{{Name: "gokapi", Deployed: true, State: stacks.StateStopped, AppConfig: &stacks.AppConfig{DesiredState: desired}}}
}
if _, states := classifyRunStates(st(stacks.DesiredStateRunning), nil, nil, time.Now()); len(states) != 1 || !states[0].Down {
t.Fatalf("fixture: a stopped app wished running must be down: %+v", states)
}
if _, states := classifyRunStates(st(stacks.DesiredStateStopped), nil, nil, time.Now()); len(states) != 1 || states[0].Down {
t.Fatalf("a stopped app wished stopped is down — the alarm fires: %+v", states)
}
}
// The clear records the wish BEFORE the hold is lifted (a dead-app scan between the two would read „no hold, wished
// running" and mail), and only for an app that IS held. RED-PROOF: drop the keepStopped call from the clear → FAILS
// („not recorded"); call it after the lift → FAILS („after the hold was lifted").
func TestR1350_TheClearRecordsTheStopIsWanted(t *testing.T) {
r := newD64Rig(t)
r.hold(t, settings.RestoreHold{Stack: "gokapi", Reason: settings.HoldReasonUnhealthyStop, UnhealthyKind: "crash_loop", Trip: 1})
var recorded []string
var heldWhenRecorded bool
r.holdDeps.keepStopped = func(stack string) (bool, error) {
recorded = append(recorded, stack)
_, heldWhenRecorded = r.sett.GetRestoreHold(stack)
return true, nil
}
o := r.actions()
o.Reconcile([]report.OperatorAction{{ID: 21, Action: report.OpClearHold, Arg: "gokapi"}})
if res := onlyResult(t, o, 21); res.Outcome != report.OutcomeDone {
t.Fatalf("result %+v", res)
}
if len(recorded) != 1 || recorded[0] != "gokapi" {
t.Fatalf("the stop was not recorded as wanted: %v", recorded)
}
if !heldWhenRecorded {
t.Fatal("the wish was recorded after the hold was lifted — a scan in between mails app_start_failed")
}
if lg := r.logBuf.String(); !strings.Contains(lg, "gokapi: the stop is recorded as wanted") {
t.Fatalf("the box's log must say it:\n%s", lg)
}
// a refusal (no hold) records nothing — a down app with no hold is a real fault the alarm must keep
o.Reconcile([]report.OperatorAction{{ID: 22, Action: report.OpClearHold, Arg: "kimai"}})
if res := onlyResult(t, o, 22); res.Outcome != report.OutcomeRefused {
t.Fatalf("result %+v", res)
}
if len(recorded) != 1 {
t.Fatalf("a refused clear recorded a wish: %v", recorded)
}
}
// The running controller's clear is wired with the record. RED-PROOF: drop keepStopped from main.go's holdDeps → FAILS.
func TestR1350_MainWiresTheRecord(t *testing.T) {
raw, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "keepStopped: stackMgr.RecordStopWantedIfDown") || !strings.Contains(string(raw), "clear: appStopGuard.ClearHoldByOperatorThen") {
t.Fatal("main.go's hold clear does not record the wanted stop (R-1350)")
}
}