Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.* лежала готовою з Етапу 1 і повністю порожньою. Движок: - обчислює правила з icmp / interface / metric / no_data і не тримає стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож перезапуск нічого не збиває - дві семантики вікна: без agg умова має триматися всі виміри (антифлап), з agg порівнюється агрегат - кореляція за топологією: причина аварії — той, у кого лишився живий сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась topo.links: інакше падіння маршрутизатора дає сорок сповіщень - вікна обслуговування, ручне заглушення зі стелею в тиждень - advisory-блокування на тік: кілька API за балансувальником безпечні Доставка: - Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що й паролі від обладнання - тенант без маршрутів отримує все в усі придатні канали — підключили Telegram, має працювати - тиха година не глушить disaster - вебхуки на внутрішні адреси заблоковано; для self-hosted знімається прапорцем процесу, бо там ця мережа своя UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр придушених, спільна шина подій замість другого WebSocket. Знайдено живою роботою й виправлено: - вимкнене або видалене правило лишало алерти сиротами назавжди - перехід у suppressed не публікував події — UI дізнавався лише після перезавантаження сторінки - кнопки дій на телефоні були 26 px 20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині розділив два справжні пристрої, 5 доставок на 5 подій без повторів. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
6.7 KiB
Go
211 lines
6.7 KiB
Go
package store
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// graph будує двонаправлений граф зі списку ребер.
|
||
func graph(down []string, edges ...[2]string) TopologyGraph {
|
||
g := TopologyGraph{Neighbors: map[string][]string{}, Down: map[string]bool{}}
|
||
mark := func(id string) {
|
||
if _, ok := g.Down[id]; !ok {
|
||
g.Down[id] = false
|
||
}
|
||
if _, ok := g.Neighbors[id]; !ok {
|
||
g.Neighbors[id] = nil
|
||
}
|
||
}
|
||
for _, e := range edges {
|
||
mark(e[0])
|
||
mark(e[1])
|
||
g.Neighbors[e[0]] = append(g.Neighbors[e[0]], e[1])
|
||
g.Neighbors[e[1]] = append(g.Neighbors[e[1]], e[0])
|
||
}
|
||
for _, d := range down {
|
||
mark(d)
|
||
g.Down[d] = true
|
||
}
|
||
return g
|
||
}
|
||
|
||
// Головний сценарій, заради якого будувалася topo.links: упав
|
||
// маршрутизатор — сорок пристроїв за ним не мають дати сорок алертів.
|
||
func TestRootCauseCollapsesOutageBehindRouter(t *testing.T) {
|
||
g := graph(
|
||
[]string{"router", "sw1", "sw2", "host1", "host2"},
|
||
[2]string{"core", "router"},
|
||
[2]string{"router", "sw1"},
|
||
[2]string{"router", "sw2"},
|
||
[2]string{"sw1", "host1"},
|
||
[2]string{"sw2", "host2"},
|
||
)
|
||
|
||
roots := g.RootCause()
|
||
|
||
if _, ok := roots["router"]; ok {
|
||
t.Fatal("маршрутизатор має бути причиною, а не наслідком")
|
||
}
|
||
for _, id := range []string{"sw1", "sw2", "host1", "host2"} {
|
||
if roots[id] != "router" {
|
||
t.Errorf("%s: корінь %q, очікували router", id, roots[id])
|
||
}
|
||
}
|
||
}
|
||
|
||
// Пристрій, у якого лишився живий сусід, впав сам.
|
||
func TestRootCauseKeepsDeviceWithLiveNeighbour(t *testing.T) {
|
||
g := graph(
|
||
[]string{"host1"},
|
||
[2]string{"sw1", "host1"},
|
||
)
|
||
if roots := g.RootCause(); len(roots) != 0 {
|
||
t.Fatalf("очікували жодного наслідку, отримали %v", roots)
|
||
}
|
||
}
|
||
|
||
// Пристрій без жодного відомого зв'язку не можна списати на чужу
|
||
// аварію: топологія про нього нічого не знає.
|
||
func TestRootCauseIsolatedDeviceStaysRoot(t *testing.T) {
|
||
g := graph([]string{"lonely"})
|
||
g.Neighbors["lonely"] = nil
|
||
|
||
if roots := g.RootCause(); len(roots) != 0 {
|
||
t.Fatalf("одинокий пристрій має лишитись причиною, отримали %v", roots)
|
||
}
|
||
}
|
||
|
||
// Повністю мертвий острів: причини не видно, тож алертують усі —
|
||
// краще зайвий шум, ніж мовчазне ковтання цілого сегмента.
|
||
func TestRootCauseFullyDownIslandAlertsAll(t *testing.T) {
|
||
g := graph(
|
||
[]string{"a", "b"},
|
||
[2]string{"a", "b"},
|
||
)
|
||
if roots := g.RootCause(); len(roots) != 0 {
|
||
t.Fatalf("острів без живих сусідів має алертувати весь, отримали %v", roots)
|
||
}
|
||
}
|
||
|
||
// Ланцюг: корінь передається вглиб, а не переписується сусідом.
|
||
func TestRootCausePropagatesAlongChain(t *testing.T) {
|
||
g := graph(
|
||
[]string{"r", "a", "b", "c"},
|
||
[2]string{"up", "r"},
|
||
[2]string{"r", "a"},
|
||
[2]string{"a", "b"},
|
||
[2]string{"b", "c"},
|
||
)
|
||
roots := g.RootCause()
|
||
for _, id := range []string{"a", "b", "c"} {
|
||
if roots[id] != "r" {
|
||
t.Errorf("%s: корінь %q, очікували r", id, roots[id])
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
func atTime(t *testing.T, s string) time.Time {
|
||
t.Helper()
|
||
v, err := time.Parse(time.RFC3339, s)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// Тихі години через північ — норма для чергувань, а не окремий випадок.
|
||
func TestQuietHoursSpanMidnight(t *testing.T) {
|
||
sc := &RouteSchedule{TZ: "UTC"}
|
||
sc.Quiet = append(sc.Quiet, struct {
|
||
Days []int `json:"days"`
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
}{From: "22:00", To: "08:00"})
|
||
|
||
cases := []struct {
|
||
ts string
|
||
want bool
|
||
}{
|
||
{"2026-08-15T23:30:00Z", true},
|
||
{"2026-08-15T03:00:00Z", true},
|
||
{"2026-08-15T07:59:00Z", true},
|
||
{"2026-08-15T08:00:00Z", false},
|
||
{"2026-08-15T12:00:00Z", false},
|
||
{"2026-08-15T21:59:00Z", false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := sc.IsQuiet(atTime(t, c.ts)); got != c.want {
|
||
t.Errorf("%s: отримали %v, очікували %v", c.ts, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestQuietHoursRespectDays(t *testing.T) {
|
||
sc := &RouteSchedule{TZ: "UTC"}
|
||
// 2026-08-15 — субота (6), 2026-08-17 — понеділок (1).
|
||
sc.Quiet = append(sc.Quiet, struct {
|
||
Days []int `json:"days"`
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
}{Days: []int{6, 0}, From: "00:00", To: "23:59"})
|
||
|
||
if !sc.IsQuiet(atTime(t, "2026-08-15T12:00:00Z")) {
|
||
t.Error("субота має бути тихою")
|
||
}
|
||
if sc.IsQuiet(atTime(t, "2026-08-17T12:00:00Z")) {
|
||
t.Error("понеділок тихим не є")
|
||
}
|
||
}
|
||
|
||
func TestNilScheduleIsNeverQuiet(t *testing.T) {
|
||
var sc *RouteSchedule
|
||
if sc.IsQuiet(time.Now()) {
|
||
t.Error("відсутній розклад не має глушити нічого")
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
func TestRouteMatchesSeverityFloor(t *testing.T) {
|
||
r := Route{Matcher: RouteMatcher{SeverityGte: "high"}}
|
||
|
||
if r.Matches(Alert{Severity: "warning"}) {
|
||
t.Error("warning не має проходити поріг high")
|
||
}
|
||
if !r.Matches(Alert{Severity: "high"}) {
|
||
t.Error("high має проходити поріг high")
|
||
}
|
||
if !r.Matches(Alert{Severity: "disaster"}) {
|
||
t.Error("disaster має проходити поріг high")
|
||
}
|
||
}
|
||
|
||
func TestRouteMatchesDeviceList(t *testing.T) {
|
||
r := Route{Matcher: RouteMatcher{DeviceIDs: []string{"d1", "d2"}}}
|
||
|
||
if !r.Matches(Alert{Severity: "warning", DeviceID: "d2"}) {
|
||
t.Error("пристрій зі списку має проходити")
|
||
}
|
||
if r.Matches(Alert{Severity: "warning", DeviceID: "d9"}) {
|
||
t.Error("пристрій поза списком не має проходити")
|
||
}
|
||
}
|
||
|
||
// Ключ дедуплікації має бути стабільним і розрізняти рівні об'єкта:
|
||
// інакше алерт про інтерфейс затирав би алерт про сам пристрій.
|
||
func TestDedupKeySeparatesDeviceAndInterface(t *testing.T) {
|
||
dev := Candidate{DeviceID: "d1"}
|
||
iface := Candidate{DeviceID: "d1", InterfaceID: "i1"}
|
||
|
||
if dev.DedupKey("r1") == iface.DedupKey("r1") {
|
||
t.Fatal("ключі пристрою й інтерфейсу мають відрізнятися")
|
||
}
|
||
if dev.DedupKey("r1") != dev.DedupKey("r1") {
|
||
t.Fatal("ключ має бути стабільним")
|
||
}
|
||
if dev.DedupKey("r1") == dev.DedupKey("r2") {
|
||
t.Fatal("різні правила — різні ключі")
|
||
}
|
||
}
|