The mesh noticed 48 core failures in six days and told nobody (ADR 0227). messenger holds the operator-channel seat: it consumes the controller's condition events and sends them to Telegram and the desktop notifier, deduplicated by key, reminded once, edited on clear, capped at 20 an hour with the rest folded, and refusing anything carrying an address, a path or a secret. mesh-watcher, on a machine other than the control node, sends to Telegram directly when the self-check heartbeat or the bus goes silent.
401 lines
13 KiB
Go
401 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeChannel struct {
|
|
name string
|
|
notReady error
|
|
fail error
|
|
sends []Message
|
|
edits map[string]Message
|
|
n int
|
|
}
|
|
|
|
func (f *fakeChannel) Name() string { return f.name }
|
|
func (f *fakeChannel) Ready() error { return f.notReady }
|
|
func (f *fakeChannel) CanEdit() bool { return true }
|
|
func (f *fakeChannel) Send(m Message) (string, error) {
|
|
if f.fail != nil {
|
|
return "", f.fail
|
|
}
|
|
f.n++
|
|
f.sends = append(f.sends, m)
|
|
return fmt.Sprint(f.n), nil
|
|
}
|
|
func (f *fakeChannel) Edit(id string, m Message) error {
|
|
if f.fail != nil {
|
|
return f.fail
|
|
}
|
|
if f.edits == nil {
|
|
f.edits = map[string]Message{}
|
|
}
|
|
f.edits[id] = m
|
|
return nil
|
|
}
|
|
|
|
type memStore struct {
|
|
recs map[string]Record
|
|
recent []Sent
|
|
}
|
|
|
|
func (m *memStore) Put(r Record) error {
|
|
if m.recs == nil {
|
|
m.recs = map[string]Record{}
|
|
}
|
|
m.recs[r.Key] = r
|
|
return nil
|
|
}
|
|
func (m *memStore) Delete(k string) error { delete(m.recs, k); return nil }
|
|
func (m *memStore) All() ([]Record, error) {
|
|
var out []Record
|
|
for _, r := range m.recs {
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (m *memStore) PutRecent(s []Sent) error { m.recent = s; return nil }
|
|
func (m *memStore) Recent() ([]Sent, error) { return m.recent, nil }
|
|
|
|
type clock struct{ t time.Time }
|
|
|
|
func (c *clock) now() time.Time { return c.t }
|
|
func (c *clock) pass(d time.Duration) { c.t = c.t.Add(d) }
|
|
func start() *clock { return &clock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} }
|
|
func cond(key, sev, summary string) Condition {
|
|
parts := strings.Split(key, ".")
|
|
return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary}
|
|
}
|
|
|
|
func newHolder(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memStore) {
|
|
t.Helper()
|
|
c := start()
|
|
tg, dt := &fakeChannel{name: "telegram"}, &fakeChannel{name: "desktop"}
|
|
st := &memStore{}
|
|
var emitted []string
|
|
h := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf,
|
|
Emit: func(e string, _ any) error { emitted = append(emitted, e); return nil }}
|
|
h.init()
|
|
return h, tg, dt, c, st
|
|
}
|
|
|
|
func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) {
|
|
h, tg, dt, c, _ := newHolder(t)
|
|
k := cond("machine.ace.silent", Urgent, "the home server has not been heard for 15 min")
|
|
k.Raised = c.now()
|
|
h.Condition(EventRaised, k)
|
|
h.Condition(EventRaised, k) // a redelivery
|
|
c.pass(time.Minute)
|
|
h.Condition(EventRaised, k) // said again by the controller
|
|
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
|
t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends))
|
|
}
|
|
if !strings.Contains(tg.sends[0].Text(), "machine.ace.silent") || !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") {
|
|
t.Fatalf("message: %q", tg.sends[0].Text())
|
|
}
|
|
if open := h.Open(); len(open) != 1 || open[0].Count != 1 {
|
|
t.Fatalf("open: %+v", open)
|
|
}
|
|
}
|
|
|
|
func TestAWarningGoesToTheDesktopWhenASessionAnswersElseTelegram(t *testing.T) {
|
|
h, tg, dt, _, _ := newHolder(t)
|
|
h.Condition(EventRaised, cond("plan.41.stalled", Warning, "plan 41 waits on a build"))
|
|
if len(dt.sends) != 1 || len(tg.sends) != 0 {
|
|
t.Fatalf("desktop answered: desktop %d telegram %d", len(dt.sends), len(tg.sends))
|
|
}
|
|
dt.fail = errors.New("the account is not logged in")
|
|
h.Condition(EventRaised, cond("plan.42.stalled", Warning, "plan 42 waits on a build"))
|
|
if len(tg.sends) != 1 {
|
|
t.Fatalf("no session: telegram %d", len(tg.sends))
|
|
}
|
|
dt.fail, dt.notReady = nil, errors.New("not configured")
|
|
h.Condition(EventRaised, cond("plan.43.stalled", Warning, "plan 43 waits on a build"))
|
|
if len(tg.sends) != 2 {
|
|
t.Fatalf("no desktop configured: telegram %d", len(tg.sends))
|
|
}
|
|
}
|
|
|
|
func TestTheCapHoldsTheRestAndFoldsThemIntoOneMessage(t *testing.T) {
|
|
h, tg, _, c, _ := newHolder(t)
|
|
h.Desktop = nil
|
|
for i := 0; i < 25; i++ {
|
|
h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent"))
|
|
}
|
|
if len(tg.sends) != CapPerHour {
|
|
t.Fatalf("sent %d, cap %d", len(tg.sends), CapPerHour)
|
|
}
|
|
st := h.Status("listening")
|
|
if st.Channels[0].Held != 5 || st.Channels[0].SentLastHour != CapPerHour {
|
|
t.Fatalf("status: %+v", st.Channels[0])
|
|
}
|
|
// Said, not silent: the fold goes out at the first tick, and again only after ten minutes.
|
|
h.Tick()
|
|
if len(tg.sends) != CapPerHour+1 {
|
|
t.Fatalf("no fold: %d sends", len(tg.sends))
|
|
}
|
|
fold := tg.sends[len(tg.sends)-1]
|
|
if !strings.Contains(fold.Title, "HELD BACK: 5") {
|
|
t.Fatalf("fold: %q", fold.Text())
|
|
}
|
|
for i := 20; i < 25; i++ {
|
|
if !strings.Contains(fold.Body, fmt.Sprintf("machine.m%d.silent", i)) {
|
|
t.Fatalf("fold does not name m%d: %q", i, fold.Body)
|
|
}
|
|
}
|
|
h.Condition(EventRaised, cond("machine.late.silent", Urgent, "a machine is silent"))
|
|
c.pass(time.Minute)
|
|
h.Tick()
|
|
if len(tg.sends) != CapPerHour+1 {
|
|
t.Fatalf("a second fold inside ten minutes")
|
|
}
|
|
c.pass(FoldEvery)
|
|
h.Tick()
|
|
if last := tg.sends[len(tg.sends)-1]; !strings.Contains(last.Body, "machine.late.silent") {
|
|
t.Fatalf("the second fold: %q", last.Text())
|
|
}
|
|
// An hour on, the window is free again.
|
|
c.pass(time.Hour)
|
|
h.Condition(EventRaised, cond("machine.next.silent", Urgent, "a machine is silent"))
|
|
if last := tg.sends[len(tg.sends)-1]; !strings.Contains(last.Text(), "machine.next.silent") {
|
|
t.Fatalf("not sent after the window: %q", last.Text())
|
|
}
|
|
}
|
|
|
|
func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testing.T) {
|
|
h, tg, _, _, _ := newHolder(t)
|
|
var emitted []string
|
|
h.Emit = func(e string, _ any) error { emitted = append(emitted, e); return nil }
|
|
h.Condition(EventRaised, cond("provider.keycloak.ace.failing", Urgent, "cannot reach 192.168.1.20:8443 with token=abc"))
|
|
if len(tg.sends) != 1 {
|
|
t.Fatalf("sends: %d", len(tg.sends))
|
|
}
|
|
text := tg.sends[0].Text()
|
|
if strings.Contains(text, "192.168") || strings.Contains(text, "token=") {
|
|
t.Fatalf("the address left: %q", text)
|
|
}
|
|
if !strings.Contains(text, "channel-refused") || !strings.Contains(text, "provider.keycloak.ace.failing") {
|
|
t.Fatalf("not said as refused, by key: %q", text)
|
|
}
|
|
hist := h.History(10)["refusals"].([]RefusalNote)
|
|
if len(hist) != 1 || hist[0].Class != "address" {
|
|
t.Fatalf("refusals: %+v", hist)
|
|
}
|
|
if len(emitted) != 1 || emitted[0] != "refused" {
|
|
t.Fatalf("emitted: %v", emitted)
|
|
}
|
|
// Its clearing keeps the words withheld.
|
|
h.Condition(EventCleared, Condition{Key: "provider.keycloak.ace.failing"})
|
|
for _, m := range tg.edits {
|
|
if strings.Contains(m.Text(), "192.168") {
|
|
t.Fatalf("the clearing carried it: %q", m.Text())
|
|
}
|
|
}
|
|
if len(tg.edits) != 1 {
|
|
t.Fatalf("edits: %d", len(tg.edits))
|
|
}
|
|
}
|
|
|
|
func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
|
|
h, tg, _, _, _ := newHolder(t)
|
|
h.Desktop = nil
|
|
h.Condition(EventRaised, cond("machine.10.0.0.7.silent", Urgent, "a machine is silent"))
|
|
if len(tg.sends) != 1 || strings.Contains(tg.sends[0].Text(), "10.0.0.7") {
|
|
t.Fatalf("sends: %+v", tg.sends)
|
|
}
|
|
}
|
|
|
|
func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
|
|
h, tg, _, c, _ := newHolder(t)
|
|
h.Desktop = nil
|
|
u := cond("bus.controller.slow-consumer", Urgent, "the controller's consumer is far behind")
|
|
u.Raised = c.now()
|
|
w := cond("plan.41.stalled", Warning, "plan 41 waits")
|
|
w.Raised = c.now()
|
|
h.Condition(EventRaised, u)
|
|
h.Condition(EventRaised, w)
|
|
c.pass(59 * time.Minute)
|
|
h.Tick()
|
|
if len(tg.sends) != 2 {
|
|
t.Fatalf("reminded before the hour: %d", len(tg.sends))
|
|
}
|
|
c.pass(2 * time.Minute)
|
|
h.Tick()
|
|
if len(tg.sends) != 3 || !strings.HasPrefix(tg.sends[2].Title, "STILL OPEN after 61 min") {
|
|
t.Fatalf("urgent reminder: %d %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title)
|
|
}
|
|
c.pass(3 * time.Hour)
|
|
h.Tick()
|
|
if len(tg.sends) != 3 {
|
|
t.Fatalf("reminded twice")
|
|
}
|
|
c.pass(9 * time.Hour) // the warning is now 13 h old
|
|
h.Tick()
|
|
if len(tg.sends) != 4 || !strings.Contains(tg.sends[3].Text(), "plan.41.stalled") {
|
|
t.Fatalf("warning reminder: %d", len(tg.sends))
|
|
}
|
|
}
|
|
|
|
func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testing.T) {
|
|
h, tg, dt, c, st := newHolder(t)
|
|
k := cond("machine.ace.silent", Urgent, "the home server is silent")
|
|
k.Raised = c.now()
|
|
h.Condition(EventRaised, k)
|
|
c.pass(14 * time.Minute)
|
|
h.Condition(EventCleared, Condition{Key: k.Key})
|
|
if len(tg.sends) != 1 || len(tg.edits) != 1 || len(dt.edits) != 1 {
|
|
t.Fatalf("telegram sends %d edits %d, desktop edits %d", len(tg.sends), len(tg.edits), len(dt.edits))
|
|
}
|
|
if m := tg.edits["1"]; !strings.HasPrefix(m.Title, "CLEARED after 14 min") {
|
|
t.Fatalf("edit: %q", m.Title)
|
|
}
|
|
if len(h.Open()) != 0 {
|
|
t.Fatalf("still open")
|
|
}
|
|
c.pass(5 * time.Minute)
|
|
h.Condition(EventRaised, k)
|
|
if len(tg.sends) != 1 || !strings.Contains(tg.edits["1"].Title, "open again, 2 times") {
|
|
t.Fatalf("reopened as new: sends %d, edit %q", len(tg.sends), tg.edits["1"].Title)
|
|
}
|
|
h.Condition(EventCleared, Condition{Key: k.Key})
|
|
c.pass(11 * time.Minute)
|
|
h.Tick()
|
|
if _, kept := st.recs[k.Key]; kept {
|
|
t.Fatalf("a cleared message kept past the reopen window")
|
|
}
|
|
h.Condition(EventRaised, k)
|
|
if len(tg.sends) != 2 {
|
|
t.Fatalf("a raise after the window is a new message: %d", len(tg.sends))
|
|
}
|
|
}
|
|
|
|
func TestASilencedConditionSendsNothing(t *testing.T) {
|
|
h, tg, dt, c, _ := newHolder(t)
|
|
k := cond("machine.ace.silent", Urgent, "silent")
|
|
k.SilencedTill = c.now().Add(2 * time.Hour)
|
|
h.Condition(EventRaised, k)
|
|
c.pass(90 * time.Minute)
|
|
h.Tick()
|
|
h.Condition(EventCleared, Condition{Key: k.Key})
|
|
if len(tg.sends)+len(dt.sends)+len(tg.edits)+len(dt.edits) != 0 {
|
|
t.Fatalf("a silenced condition said something")
|
|
}
|
|
// Silenced after it was sent: no reminder.
|
|
k2 := cond("machine.shanks.silent", Urgent, "silent")
|
|
k2.Raised = c.now()
|
|
h.Condition(EventRaised, k2)
|
|
k2.SilencedTill = c.now().Add(3 * time.Hour)
|
|
h.Condition(EventChanged, k2)
|
|
c.pass(2 * time.Hour)
|
|
h.Tick()
|
|
if len(tg.sends) != 1 {
|
|
t.Fatalf("reminded while silenced: %d", len(tg.sends))
|
|
}
|
|
}
|
|
|
|
func TestEscalationIsSaidOnce(t *testing.T) {
|
|
h, tg, dt, _, _ := newHolder(t)
|
|
k := cond("machine.novox.silent", Warning, "the anchor is silent")
|
|
h.Condition(EventRaised, k)
|
|
k.Severity = Urgent
|
|
h.Condition(EventChanged, k)
|
|
h.Condition(EventChanged, k)
|
|
if len(dt.sends) != 2 || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "NOW URGENT") {
|
|
t.Fatalf("desktop %d telegram %d", len(dt.sends), len(tg.sends))
|
|
}
|
|
}
|
|
|
|
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
|
h, tg, _, _, _ := newHolder(t)
|
|
h.Desktop = nil
|
|
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
|
|
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
|
|
st := h.Status("listening")
|
|
if !strings.HasPrefix(st.Verdict, "CANNOT SEND") || len(st.Unsent) != 1 || st.Channels[0].LastError == "" {
|
|
t.Fatalf("status: %+v", st)
|
|
}
|
|
tg.fail = nil
|
|
h.Tick()
|
|
if len(tg.sends) != 1 || h.Status("listening").Verdict != "ok" {
|
|
t.Fatalf("not retried: %d, %s", len(tg.sends), h.Status("listening").Verdict)
|
|
}
|
|
}
|
|
|
|
func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
|
|
h, tg, dt, _, _ := newHolder(t)
|
|
tg.notReady = errors.New("no bot token")
|
|
dt.notReady = errors.New("no machine")
|
|
st := h.Status("listening")
|
|
if !strings.HasPrefix(st.Verdict, "CANNOT SEND") || st.Channels[0].NotReady != "no bot token" {
|
|
t.Fatalf("status: %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestARestartForgetsNothing(t *testing.T) {
|
|
h, tg, _, c, st := newHolder(t)
|
|
h.Desktop = nil
|
|
k := cond("machine.ace.silent", Urgent, "silent")
|
|
k.Raised = c.now()
|
|
h.Condition(EventRaised, k)
|
|
again := &Holder{Telegram: tg, Store: st, Now: c.now, Logf: t.Logf}
|
|
if err := again.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again.Condition(EventRaised, k)
|
|
if len(tg.sends) != 1 {
|
|
t.Fatalf("a restart sent it again")
|
|
}
|
|
again.Condition(EventCleared, Condition{Key: k.Key})
|
|
if len(tg.edits) != 1 {
|
|
t.Fatalf("a restart lost the message to edit")
|
|
}
|
|
}
|
|
|
|
func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
|
|
h, tg, _, c, _ := newHolder(t)
|
|
h.Desktop = nil
|
|
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
|
|
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
|
|
if len(tg.sends) != 1 || h.Status("listening").Unreadable != 2 {
|
|
t.Fatalf("sends %d", len(tg.sends))
|
|
}
|
|
c.pass(61 * time.Minute)
|
|
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
|
|
if len(tg.sends) != 2 {
|
|
t.Fatalf("not said again after an hour")
|
|
}
|
|
}
|
|
|
|
func TestNotifyIsKeptApartFromConditions(t *testing.T) {
|
|
h, tg, _, _, _ := newHolder(t)
|
|
h.Desktop = nil
|
|
out, err := h.Notify("backup.ace.failed", Warning, "last night's backup of the home server failed", "backup", false)
|
|
if err != nil || out["key"] != "notify.backup.ace.failed" || len(tg.sends) != 1 {
|
|
t.Fatalf("%v %v %d", out, err, len(tg.sends))
|
|
}
|
|
if _, err := h.Notify("x", "", "s", "", false); err == nil {
|
|
t.Fatalf("no severity was not refused")
|
|
}
|
|
if _, err := h.Notify("x", Urgent, "see /var/lib/x", "", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(tg.sends[len(tg.sends)-1].Text(), "/var/lib") {
|
|
t.Fatalf("a path left")
|
|
}
|
|
}
|
|
|
|
func TestATestMessageRespectsTheRule(t *testing.T) {
|
|
h, tg, dt, _, _ := newHolder(t)
|
|
if out := h.Test("telegram", "see https://example.org"); out["refused"] == "" || len(tg.sends) != 0 {
|
|
t.Fatalf("%v", out)
|
|
}
|
|
if out := h.Test("", ""); out["telegram"] != "sent" || out["desktop"] != "sent" || len(dt.sends) != 1 {
|
|
t.Fatalf("%v", out)
|
|
}
|
|
}
|