8fdb9e5cd1
The kuma service_down nudge cannot name the service. mavpoll folds the whole monitor_status gauge into one boolean fact keyed `service_down`, and the phraser names a service only when the fact key is the service name, so the message is always the generic "a service on homesrv is down". Every fifteen minutes, with nothing to act on. A fact per monitor is the real fix and it is filed as Vikunja #444; this is what to do until then. `disabled_rules` in mavend.json subtracts from loop.DefaultRules by name. Config only subtracts — rules stay code, the set stays canonical and ordered as written. A disabled rule is not gathered for either, since the gatherer derives its key set from the rules it was given. Unknown names are ignored so deleting a rule cannot brick a config that still lists it, and the boot log says what was dropped, because a rule that vanishes silently looks exactly like a rule that is broken. Deploy turns service_down off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
437 lines
14 KiB
Go
437 lines
14 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. 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. 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": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "service_down quiet when kuma says up",
|
|
rule: ServiceDownRule(),
|
|
facts: map[string]store.Fact{"service_down": ago("service_down", "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": {Key: "service_down", 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": ago("service_down", "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": ago("service_down", "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": ago("service_down", "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": ago("service_down", "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": ago("service_down", "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) {
|
|
for _, r := range DefaultRules() {
|
|
if len(r.InertWhenNoData) == 0 {
|
|
t.Errorf("rule %q declares no InertWhenNoData keys", 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 (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 — 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": ago("service_down", "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)
|
|
}
|
|
}
|