7e21cd06b3
Stub and LLM paths both read loop.DownServices, so the message can never name a service the predicate did not fire on. Two down at once are both named — he needs the blast radius.
502 lines
17 KiB
Go
502 lines
17 KiB
Go
package loop
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// Direct tests for the five default rule predicates.
|
|
//
|
|
// A predicate is pure — (State) -> bool, no I/O — so these need no store and no
|
|
// daemon. They test the predicate ALONE: the restraint gate is tested in
|
|
// loop_test.go and gate_test.go, never here.
|
|
//
|
|
// Every rule gets the same three questions plus its own edges:
|
|
// - does it fire when it should?
|
|
// - does it stay quiet when it should?
|
|
// - is it silent when the key it needs has no data at all?
|
|
//
|
|
// The last one is load-bearing. docs/design.md: "since(key)==null → don't fire.
|
|
// Silence on no-data is 'shuts up when uncertain'."
|
|
|
|
// stateWith builds a snapshot at refTime() holding just the given facts.
|
|
// Presence and the env flags are left zero — the predicate must not read them.
|
|
func stateWith(facts map[string]store.Fact) State {
|
|
return State{Now: refTime(), Facts: facts}
|
|
}
|
|
|
|
// ago is a fact for key written `d` before refTime().
|
|
func ago(key, source, value string, d time.Duration) store.Fact {
|
|
return factAt(key, source, value, refTime().Add(-d))
|
|
}
|
|
|
|
// ---------------------------- since-based care rules -------------------------
|
|
|
|
// The three care rules share one shape: "fire when it has been at least N since
|
|
// the last fact for key". One table drives all of them.
|
|
func TestCareRulePredicates(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
rule Rule
|
|
facts map[string]store.Fact
|
|
want bool
|
|
}{
|
|
// water — threshold 3h.
|
|
{
|
|
name: "water fires at 4h",
|
|
rule: WaterRule(),
|
|
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "water fires exactly at the 3h threshold",
|
|
rule: WaterRule(),
|
|
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "water quiet just under 3h",
|
|
rule: WaterRule(),
|
|
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "water quiet on no data",
|
|
rule: WaterRule(),
|
|
facts: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "water quiet on a zero-timestamp fact",
|
|
rule: WaterRule(),
|
|
facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "water quiet when the only fact is for another key",
|
|
rule: WaterRule(),
|
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)},
|
|
want: false,
|
|
},
|
|
|
|
// meal — threshold 6h.
|
|
{
|
|
name: "meal fires at 7h",
|
|
rule: MealRule(),
|
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "meal fires exactly at the 6h threshold",
|
|
rule: MealRule(),
|
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "meal quiet just under 6h",
|
|
rule: MealRule(),
|
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "meal quiet on no data",
|
|
rule: MealRule(),
|
|
facts: nil,
|
|
want: false,
|
|
},
|
|
|
|
// break — needs BOTH anchors: at the desk now, and no break for 90min.
|
|
{
|
|
name: "break fires when at desk and no break for 2h",
|
|
rule: BreakRule(),
|
|
facts: map[string]store.Fact{
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "break fires exactly at both thresholds",
|
|
rule: BreakRule(),
|
|
facts: map[string]store.Fact{
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute),
|
|
"break": ago("break", "voice", `"walk"`, 90*time.Minute),
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "break quiet when the desk signal is stale (user left)",
|
|
rule: BreakRule(),
|
|
facts: map[string]store.Fact{
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute),
|
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "break quiet when the last break was recent",
|
|
rule: BreakRule(),
|
|
facts: map[string]store.Fact{
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
|
"break": ago("break", "voice", `"walk"`, 20*time.Minute),
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "break quiet with only the desk anchor",
|
|
rule: BreakRule(),
|
|
facts: map[string]store.Fact{
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "break quiet with only the break anchor",
|
|
rule: BreakRule(),
|
|
facts: map[string]store.Fact{
|
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "break quiet on no data",
|
|
rule: BreakRule(),
|
|
facts: nil,
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
|
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------- ops rules --------------------------------------
|
|
|
|
// The two ops rules match on a value AND on which poller wrote it. docs/design.md:
|
|
// "a compromised poller must not be able to forge a trigger." Half of this
|
|
// table is forgery attempts; all of them must be refused.
|
|
func TestOpsRulePredicates(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
rule Rule
|
|
facts map[string]store.Fact
|
|
want bool
|
|
}{
|
|
// service_down — only poll:uptimekuma may say a service is down.
|
|
{
|
|
name: "service_down fires on a kuma down fact",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "service_down quiet when kuma says up",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "service_down quiet on no data",
|
|
rule: ServiceDownRule(),
|
|
facts: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "service_down quiet on a zero-timestamp fact",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
|
|
want: false,
|
|
},
|
|
// forgery attempts — right value, wrong writer.
|
|
{
|
|
name: "service_down refuses a forgery from the netdata poller",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "service_down refuses a forgery from ambient audio",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "service_down refuses a forgery from the user's own voice",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "service_down refuses a source that only looks like kuma",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "service_down refuses an unquoted down value",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
|
|
want: false,
|
|
},
|
|
|
|
// netdata_critical — only poll:netdata may raise a critical alarm.
|
|
{
|
|
name: "netdata_critical fires on a netdata critical alarm",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "netdata_critical quiet on a warning alarm",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "netdata_critical quiet on a cleared alarm",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "netdata_critical quiet on no data",
|
|
rule: NetdataCriticalRule(),
|
|
facts: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "netdata_critical quiet on a zero-timestamp fact",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "netdata_critical refuses a forgery from the kuma poller",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "netdata_critical refuses a forgery from ambient audio",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "netdata_critical reads netdata_alarm, not netdata_critical",
|
|
rule: NetdataCriticalRule(),
|
|
facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
|
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------- rule metadata ----------------------------------
|
|
|
|
// Every default rule must declare the keys it needs. The gate uses that list as
|
|
// a second no-data backstop, so a rule that forgets it loses the safety net
|
|
// even if its predicate happens to check.
|
|
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
|
// A rule over a key set that only exists at read time declares a prefix
|
|
// instead — the gatherer still needs to be told what to load.
|
|
for _, r := range DefaultRules() {
|
|
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
|
|
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A no-data snapshot must make EVERY default rule quiet, predicate alone, with
|
|
// the gate out of the picture. This is the whole-set version of the per-rule
|
|
// no-data cases above.
|
|
func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
|
|
empty := stateWith(nil)
|
|
for _, r := range DefaultRules() {
|
|
if r.Predicate(empty) {
|
|
t.Errorf("rule %q fires on an empty snapshot", r.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Severities are the delivery contract (docs/design.md § Delivery / channel
|
|
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
|
|
// them so a change to a rule's insistence has to be deliberate.
|
|
func TestDefaultRuleSeverities(t *testing.T) {
|
|
want := map[string]Severity{
|
|
"water": Sev1,
|
|
"meal": Sev1,
|
|
"break": Sev2,
|
|
"service_down": Sev4,
|
|
"netdata_critical": Sev3,
|
|
}
|
|
got := map[string]Severity{}
|
|
for _, r := range DefaultRules() {
|
|
got[r.Name] = r.Severity
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("rule count changed: want %d, got %d", len(want), len(got))
|
|
}
|
|
for name, sev := range want {
|
|
if got[name] != sev {
|
|
t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cooldown bounds keep the feedback tuner honest — docs/design.md wants
|
|
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
|
|
// stalker". A base outside its own envelope would make that meaningless.
|
|
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
|
for _, r := range DefaultRules() {
|
|
c := r.Cooldown
|
|
if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 {
|
|
t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c)
|
|
continue
|
|
}
|
|
if c.Base < c.Min || c.Base > c.Max {
|
|
t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A predicate must read only the snapshot it is handed. Same snapshot twice
|
|
// (and a snapshot shared between two rules) must give the same answer — no
|
|
// hidden state, no clock reads.
|
|
func TestPredicatesArePure(t *testing.T) {
|
|
s := stateWith(map[string]store.Fact{
|
|
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
|
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
|
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
|
})
|
|
for _, r := range DefaultRules() {
|
|
first := r.Predicate(s)
|
|
for i := 0; i < 3; i++ {
|
|
if again := r.Predicate(s); again != first {
|
|
t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func ruleNames(rs []Rule) []string {
|
|
out := make([]string, len(rs))
|
|
for i, r := range rs {
|
|
out[i] = r.Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestRulesExceptDropsOnlyTheNamed(t *testing.T) {
|
|
rules, dropped := RulesExcept([]string{"service_down"})
|
|
if len(rules) != len(DefaultRules())-1 {
|
|
t.Fatalf("got %v", ruleNames(rules))
|
|
}
|
|
for _, r := range rules {
|
|
if r.Name == "service_down" {
|
|
t.Error("a disabled rule was still wired")
|
|
}
|
|
}
|
|
if len(dropped) != 1 || dropped[0] != "service_down" {
|
|
t.Errorf("dropped = %v", dropped)
|
|
}
|
|
}
|
|
|
|
// A config that names a rule nobody wrote must not stop the daemon booting,
|
|
// and must not quietly drop a real rule alongside it.
|
|
func TestRulesExceptIgnoresUnknownNames(t *testing.T) {
|
|
rules, dropped := RulesExcept([]string{"no_such_rule", " ", ""})
|
|
if len(rules) != len(DefaultRules()) {
|
|
t.Errorf("an unknown name removed something: %v", ruleNames(rules))
|
|
}
|
|
if len(dropped) != 0 {
|
|
t.Errorf("dropped = %v, want nothing", dropped)
|
|
}
|
|
}
|
|
|
|
func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
|
|
rules, dropped := RulesExcept(nil)
|
|
if len(rules) != len(DefaultRules()) || dropped != nil {
|
|
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
|
}
|
|
}
|
|
|
|
// ---------------------------- per-monitor service_down -----------------------
|
|
|
|
// The rule must name what fired on it, and the phraser reads the same helper,
|
|
// so a service that is up can never be spoken as down.
|
|
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
|
s := State{
|
|
Now: refTime(),
|
|
Facts: map[string]store.Fact{
|
|
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
|
|
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
|
|
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
|
|
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
|
|
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
|
|
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
|
|
},
|
|
}
|
|
got := DownServices(s)
|
|
want := []string{"db", "vault"} // key order, so speech is stable
|
|
if len(got) != len(want) {
|
|
t.Fatalf("DownServices = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("DownServices = %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
|
|
// the aggregate stayed "down" and pausing achieved nothing.
|
|
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
|
|
base := map[string]store.Fact{
|
|
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
|
|
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
|
|
}
|
|
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
|
t.Fatal("web is still down, the rule must fire")
|
|
}
|
|
delete(base, "service_down:web")
|
|
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
|
t.Fatal("only a paused monitor is left, the rule must be quiet")
|
|
}
|
|
}
|
|
|
|
// Edge-triggered: he is told once per transition. A service that stays down
|
|
// for a day used to qualify on every tick, with cooldown as the only brake.
|
|
func TestServiceDownFiresOncePerTransition(t *testing.T) {
|
|
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
|
|
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
|
|
if !ServiceDownRule().Predicate(s) {
|
|
t.Fatal("first sight of the transition must fire")
|
|
}
|
|
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
|
|
if ServiceDownRule().Predicate(s) {
|
|
t.Fatal("already told about this transition, must be quiet")
|
|
}
|
|
// A second service goes down after that nudge — a new edge, so it fires.
|
|
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
|
|
if !ServiceDownRule().Predicate(s) {
|
|
t.Fatal("a later transition must fire again")
|
|
}
|
|
}
|