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("сходинка не повернулась у чергу — драбину нікому зупинити") } // --- Оренда: токен, а не строк ------------------------------------ // // Розрив, якого не закривала сама лише leased_until. Партія — до // сотні сходинок, кожна доставка з власним мережевим таймаутом, а // оренда — дві хвилини: партія переростає її на першому ж підвислому // каналі. Тоді рядок дістається наступному взяттю ще ДО того, як // руки дійшли до нашої сходинки, і без звірки володіння обидва // доставили б ту саму сходинку — два дзвінки о третій ночі й два // рядки «надіслано» на одну сходинку. // // Нижче відтворено рівно цей порядок і перевірено три речі, кожна з // яких могла б піти не так окремо. // (1) Оренда спливла, але НІХТО не перебрав — запис має пройти. // // Це не дрібниця й не піддавки: якби токен вимагав ще й живого // строку, кожна повільна партія втрачала б усі свої сходинки, і // драбина повзла б зі швидкістю одного такту на сходинку. Токен // доводить володіння, а володіння не зникає від того, що годинник // пробив, поки на рядок ніхто інший не претендує. a5 := newAlert(ruleID + ":dev:" + deviceID + ":5") if err := st.ArmEscalation(ctx, tenantID, a5, 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`, a5); err != nil { t.Fatal(err) } // takeOne бере чергу й дістає з неї знімок потрібного алерту. takeOne := func(alertID string) EscalationSnapshot { t.Helper() batch, err := st.TakeDueEscalations(ctx, 10) if err != nil { t.Fatalf("взяття черги: %v", err) } for _, x := range batch { if x.AlertID == alertID { return x } } t.Fatalf("сходинка алерту %s не потрапила в чергу", alertID) return EscalationSnapshot{} } // expireLease старить оренду, НЕ чіпаючи токена: саме так виглядає // рядок, коли процес ще йде по партії, а строк уже вийшов. expireLease := func(alertID string) { t.Helper() if _, err := st.pool.Exec(ctx, `UPDATE alr.alert_escalations SET leased_until = now() - interval '1 second' WHERE alert_id = $1`, alertID); err != nil { t.Fatal(err) } } // ladder читає стан драбини так, як його бачить база. ladder := func(alertID string) (stepIdx int, token string, nextAt *time.Time) { t.Helper() if err := st.pool.QueryRow(ctx, ` SELECT step_idx, COALESCE(lease_token::text,''), next_at FROM alr.alert_escalations WHERE alert_id = $1 `, alertID).Scan(&stepIdx, &token, &nextAt); err != nil { t.Fatal(err) } return stepIdx, token, nextAt } sLate := takeOne(a5) if sLate.LeaseToken == "" { t.Fatal("взяття не поставило токен оренди — звіряти буде нічого") } expireLease(a5) dLate := PlanEscalation(sLate, time.Now()) if dLate.Action != EscFire { t.Fatalf("сходинка мала спрацювати: %v/%s", dLate.Action, dLate.Outcome) } okLate, err := st.ApplyEscalation(ctx, sLate, dLate) if err != nil { t.Fatal(err) } if !okLate { t.Error("запис не пройшов, хоча оренду ніхто не перебирав — " + "повільна партія втрачатиме всі свої сходинки") } // (2) Оренду ПЕРЕБРАЛИ — запізнілий запис не має пройти. a6 := newAlert(ruleID + ":dev:" + deviceID + ":6") if err := st.ArmEscalation(ctx, tenantID, a6, 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`, a6); err != nil { t.Fatal(err) } first := takeOne(a6) // перший інстанс: узяв і пішов доставляти партію expireLease(a6) // партія затягнулась довше за оренду second := takeOne(a6) // другий інстанс перебрав ту саму сходинку if first.LeaseToken == second.LeaseToken { t.Fatal("два взяття отримали однаковий токен — перевірка нічого не доводить") } if first.StepIdx != second.StepIdx { t.Fatalf("другому взяттю дісталась інша сходинка (%d проти %d) — "+ "відтворено не той розрив", second.StepIdx, first.StepIdx) } dFirst := PlanEscalation(first, time.Now()) if dFirst.Action != EscFire { t.Fatalf("сходинка мала спрацювати: %v/%s", dFirst.Action, dFirst.Outcome) } okFirst, err := st.ApplyEscalation(ctx, first, dFirst) if err != nil { t.Fatal(err) } if okFirst { t.Error("запис пройшов під перебраною орендою — сходинка піде в канали вдруге") } // Відмова має бути БЕЗ наслідків: чужий запис не зриває ані оренду // того, хто зараз працює, ані стан драбини. Інакше запізнілий // інстанс, нічого не доставивши, псував би роботу тому, хто доставить. stepAfter, tokenAfter, nextAfter := ladder(a6) if stepAfter != first.StepIdx { t.Errorf("невдалий запис усе-таки просунув драбину: step_idx = %d", stepAfter) } if tokenAfter != second.LeaseToken { t.Errorf("невдалий запис зірвав оренду того, хто працює: токен %q", tokenAfter) } if nextAfter == nil { t.Error("невдалий запис зняв час наступної сходинки — драбина осиротіла") } // (3) І драбина не застрягла. // // Найгірший розклад: другий інстанс, який перебрав сходинку, теж не // дійшов до запису (впав, викотили нову версію). Тоді сходинка не // доставлена НІКИМ, і єдине, що має її врятувати, — спливання // оренди. Перевіряємо, що наступний такт бере її з тим самим // step_idx і доводить до розв'язку. expireLease(a6) third := takeOne(a6) if third.LeaseToken == second.LeaseToken || third.LeaseToken == first.LeaseToken { t.Error("нове взяття не оновило токен — оренда перестала бути власністю") } if third.StepIdx != first.StepIdx { t.Errorf("сходинку загублено: наступний такт узяв step_idx = %d замість %d", third.StepIdx, first.StepIdx) } dThird := PlanEscalation(third, time.Now()) if dThird.Action != EscFire { t.Fatalf("сходинка мала спрацювати: %v/%s", dThird.Action, dThird.Outcome) } okThird, err := st.ApplyEscalation(ctx, third, dThird) if err != nil { t.Fatal(err) } if !okThird { t.Fatal("наступний такт не зміг записати рішення — драбина застрягла назавжди") } stepDone, tokenDone, nextDone := ladder(a6) if stepDone != first.StepIdx+1 { t.Errorf("драбина не просунулась: step_idx = %d", stepDone) } if tokenDone != "" { t.Errorf("токен не знято разом з орендою: %q", tokenDone) } if nextDone == nil { t.Error("у живої драбини не лишилось часу наступної сходинки") } // І найпізніший з усіх: перший інстанс, який усе ще тримає свій // протухлий знімок, після цього теж нічого не запише. okStale, err := st.ApplyEscalation(ctx, first, dFirst) if err != nil { t.Fatal(err) } if okStale { 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("тест не дістав сходинку закритого алерту — перевірка нічого не довела") } }