carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package metrics
2
3import (
4 "encoding/json"
5 "testing"
6 "time"
7)
8
9// TestRuleStateJSONRoundTrip pins the true wire shape: `omitempty` on a
10// time.Time field is a no-op in encoding/json (structs are never "empty"),
11// so a zero RuleState's since/fired_at keys are always present, carrying
12// the zero-time string — not omitted. Round-tripping is still lossless for
13// both a zero and a non-zero RuleState, which is what consumers (the agent
14// loop's persistence, the console watcher) actually depend on.
15func TestRuleStateJSONRoundTrip(t *testing.T) {
16 zero := RuleState{Firing: false}
17 b, err := json.Marshal(zero)
18 if err != nil {
19 t.Fatalf("marshal zero RuleState: %v", err)
20 }
21 const wantZero = `{"firing":false,"since":"0001-01-01T00:00:00Z","fired_at":"0001-01-01T00:00:00Z"}`
22 if string(b) != wantZero {
23 t.Fatalf("zero RuleState wire shape changed:\n got: %s\n want: %s", b, wantZero)
24 }
25 var gotZero RuleState
26 if err := json.Unmarshal(b, &gotZero); err != nil {
27 t.Fatalf("unmarshal zero RuleState: %v", err)
28 }
29 if !gotZero.Since.Equal(zero.Since) || !gotZero.FiredAt.Equal(zero.FiredAt) || gotZero != zero {
30 t.Fatalf("zero RuleState did not round-trip: got %+v want %+v", gotZero, zero)
31 }
32
33 since := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
34 firedAt := since.Add(10 * time.Minute)
35 nonZero := RuleState{Firing: true, Since: since, FiredAt: firedAt, Value: 0.95}
36 b, err = json.Marshal(nonZero)
37 if err != nil {
38 t.Fatalf("marshal non-zero RuleState: %v", err)
39 }
40 var gotNonZero RuleState
41 if err := json.Unmarshal(b, &gotNonZero); err != nil {
42 t.Fatalf("unmarshal non-zero RuleState: %v", err)
43 }
44 if !gotNonZero.Since.Equal(nonZero.Since) || !gotNonZero.FiredAt.Equal(nonZero.FiredAt) || gotNonZero.Firing != nonZero.Firing || gotNonZero.Value != nonZero.Value {
45 t.Fatalf("non-zero RuleState did not round-trip: got %+v want %+v", gotNonZero, nonZero)
46 }
47}
48
49func TestEvaluateMemSustainAndHysteresis(t *testing.T) {
50 r := DefaultRules()
51 st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
52 t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
53 obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} }
54
55 st, ev := Evaluate(r, st, obs(0.95, t0)) // above, sustain starts — no event yet
56 if len(ev) != 0 || st.Rules["mem"].Firing {
57 t.Fatalf("must not fire before sustain: ev=%v firing=%v", ev, st.Rules["mem"].Firing)
58 }
59 st, ev = Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute))) // sustain met — fires
60 if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "mem" {
61 t.Fatalf("want fire event, got %v", ev)
62 }
63 st, ev = Evaluate(r, st, obs(0.88, t0.Add(11*time.Minute))) // hysteresis band (0.85..0.90) — stays firing
64 if len(ev) != 0 || !st.Rules["mem"].Firing {
65 t.Fatal("must hold inside hysteresis band")
66 }
67 st, ev = Evaluate(r, st, obs(0.80, t0.Add(12*time.Minute))) // below clear line — clears
68 if len(ev) != 1 || ev[0].Firing {
69 t.Fatalf("want clear event, got %v", ev)
70 }
71}
72
73func TestEvaluateDiskFiresImmediately(t *testing.T) {
74 r := DefaultRules()
75 st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
76 t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
77 d := 0.95
78
79 _, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d})
80 if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "disk" {
81 t.Fatalf("disk rule is immediate (sustain 0): want fire on first tick, got %v", ev)
82 }
83}
84
85func TestEvaluateSustainResetsOnDip(t *testing.T) {
86 r := DefaultRules()
87 st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
88 t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
89 obs := func(m float64, at time.Time) Observation { return Observation{At: at, MemFrac: &m} }
90
91 st, _ = Evaluate(r, st, obs(0.95, t0)) // sustain starts
92 st, _ = Evaluate(r, st, obs(0.80, t0.Add(5*time.Minute))) // dip below threshold — resets Since
93 if !st.Rules["mem"].Since.IsZero() {
94 t.Fatalf("dip below threshold must reset Since, got %v", st.Rules["mem"].Since)
95 }
96 // Climb back above threshold; Since restarts at t0+10m. If sustain had
97 // wrongly resumed counting from the original t0, this would already have
98 // fired (t0+10m is 10 minutes after t0) — it must not.
99 st, ev := Evaluate(r, st, obs(0.95, t0.Add(10*time.Minute)))
100 if len(ev) != 0 || st.Rules["mem"].Firing {
101 t.Fatalf("sustain must restart after a dip, not resume from the original Since: ev=%v", ev)
102 }
103 // A full 10 minutes after the restart (t0+20m) — now it fires.
104 _, ev = Evaluate(r, st, obs(0.95, t0.Add(20*time.Minute)))
105 if len(ev) != 1 || !ev[0].Firing {
106 t.Fatalf("want fire once the restarted sustain window elapses, got %v", ev)
107 }
108}
109
110func TestEvaluateNilReadingHoldsState(t *testing.T) {
111 r := DefaultRules()
112 st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
113 t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
114 d := 0.95
115
116 // Fire disk (immediate).
117 st, ev := Evaluate(r, st, Observation{At: t0, DiskFrac: &d})
118 if len(ev) != 1 || !st.Rules["disk"].Firing {
119 t.Fatalf("setup: disk should be firing, got %v %v", ev, st.Rules["disk"])
120 }
121 quietBefore := st.Rules["mem"]
122
123 // A tick with nil disk and nil mem must leave both rules exactly as they are:
124 // a firing alert stays firing, a quiet rule stays quiet — no event either way.
125 st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute)})
126 if len(ev) != 0 {
127 t.Fatalf("nil readings must never emit events, got %v", ev)
128 }
129 if !st.Rules["disk"].Firing {
130 t.Fatal("nil reading must not clear a firing rule")
131 }
132 if st.Rules["mem"] != quietBefore {
133 t.Fatalf("nil reading must not change a quiet rule's state: before=%v after=%v", quietBefore, st.Rules["mem"])
134 }
135}
136
137func TestEvaluateLagNilForeverNoEvent(t *testing.T) {
138 r := DefaultRules()
139 st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
140 t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
141
142 for i := 0; i < 5; i++ {
143 var ev []Event
144 st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Duration(i) * time.Minute)})
145 if len(ev) != 0 {
146 t.Fatalf("lag rule must never fire while readings stay nil, got %v", ev)
147 }
148 if st.Rules["lag"].Firing {
149 t.Fatal("lag rule must never fire while readings stay nil")
150 }
151 }
152}
153
154func TestEvaluateLagFiresAndClears(t *testing.T) {
155 r := DefaultRules()
156 st := AlertState{V: 1, Box: "b", Rules: map[string]RuleState{}}
157 t0 := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
158 lag := func(s int64) *int64 { return &s }
159
160 st, ev := Evaluate(r, st, Observation{At: t0, LagSecs: lag(1000)}) // > 900, immediate
161 if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "lag" {
162 t.Fatalf("want lag fire on first tick over threshold, got %v", ev)
163 }
164 // Clear line is LagSecs-300 = 600. 700 is inside the hysteresis band (600..900) — stays firing.
165 st, ev = Evaluate(r, st, Observation{At: t0.Add(time.Minute), LagSecs: lag(700)})
166 if len(ev) != 0 || !st.Rules["lag"].Firing {
167 t.Fatalf("lag must hold inside its hysteresis band, got ev=%v", ev)
168 }
169 // 500 < 600 — clears.
170 _, ev = Evaluate(r, st, Observation{At: t0.Add(2 * time.Minute), LagSecs: lag(500)})
171 if len(ev) != 1 || ev[0].Firing {
172 t.Fatalf("want lag clear below LagSecs-300, got %v", ev)
173 }
174}
175
176func TestRulesFromEnv(t *testing.T) {
177 env := func(m map[string]string) func(string) string {
178 return func(k string) string { return m[k] }
179 }
180
181 r := RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "95"}))
182 if r.DiskFrac != 0.95 {
183 t.Fatalf("want DiskFrac 0.95, got %v", r.DiskFrac)
184 }
185
186 def := DefaultRules()
187 r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_DISK_PCT": "nonsense"}))
188 if r.DiskFrac != def.DiskFrac {
189 t.Fatalf("unparsable value must keep default %v, got %v", def.DiskFrac, r.DiskFrac)
190 }
191
192 r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_MEM_PCT": "150"}))
193 if r.MemFrac != def.MemFrac {
194 t.Fatalf("out-of-range value must keep default %v, got %v", def.MemFrac, r.MemFrac)
195 }
196
197 r = RulesFromEnv(env(map[string]string{"CARLOS_ALERT_CPU_PCT": "80"}))
198 if r.CPUFrac != 0.80 {
199 t.Fatalf("want CPUFrac 0.80, got %v", r.CPUFrac)
200 }
201}
202
203func TestGeoIPRuleFiresAfterItsSustainAndClearsOnZero(t *testing.T) {
204 r := DefaultRules()
205 t0 := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
206 one, zero := 1.0, 0.0
207 st := AlertState{V: 1, Box: "b"}
208 st, ev := Evaluate(r, st, Observation{At: t0, GeoIP: &one})
209 if len(ev) != 0 {
210 t.Fatalf("fired at once: %v", ev)
211 }
212 st, ev = Evaluate(r, st, Observation{At: t0.Add(29 * time.Minute), GeoIP: &one})
213 if len(ev) != 0 {
214 t.Fatalf("fired before 30 minutes: %v", ev)
215 }
216 st, ev = Evaluate(r, st, Observation{At: t0.Add(30 * time.Minute), GeoIP: &one})
217 if len(ev) != 1 || !ev[0].Firing || ev[0].Rule != "geoip" || ev[0].Value != 1 {
218 t.Fatalf("at 30 minutes: %v", ev)
219 }
220 st, ev = Evaluate(r, st, Observation{At: t0.Add(31 * time.Minute)}) // nil: no change
221 if len(ev) != 0 || !st.Rules["geoip"].Firing {
222 t.Fatalf("a nil reading changed the rule: %v %v", ev, st.Rules["geoip"])
223 }
224 _, ev = Evaluate(r, st, Observation{At: t0.Add(32 * time.Minute), GeoIP: &zero})
225 if len(ev) != 1 || ev[0].Firing {
226 t.Fatalf("a zero reading must clear at once: %v", ev)
227 }
228}
229
230// Code review R2-5: a reason that changes while firing re-fires with the
231// new code, without clearing or restarting the sustain.
232func TestGeoIPReasonChangeWhileFiring(t *testing.T) {
233 r := DefaultRules()
234 t0 := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
235 two, three := 2.0, 3.0
236 st := AlertState{V: 1, Box: "b"}
237 st, _ = Evaluate(r, st, Observation{At: t0, GeoIP: &two})
238 st, ev := Evaluate(r, st, Observation{At: t0.Add(30 * time.Minute), GeoIP: &two})
239 if len(ev) != 1 || ev[0].Value != 2 {
240 t.Fatalf("fire = %v", ev)
241 }
242 st, ev = Evaluate(r, st, Observation{At: t0.Add(31 * time.Minute), GeoIP: &three})
243 if len(ev) != 1 || !ev[0].Firing || ev[0].Value != 3 || !st.Rules["geoip"].Firing || st.Rules["geoip"].Value != 3 {
244 t.Fatalf("escalation = %v %+v", ev, st.Rules["geoip"])
245 }
246 if _, ev = Evaluate(r, st, Observation{At: t0.Add(32 * time.Minute), GeoIP: &three}); len(ev) != 0 {
247 t.Fatalf("an unchanged reason re-fired: %v", ev)
248 }
249}
250