За другою рецензією:
* scripts/dbtest.sh писав у шапці «не напрямляйте на робочу базу» й
нічого для цього не робив — перевірено, пішов котити міграції на базу
з бойовим іменем. Тепер вимагає probe/test в імені.
* sendText ковтав помилку, тож журнал ескалацій писав «надіслано» на
сходинці, жодне повідомлення якої не дійшло. Три результати замість
двох: no_channels, failed, sent.
* stopped_at IS NULL рятував лише від ack; гасіння правилом і
ResolveMissing рядка драбини не чіпають, і сходинка дзвонила за
погашеним алертом. Додано перевірку стану алерту в тому ж UPDATE.
* escalate() блокував весь тік движка — мертвий вебхук одного кабінету
зупиняв обчислення правил усім. Винесено в RunEscalations.
* алерт, народжений під заглушенням, не сповіщався ніколи: ні при
народженні, ні коли вікно скінчилось. Тепер перехід suppressed→firing
сповіщається, а драбина рахує час від першого сповіщення.
* alr.rules.channel_ids приймав чужі канали, глушачи і сповіщення, і
драбину. Перевірка як для сходинок; DeleteChannel чистить посилання.
І перше, що зловив прогін проти справжньої бази: nil-зріз каналів їде
явним NULL повз DEFAULT '{}' — правило без каналів давало 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
388 lines
15 KiB
Go
388 lines
15 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Перевірка ескалації ПРОТИ БАЗИ.
|
||
//
|
||
// Чиста PlanEscalation покриває рішення, і це головна половина. Але є
|
||
// друга, яку рішенням не перевіриш узагалі: атомарність. «Сходинка не
|
||
// подвоїлась при перезапуску» — це властивість трьох запитів
|
||
// (взведення з ON CONFLICT, взяття з орендою, запис рішення), а не
|
||
// властивість функції. Тест, який їх не виконує, про них нічого не
|
||
// каже — скільки б зелених рядків він не давав.
|
||
//
|
||
// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має
|
||
// вимагати бази. Запускати треба на ОДНОРАЗОВІЙ базі — тест створює
|
||
// кабінет і видаляє його разом з усім вмістом:
|
||
//
|
||
// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \
|
||
// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16
|
||
// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate
|
||
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
|
||
// go test ./internal/store/ -run EscalationAgainstDB -v
|
||
func TestEscalationAgainstDB(t *testing.T) {
|
||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||
if dsn == "" {
|
||
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
|
||
}
|
||
ctx := context.Background()
|
||
|
||
st, err := New(ctx, dsn)
|
||
if err != nil {
|
||
t.Fatalf("підключення: %v", err)
|
||
}
|
||
t.Cleanup(st.Close)
|
||
|
||
slug := "esc-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
|
||
var tenantID string
|
||
if err := st.pool.QueryRow(ctx, `
|
||
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
|
||
`, slug, "Перевірка ескалацій").Scan(&tenantID); err != nil {
|
||
t.Fatalf("кабінет: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = st.pool.Exec(context.Background(),
|
||
`DELETE FROM core.tenants WHERE id = $1`, tenantID)
|
||
})
|
||
|
||
var deviceID string
|
||
if err := st.pool.QueryRow(ctx, `
|
||
INSERT INTO inv.devices (tenant_id, name, address, kind)
|
||
VALUES ($1, $2, '10.77.0.1', 'switch') RETURNING id::text
|
||
`, tenantID, slug+"-sw").Scan(&deviceID); err != nil {
|
||
t.Fatalf("хост: %v", err)
|
||
}
|
||
|
||
// Канал справжній, а не рядок «ch»: збереження драбини тепер
|
||
// відмовляє в сходинці, що посилається на неіснуючий канал.
|
||
var channelID string
|
||
if err := st.pool.QueryRow(ctx, `
|
||
INSERT INTO alr.channels (tenant_id, kind, name, config, min_severity, enabled)
|
||
VALUES ($1, 'webhook', $2, '{}'::jsonb, 'warning', true) RETURNING id::text
|
||
`, tenantID, slug+"-ch").Scan(&channelID); err != nil {
|
||
t.Fatalf("канал: %v", err)
|
||
}
|
||
|
||
policy := EscalationPolicy{
|
||
Name: "Нічне чергування",
|
||
Steps: []EscalationStep{
|
||
{AfterMin: 15, ChannelIDs: []string{channelID}},
|
||
{AfterMin: 45, ChannelIDs: []string{channelID}},
|
||
},
|
||
}
|
||
policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy)
|
||
if err != nil {
|
||
t.Fatalf("політика: %v", err)
|
||
}
|
||
|
||
var ruleID string
|
||
if err := st.pool.QueryRow(ctx, `
|
||
INSERT INTO alr.rules (tenant_id, name, source, severity, condition, escalation_policy_id)
|
||
VALUES ($1, 'Пінг', 'icmp', 'high', '{"metric":"loss_pct","op":">","value":20}'::jsonb, $2)
|
||
RETURNING id::text
|
||
`, tenantID, policyID).Scan(&ruleID); err != nil {
|
||
t.Fatalf("правило: %v", err)
|
||
}
|
||
|
||
newAlert := func(key string) string {
|
||
t.Helper()
|
||
var id string
|
||
if err := st.pool.QueryRow(ctx, `
|
||
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, state,
|
||
title, dedup_key, started_at, last_seen_at)
|
||
VALUES ($1, $2, $3, 'high', 'firing', 'ядро не відповідає', $4,
|
||
now() - interval '20 minutes', now())
|
||
RETURNING id::text
|
||
`, tenantID, ruleID, deviceID, key).Scan(&id); err != nil {
|
||
t.Fatalf("алерт: %v", err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
started := time.Now().Add(-20 * time.Minute)
|
||
|
||
// --- Взведення не подвоюється -------------------------------------
|
||
//
|
||
// Найтонше місце всієї ескалації: повторна доставка того самого
|
||
// алерту (ретрай, другий інстанс, перезапуск між надсиланням і
|
||
// записом) не має перезапускати драбину з нуля.
|
||
a1 := newAlert(ruleID + ":dev:" + deviceID)
|
||
for i := 0; i < 3; i++ {
|
||
if err := st.ArmEscalation(ctx, tenantID, a1, policyID, false, policy, started); err != nil {
|
||
t.Fatalf("взведення %d: %v", i, err)
|
||
}
|
||
}
|
||
var rows int
|
||
if err := st.pool.QueryRow(ctx,
|
||
`SELECT count(*)::int FROM alr.alert_escalations WHERE alert_id = $1`, a1).Scan(&rows); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rows != 1 {
|
||
t.Fatalf("три взведення дали %d рядків драбини — драбина подвоїлась", rows)
|
||
}
|
||
|
||
// --- Оренда не дає взяти сходинку двічі ---------------------------
|
||
due, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatalf("взяття сходинки: %v", err)
|
||
}
|
||
if len(due) != 1 {
|
||
t.Fatalf("очікували одну сходинку, отримали %d", len(due))
|
||
}
|
||
if due[0].AlertState != "firing" || len(due[0].Steps) != 2 {
|
||
t.Fatalf("знімок прочитано неправильно: %+v", due[0])
|
||
}
|
||
|
||
again, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(again) != 0 {
|
||
t.Fatalf("та сама сходинка взята вдруге під орендою: %d", len(again))
|
||
}
|
||
|
||
// --- Запис рішення просуває драбину й веде журнал ------------------
|
||
d := PlanEscalation(due[0], time.Now())
|
||
if d.Action != EscFire {
|
||
t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
|
||
}
|
||
applied, err := st.ApplyEscalation(ctx, due[0], d)
|
||
if err != nil {
|
||
t.Fatalf("запис рішення: %v", err)
|
||
}
|
||
if !applied {
|
||
t.Fatal("рішення не застосовано до живої драбини")
|
||
}
|
||
if err := st.LogEscalationStep(ctx, due[0], d, d.Outcome, d.Detail); err != nil {
|
||
t.Fatalf("журнал сходинки: %v", err)
|
||
}
|
||
|
||
var stepIdx int
|
||
var leased *time.Time
|
||
if err := st.pool.QueryRow(ctx, `
|
||
SELECT step_idx, leased_until FROM alr.alert_escalations WHERE alert_id = $1
|
||
`, a1).Scan(&stepIdx, &leased); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stepIdx != 1 {
|
||
t.Errorf("драбина не просунулась: step_idx = %d", stepIdx)
|
||
}
|
||
if leased != nil {
|
||
t.Error("оренда не знята після запису рішення")
|
||
}
|
||
|
||
var logged int
|
||
if err := st.pool.QueryRow(ctx,
|
||
`SELECT count(*)::int FROM alr.escalation_steps WHERE alert_id = $1 AND outcome = 'sent'`,
|
||
a1).Scan(&logged); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if logged != 1 {
|
||
t.Errorf("журнал сходинок порожній або задвоєний: %d", logged)
|
||
}
|
||
|
||
// --- Підтвердження зупиняє драбину негайно -------------------------
|
||
if _, err := st.AckAlert(ctx, tenantID, a1, "", "беру"); err != nil {
|
||
t.Fatalf("підтвердження: %v", err)
|
||
}
|
||
var stopReason string
|
||
var nextAt *time.Time
|
||
if err := st.pool.QueryRow(ctx, `
|
||
SELECT COALESCE(stop_reason,''), next_at
|
||
FROM alr.alert_escalations WHERE alert_id = $1
|
||
`, a1).Scan(&stopReason, &nextAt); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stopReason != "acked" {
|
||
t.Errorf("драбину не зупинено підтвердженням: причина %q", stopReason)
|
||
}
|
||
if nextAt != nil {
|
||
t.Error("у зупиненої драбини лишився час наступної сходинки")
|
||
}
|
||
|
||
// І головне: зупинена драбина більше не потрапляє в чергу.
|
||
if _, err := st.pool.Exec(ctx,
|
||
`UPDATE alr.alert_escalations SET leased_until = NULL WHERE alert_id = $1`, a1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rest, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, r := range rest {
|
||
if r.AlertID == a1 {
|
||
t.Fatal("підтверджений алерт повернувся в чергу ескалації")
|
||
}
|
||
}
|
||
|
||
// --- Підтвердження ПОСЕРЕД партії не має воскрешати драбину --------
|
||
//
|
||
// Найдовший розрив у механізмі: сходинку взяли в чергу, і поки до неї
|
||
// дійшли руки (кожна доставка попередніх — з власним таймаутом),
|
||
// людина натиснула «Прийняти». Тут відтворено саме цей порядок:
|
||
// знімок узято ДО ack, рішення застосовується ПІСЛЯ.
|
||
a3 := newAlert(ruleID + ":dev:" + deviceID + ":3")
|
||
if err := st.ArmEscalation(ctx, tenantID, a3, policyID, false, policy, started); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := st.pool.Exec(ctx,
|
||
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
|
||
a3); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dueRace, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var snap EscalationSnapshot
|
||
for _, x := range dueRace {
|
||
if x.AlertID == a3 {
|
||
snap = x
|
||
}
|
||
}
|
||
if snap.AlertID == "" {
|
||
t.Fatal("сходинка не потрапила в чергу")
|
||
}
|
||
dRace := PlanEscalation(snap, time.Now())
|
||
if dRace.Action != EscFire {
|
||
t.Fatalf("сходинка мала спрацювати: %v/%s", dRace.Action, dRace.Outcome)
|
||
}
|
||
if _, err := st.AckAlert(ctx, tenantID, a3, "", "беру"); err != nil {
|
||
t.Fatalf("підтвердження: %v", err)
|
||
}
|
||
appliedRace, err := st.ApplyEscalation(ctx, snap, dRace)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if appliedRace {
|
||
t.Error("рішення застосовано до вже зупиненої драбини — людину розбудять після ack")
|
||
}
|
||
var raceStop string
|
||
var raceNext *time.Time
|
||
if err := st.pool.QueryRow(ctx, `
|
||
SELECT COALESCE(stop_reason,''), next_at
|
||
FROM alr.alert_escalations WHERE alert_id = $1
|
||
`, a3).Scan(&raceStop, &raceNext); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if raceStop != "acked" || raceNext != nil {
|
||
t.Errorf("зупинену драбину воскрешено: причина %q, наступна %v", raceStop, raceNext)
|
||
}
|
||
|
||
// --- Гасіння правилом посеред партії теж не має дзвонити ------------
|
||
//
|
||
// Той самий розрив, що й з ack, але шляхом, який рядка драбини НЕ
|
||
// чіпає: resolveRuleAlerts переводить алерт у 'resolved' і про
|
||
// драбину нічого не знає. Знімок узято ДО гасіння, рішення
|
||
// застосовується ПІСЛЯ.
|
||
a4 := newAlert(ruleID + ":dev:" + deviceID + ":4")
|
||
if err := st.ArmEscalation(ctx, tenantID, a4, policyID, false, policy, started); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := st.pool.Exec(ctx,
|
||
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
|
||
a4); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dueRule, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var snapRule EscalationSnapshot
|
||
for _, x := range dueRule {
|
||
if x.AlertID == a4 {
|
||
snapRule = x
|
||
}
|
||
}
|
||
if snapRule.AlertID == "" {
|
||
t.Fatal("сходинка не потрапила в чергу")
|
||
}
|
||
dRule := PlanEscalation(snapRule, time.Now())
|
||
if dRule.Action != EscFire {
|
||
t.Fatalf("сходинка мала спрацювати: %v/%s", dRule.Action, dRule.Outcome)
|
||
}
|
||
// Гасимо так, як це робить вимкнення правила: рядок драбини не чіпаємо.
|
||
if _, err := st.pool.Exec(ctx,
|
||
`UPDATE alr.alerts SET state = 'resolved', resolved_at = now() WHERE id = $1`,
|
||
a4); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
appliedRule, err := st.ApplyEscalation(ctx, snapRule, dRule)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if appliedRule {
|
||
t.Error("сходинка спрацювала за погашеним алертом — дзвінок ні про що")
|
||
}
|
||
// І драбина не застрягла: наступний такт зупинить її штатно.
|
||
if _, err := st.pool.Exec(ctx,
|
||
`UPDATE alr.alert_escalations SET leased_until = NULL WHERE alert_id = $1`, a4); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
againRule, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var back bool
|
||
for _, x := range againRule {
|
||
if x.AlertID != a4 {
|
||
continue
|
||
}
|
||
back = true
|
||
dd := PlanEscalation(x, time.Now())
|
||
if dd.Action != EscStop || dd.Outcome != "closed" {
|
||
t.Errorf("наступний такт мав зупинити драбину: %v/%s", dd.Action, dd.Outcome)
|
||
}
|
||
okStop, err := st.ApplyEscalation(ctx, x, dd)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !okStop {
|
||
t.Error("зупинку не записано — драбина застрягла назавжди")
|
||
}
|
||
}
|
||
if !back {
|
||
t.Error("сходинка не повернулась у чергу — драбину нікому зупинити")
|
||
}
|
||
|
||
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
|
||
a2 := newAlert(ruleID + ":dev:" + deviceID + ":2")
|
||
if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.CloseAlert(ctx, tenantID, a2); err != nil {
|
||
t.Fatalf("закриття: %v", err)
|
||
}
|
||
if _, err := st.pool.Exec(ctx, `
|
||
UPDATE alr.alert_escalations
|
||
SET stopped_at = NULL, stop_reason = NULL, next_at = now() - interval '1 minute'
|
||
WHERE alert_id = $1
|
||
`, a2); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Драбину штучно «воскресили» повз зупинку — рішення все одно має
|
||
// зупинити її, бо стан алерту читається перед кожною сходинкою.
|
||
dueClosed, err := st.TakeDueEscalations(ctx, 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var found bool
|
||
for _, s := range dueClosed {
|
||
if s.AlertID != a2 {
|
||
continue
|
||
}
|
||
found = true
|
||
if got := PlanEscalation(s, time.Now()); got.Action != EscStop || got.Outcome != "closed" {
|
||
t.Fatalf("закритий алерт: %v/%s", got.Action, got.Outcome)
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("тест не дістав сходинку закритого алерту — перевірка нічого не довела")
|
||
}
|
||
}
|