12f038618e
Constructors seed only healthy hosts; an already-degraded/stale host is left unseeded so the first Check() emits once (cooldown dedups on hub restart). Born-degraded red-proof + staleness test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
133 lines
5.3 KiB
Go
133 lines
5.3 KiB
Go
package monitor
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// reportWith builds a host-report body carrying the given capability snapshot (the only field the
|
|
// capability checker reads). One critical-degraded entry makes the host "degraded".
|
|
func reportWith(caps string) []byte {
|
|
return []byte(`{"host_id":"h1","capabilities":` + caps + `}`)
|
|
}
|
|
|
|
const capAllOK = `[{"name":"guest-init-pid","feature":"drive-gate","critical":true,"status":"ok"}]`
|
|
const capCritDegraded = `[{"name":"guest-init-pid","feature":"drive-gate guest-sees","critical":true,"status":"degraded","reason":"sudo policy denied"}]`
|
|
const capNonCritDegraded = `[{"name":"disk-smart","feature":"SMART","critical":false,"status":"degraded","reason":"binary not found"}]`
|
|
|
|
func newCapStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
st, err := store.New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ck", RetrievalPassword: "p"})
|
|
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"})
|
|
return st
|
|
}
|
|
|
|
// TestHostCapabilityChecker covers the full transition contract (§10): seed (no event), ok→degraded
|
|
// (one event), steady degraded (none), degraded→ok (recovered), and that a non-critical degradation
|
|
// never trips the host state.
|
|
func TestHostCapabilityChecker(t *testing.T) {
|
|
st := newCapStore(t)
|
|
st.SaveHostReport("h1", "c1", reportWith(capAllOK), store.HostReportDenorm{})
|
|
|
|
var events []string
|
|
onEvent := func(_, eventType, _, _, _, _ string) { events = append(events, eventType) }
|
|
|
|
// Seed ok → no event on init.
|
|
cc := NewHostCapabilityChecker(st, onEvent, log.New(io.Discard, "", 0))
|
|
if cc.GetState("h1") != "ok" {
|
|
t.Fatalf("seed state = %s, want ok", cc.GetState("h1"))
|
|
}
|
|
if len(events) != 0 {
|
|
t.Fatalf("seed must not emit, got %v", events)
|
|
}
|
|
|
|
// ok → degraded: exactly ONE operator event.
|
|
st.SaveHostReport("h1", "c1", reportWith(capCritDegraded), store.HostReportDenorm{})
|
|
cc.Check()
|
|
if cc.GetState("h1") != "degraded" {
|
|
t.Fatalf("state = %s, want degraded", cc.GetState("h1"))
|
|
}
|
|
if len(events) != 1 || events[0] != "agent_capability_degraded" {
|
|
t.Fatalf("want one agent_capability_degraded, got %v", events)
|
|
}
|
|
|
|
// Steady degraded: NO duplicate event.
|
|
st.SaveHostReport("h1", "c1", reportWith(capCritDegraded), store.HostReportDenorm{})
|
|
cc.Check()
|
|
if len(events) != 1 {
|
|
t.Fatalf("steady degraded must not re-emit, got %v", events)
|
|
}
|
|
|
|
// degraded → ok: recovered event.
|
|
st.SaveHostReport("h1", "c1", reportWith(capAllOK), store.HostReportDenorm{})
|
|
cc.Check()
|
|
if cc.GetState("h1") != "ok" {
|
|
t.Fatalf("state = %s, want ok", cc.GetState("h1"))
|
|
}
|
|
if len(events) != 2 || events[1] != "agent_capability_recovered" {
|
|
t.Fatalf("want recovered event, got %v", events)
|
|
}
|
|
}
|
|
|
|
// A NON-critical degradation must NOT flip the host to degraded (no operator noise).
|
|
func TestHostCapabilityChecker_NonCriticalIgnored(t *testing.T) {
|
|
st := newCapStore(t)
|
|
st.SaveHostReport("h1", "c1", reportWith(capAllOK), store.HostReportDenorm{})
|
|
var events []string
|
|
cc := NewHostCapabilityChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
|
|
|
st.SaveHostReport("h1", "c1", reportWith(capNonCritDegraded), store.HostReportDenorm{})
|
|
cc.Check()
|
|
if cc.GetState("h1") != "ok" {
|
|
t.Fatalf("non-critical degradation flipped state to %s, want ok", cc.GetState("h1"))
|
|
}
|
|
if len(events) != 0 {
|
|
t.Fatalf("non-critical degradation must not alert, got %v", events)
|
|
}
|
|
}
|
|
|
|
// A pre-v0.44.0 agent (no capabilities array) is "ok" — an old agent can't trip a false alert.
|
|
func TestHostCapabilityChecker_OldAgentNoCaps(t *testing.T) {
|
|
st := newCapStore(t)
|
|
st.SaveHostReport("h1", "c1", []byte(`{"host_id":"h1"}`), store.HostReportDenorm{})
|
|
cc := NewHostCapabilityChecker(st, func(_, _, _, _, _, _ string) {}, log.New(io.Discard, "", 0))
|
|
if cc.GetState("h1") != "ok" {
|
|
t.Fatalf("old agent (no caps) state = %s, want ok", cc.GetState("h1"))
|
|
}
|
|
}
|
|
|
|
// F2 RED-PROOF: a host already DEGRADED at hub (re)start is left UNSEEDED by the constructor and the
|
|
// FIRST Check emits once — so a box that was already broken when the hub restarted alerts (vs the
|
|
// pre-fix path which seeded it silently and never alerted). Companion: pre-fix the constructor seeded
|
|
// "degraded" and Check saw oldState==newState → no event.
|
|
func TestHostCapabilityChecker_F2_BornDegradedAlerts(t *testing.T) {
|
|
st := newCapStore(t)
|
|
st.SaveHostReport("h1", "c1", reportWith(capCritDegraded), store.HostReportDenorm{})
|
|
var events []string
|
|
cc := NewHostCapabilityChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
|
if len(events) != 0 {
|
|
t.Fatalf("construction must not emit, got %v", events)
|
|
}
|
|
if cc.GetState("h1") != "unknown" {
|
|
t.Fatalf("born-degraded must be left unseeded, GetState = %s want unknown", cc.GetState("h1"))
|
|
}
|
|
cc.Check()
|
|
if len(events) != 1 || events[0] != "agent_capability_degraded" {
|
|
t.Fatalf("born-degraded first Check → one agent_capability_degraded, got %v", events)
|
|
}
|
|
cc.Check() // steady degraded → no duplicate
|
|
if len(events) != 1 {
|
|
t.Fatalf("steady degraded must not re-emit, got %v", events)
|
|
}
|
|
}
|