hub v0.114.0: self-bind auto-send while a customer waits for a box (R-509); node_* bypass the quiet hour (ruling 2026-09-15); PBS re-issue adopts an endpoint token (R-511); controller supervisor events (R-523); event registers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-523 — the checker that turns the agent's controller_supervisor stanza into events.
|
||||
// The JSON below is the exact wire shape the agent's TestControllerSupervisorStanza_WireShape pins.
|
||||
|
||||
func supReport(guests string) []byte {
|
||||
return []byte(`{"host_id":"h1","controller_supervisor":{"guests":` + guests + `}}`)
|
||||
}
|
||||
|
||||
type evRec struct {
|
||||
mu sync.Mutex
|
||||
evs []string
|
||||
}
|
||||
|
||||
func (r *evRec) fn(_, et, sev, _, _, _ string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.evs = append(r.evs, et+"/"+sev)
|
||||
}
|
||||
func (r *evRec) take() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := r.evs
|
||||
r.evs = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// The consequence: an agent restart of a controller reaches the dispatcher as
|
||||
// controller_restarted_by_agent, once; a crash-loop reaches it as controller_crashloop (error).
|
||||
//
|
||||
// RED-PROOF: make observe() skip the `LastRestartAt != prev.lastRestartAt` branch → the second
|
||||
// Check emits nothing → "restart did not produce controller_restarted_by_agent".
|
||||
func TestControllerSupervisorChecker_EmitsOnMovement(t *testing.T) {
|
||||
st := newCapStore(t)
|
||||
rec := &evRec{}
|
||||
c := NewControllerSupervisorChecker(st, rec.fn, log.New(io.Discard, "", 0))
|
||||
|
||||
// Seed: an old restart already recorded at first sight → silent.
|
||||
st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":1,"last_restart_at":"2026-09-15T08:00:00Z","last_reason":"exited","crashloop":false,"parked":false}]`), store.HostReportDenorm{})
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 0 {
|
||||
t.Fatalf("first observation must seed silently, got %v", evs)
|
||||
}
|
||||
// Same record again → silent.
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 0 {
|
||||
t.Fatalf("unchanged record emitted %v", evs)
|
||||
}
|
||||
// A new restart.
|
||||
st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":2,"last_restart_at":"2026-09-15T09:00:00Z","last_reason":"exited","crashloop":false,"parked":false}]`), store.HostReportDenorm{})
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_restarted_by_agent/info" {
|
||||
t.Fatalf("restart did not produce controller_restarted_by_agent (got %v)", evs)
|
||||
}
|
||||
// Agent restarted (counter back to 1) with a NEW timestamp → still an event.
|
||||
st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":1,"last_restart_at":"2026-09-15T10:00:00Z","last_reason":"absent","crashloop":false,"parked":false}]`), store.HostReportDenorm{})
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_restarted_by_agent/info" {
|
||||
t.Fatalf("a restart after an agent restart (counter reset) was lost: %v", evs)
|
||||
}
|
||||
// Crash-loop.
|
||||
st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":4,"last_restart_at":"2026-09-15T10:00:00Z","last_reason":"exited","crashloop":true,"crashloop_since":"2026-09-15T10:05:00Z","parked":false}]`), store.HostReportDenorm{})
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_crashloop/error" {
|
||||
t.Fatalf("crash-loop did not produce controller_crashloop/error (got %v)", evs)
|
||||
}
|
||||
// A pre-v0.131.0 report (no stanza) → nothing.
|
||||
st.SaveHostReport("h1", "c1", []byte(`{"host_id":"h1"}`), store.HostReportDenorm{})
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 0 {
|
||||
t.Fatalf("a report without the stanza emitted %v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// A hub restarted DURING a crash-loop must say so once, not seed it away.
|
||||
func TestControllerSupervisorChecker_CrashloopAtFirstSightEmits(t *testing.T) {
|
||||
st := newCapStore(t)
|
||||
rec := &evRec{}
|
||||
c := NewControllerSupervisorChecker(st, rec.fn, log.New(io.Discard, "", 0))
|
||||
st.SaveHostReport("h1", "c1", supReport(`[{"vmid":9201,"restarts_total":3,"last_restart_at":"2026-09-15T10:00:00Z","crashloop":true,"crashloop_since":"2026-09-15T10:05:00Z"}]`), store.HostReportDenorm{})
|
||||
c.Check()
|
||||
c.Check()
|
||||
if evs := rec.take(); len(evs) != 1 || evs[0] != "controller_crashloop/error" {
|
||||
t.Fatalf("crash-loop at first sight: want exactly one controller_crashloop, got %v", evs)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user