8a13d189bb
It repeated every five minutes for over two hours. WasAcked was the only stop condition and nothing reachable from telegram can mark a nudge acked — the only ack is a voice 'готово' on a box that runs no voice loop. Two endings now. The condition cleared, which the rule answers through the new Rule.StillTrue — deliberately not Predicate, which is edge-triggered and reads false one tick after the alarm is raised, so building the stop on it would cancel every alarm immediately. Or the alarm got old, which is the bound that needs no cooperation from the rule. A rule with no StillTrue is never read as resolved and stops only on age. Covered by tests including the flap case, since none of it can be reproduced by hand without waiting hours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.1 KiB
Go
187 lines
6.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery"
|
|
"github.com/kami/maven/internal/loop"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
|
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
|
// ever mark one acked. None of what follows can be reproduced by hand without
|
|
// sitting in front of the box for hours, so it is covered here or nowhere.
|
|
|
|
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
|
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
|
t.Helper()
|
|
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
|
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
|
}
|
|
}
|
|
|
|
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
|
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
|
// repeat these tests are about never happens. The daemon wires it (main.go).
|
|
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
|
t.Helper()
|
|
rules := loop.DefaultRules()
|
|
g := loop.NewGatherer(st, rules)
|
|
d := delivery.NewDispatcher(delivery.Config{
|
|
Voice: sink, Ntfy: sink, Telegram: sink,
|
|
Ack: st, Nudges: st, Reminders: st,
|
|
})
|
|
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
|
}
|
|
|
|
// telegramSends counts sends that went out on the telegram reach.
|
|
func telegramSends(sink *fakeSink, rule string) int {
|
|
n := 0
|
|
for _, s := range sink.sends {
|
|
if s.RuleName == rule {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
|
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
|
t.Helper()
|
|
rows, err := st.RecentNudges(ctx, 50)
|
|
if err != nil {
|
|
t.Fatalf("recent nudges: %v", err)
|
|
}
|
|
var out []string
|
|
for _, n := range rows {
|
|
if n.Rule == rule {
|
|
out = append(out, n.Outcome)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
|
// The condition clearing is the ending that should happen. StillTrue reads
|
|
// the same DownServices helper the phraser reads, so the repeat stops on
|
|
// exactly the monitor he was told about.
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
seedDown(t, st, ctx, "down", now)
|
|
|
|
sink := &fakeSink{}
|
|
tl := newAlarmTickLoop(t, st, sink)
|
|
tl.tick(ctx, now)
|
|
if telegramSends(sink, "service_down") == 0 {
|
|
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
|
}
|
|
|
|
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
|
sink.sends = nil
|
|
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
|
|
|
if n := telegramSends(sink, "service_down"); n != 0 {
|
|
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
|
}
|
|
for _, o := range outcomes(t, st, ctx, "service_down") {
|
|
if o != store.NudgeResolved {
|
|
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
|
// Still down, still un-acked, and nobody has answered in two hours. That is
|
|
// not one more repeat away from being answered.
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
seedDown(t, st, ctx, "down", now)
|
|
|
|
sink := &fakeSink{}
|
|
tl := newAlarmTickLoop(t, st, sink)
|
|
tl.tick(ctx, now)
|
|
|
|
sink.sends = nil
|
|
tl.tick(ctx, now.Add(6*time.Minute))
|
|
if n := telegramSends(sink, "service_down"); n == 0 {
|
|
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
|
}
|
|
|
|
sink.sends = nil
|
|
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
|
if n := telegramSends(sink, "service_down"); n != 0 {
|
|
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
|
}
|
|
// Ignored, not resolved: nothing says the service got better.
|
|
for _, o := range outcomes(t, st, ctx, "service_down") {
|
|
if o != store.NudgeIgnored {
|
|
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
|
// Down, up, down again. Closing the first run must not make the second run
|
|
// unreportable, and must not silently reopen the closed rows either.
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
seedDown(t, st, ctx, "down", now)
|
|
|
|
sink := &fakeSink{}
|
|
tl := newAlarmTickLoop(t, st, sink)
|
|
tl.tick(ctx, now)
|
|
first := len(outcomes(t, st, ctx, "service_down"))
|
|
|
|
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
|
tl.tick(ctx, now.Add(2*time.Minute))
|
|
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
|
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
|
}
|
|
|
|
seedDown(t, st, ctx, "down", now.Add(30*time.Minute))
|
|
sink.sends = nil
|
|
tl.tick(ctx, now.Add(31*time.Minute))
|
|
|
|
if n := telegramSends(sink, "service_down"); n == 0 {
|
|
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
|
}
|
|
got := outcomes(t, st, ctx, "service_down")
|
|
if len(got) <= first {
|
|
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
|
}
|
|
}
|
|
|
|
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
|
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
|
// that says nothing must keep its alarm until the age cap, or a rule author
|
|
// silences their own alarm by omission.
|
|
st := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := refNow()
|
|
|
|
sink := &fakeSink{}
|
|
tl := newAlarmTickLoop(t, st, sink)
|
|
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
|
|
|
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
|
t.Fatalf("record nudge: %v", err)
|
|
}
|
|
|
|
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
|
if len(live) != 1 {
|
|
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
|
}
|
|
|
|
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
|
if len(live) != 0 {
|
|
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
|
}
|
|
}
|