Ескалації: журнал більше не бреше, ack не воскрешає драбину
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m50s
CI / agent (push) Successful in 1m2s

Три вади, знайдені рецензією, яких щасливий шлях показати не міг:

* outcome='sent' писався до доставки; помилка читання каналів клала в
  кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
  слідом «надіслано». Канали тепер читаються до просування стану,
  журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
  партії не рятувало людину від дзвінка.
* час брався раз на партію.

Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).

І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-28 17:20:18 +03:00
parent 3b9217de3c
commit cedf1d261d
20 changed files with 1719 additions and 56 deletions

View file

@ -7320,3 +7320,134 @@ FAIL modal > тло стає inert, поки вікно відкрите
**Чого тести НЕ покривають:** саму подорож події від прогону відповідності **Чого тести НЕ покривають:** саму подорож події від прогону відповідності
до каналу — це перевірено вручну на стенді, не в CI. до каналу — це перевірено вручну на стенді, не в CI.
## 2026-08-28 — Вимкнення правила через форму: алерти висіли вічно, а правка вмикала назад
Дві вади навколо одного прапорця, знайдені рецензією й підтверджені на коді.
**Вада А: `PUT` вимикав правило, не гасячи його алертів.** `SetRuleEnabled`
(перемикач у списку) після вимкнення кличе `resolveRuleAlerts`і має на це
причину: вимкнене правило випадає з `ActiveRules`, тобто `ResolveMissing` за
ним більше не біжить і закрити свої алерти воно вже не зможе. `UpdateRule`
(та сама дія, але з форми) цього не робила. Алерт лишався в `firing`
назавжди, а драбина ескалації продовжувала будити за ним людей — з повторами
це тижні.
**Вада Б: відсутнє `enabled` сервер читав як згоду ввімкнути.** Форма правил
поля не надсилала взагалі (перемикача в ній немає — він у списку), а
`httpapi/alerts.go` ставив `req.Enabled == nil || *req.Enabled`. Тобто
відкрити НАВМИСНО вимкнене правило, поправити в ньому будь-що й зберегти —
означало мовчки його ввімкнути. У формі при цьому не змінювалось нічого.
**Що зроблено.**
* `store.RuleInput.Enabled` став `*bool`: «поля не було» і «поле = false» —
різні наміри, і `bool` їх не розрізняв. На створенні `nil` досі означає
«увімкнене», на правці — «не чіпати».
* `store.planRuleEnabled` — рішення окремою чистою функцією: яким стане
прапорець і чи гасити алерти. Гасіння прив'язане до ПЕРЕХОДУ
«увімкнене → вимкнене», а не до нового значення: повторне збереження вже
вимкненого правила не додає зайвих подій у `event_outbox`.
* `store.UpdateRule` читає стан до правки через `SELECT … FOR UPDATE` у тій
самій транзакції — інакше між читанням і записом уміщається перемикач зі
списку, і гасіння не спрацювало б у жодному з двох записів.
* Форма (`web/src/pages/RulesPage.tsx`) тепер надсилає `enabled` явно.
Обидва боки полагоджені навмисно: сервер — щоб не ламати чужі скрипти,
написані за цим API, форма — щоб не покладатись на здогад узагалі.
* Тести: `alerts_rule_update_test.go` (чотири випадки рішення),
`alerts_rule_update_db_test.go` (проти бази: гасіння, черга подій,
відсутність зайвого проходу), `web/src/test/rules.test.tsx` (форма
надсилає стан обома боками — вимкнений і увімкнений).
**Чого тести НЕ покривають:** прогін проти бази вимагає `NETPULSE_TEST_DSN`
і на машині, де це писалось, не запускався — Postgres там немає. Тобто SQL
самої `UpdateRule` перевірено лише компілятором і читанням. Не покрито також
дальший ланцюг: що погашений алерт справді знімає взведену драбину (це
робить фон за подією `alert.resolved`, не сама `UpdateRule`) і що гонка
«форма проти перемикача» справді розв'язується замком — обидва потребують
живого стенду.
**Помічено, але НЕ виправлено (окремий обсяг).** Зміна селектора чи умови
правила старі алерти не чіпає. Для опитуваних джерел це самолікується
наступним тіком: `ResolveMissing` закриває все, чого немає серед свіжих
кандидатів. Для ПОДІЄВИХ (`syslog`, `ncm`, `compliance`, `trap`) — ні:
`engine.go` їх у цьому циклі пропускає взагалі, тож алерт, який більше не
відповідає жодному селектору, висить до `auto_close_seconds`, а при нулі —
доки його не закриє людина. Те саме стосується зміни `source` з опитуваного
на подієвий.
---
## 2026-08-28 — Ескалації: рецензія, три виправлення й перший справжній прогін тестів проти бази
### Ескалації доведено до кінця на живих даних
Наскрізно, без вигаданого правила: прогін відповідності → 6 критичних
порушень → 6 алертів → драбина. 18 доставок у Telegram (перше сповіщення
+ дві сходинки × 6 алертів), усі `sent`. Драбина зупинилась сама з
причиною «подієвий алерт не повторюється».
### Рецензія знайшла три вади, яких щасливий шлях показати не міг
**Журнал ескалацій брехав.** `outcome='sent'` писався ДО доставки. Сходинка,
чиї канали видалили, вимкнули або підняли їм поріг серйозності, лишала в
журналі «надіслано» — доказ, заради якого журнал існує, стверджував
протилежне. Гірше: помилка читання каналів клала в кеш **порожню мапу**,
і одна тимчасова невдача з'їдала всі належні сходинки кабінету за тік,
кожна з них — зі слідом «надіслано».
Виправлено перестановкою порядку: канали читаються ДО просування стану
(не прочитались — сходинка лишається належною й повториться), стан
просувається ДО доставки (щоб не надіслати двічі), а журнал пишеться
ПІСЛЯ доставки — з тим, що сталося насправді (`no_channels`, а не
`sent`). `ApplyEscalation` розділено на просування стану й
`LogEscalationStep`.
**Драбину можна було воскресити після «Прийняти».** `UPDATE` у
`ApplyEscalation` не мав умови `stopped_at IS NULL`. Партія обробляється
послідовно, кожна доставка з власним таймаутом, тож розрив між «взяли
сходинку в чергу» і «надіслали» вимірюється хвилинами. Людина підтверджує
алерт — а `UPDATE` знімає `stopped_at` і будить її знову. Тепер такий
`UPDATE` не влучає в рядок, сходинка скасовується з записом у лог.
**Час брався один раз на партію** — рішення пізніх сходинок рахувались від
застарілого моменту. Тепер на кожну сходинку свій.
### Суміжні вади, знайдені тією ж рецензією
* `UpdateRule` з `enabled:false` не гасив алертів правила (на відміну від
`SetRuleEnabled`) — вони висіли `firing` вічно, а драбина будила людей
до ~21 доби. Рішення винесено в чисту `planRuleEnabled`, гасіння
прив'язане до переходу «увімкнене → вимкнене».
* Сервер домислював `enabled: true` за відсутнім полем і на ОНОВЛЕННІ, а
форма правил це поле не слала — редагування вимкненого правила мовчки
його вмикало. Полагоджено обидва боки.
* `channel_ids` сходинок драбини не звірялись із реальними каналами —
можна було зберегти драбину з мертвими або **чужими** id, і вона
виглядала налаштованою. Перевірка закрита у store, а не в HTTP.
Видалення каналу тепер прибирає його зі сходинок і попереджає, які
драбини зачепить. Сходинка лишається порожньою, а не викидається:
викинута мовчки зсунула б чергування.
### scripts/dbtest.sh — і перший прогін тестів проти справжньої бази
Найдорожча тиха відмова проєкту: тести проти бази мовчки пропускаються
без `NETPULSE_TEST_DSN`, тож `go test ./...` півтора року показував «ok»,
а всередині кожного стояв `t.Skip`. Ізоляція кабінетів, стеля тарифу,
гасіння алертів, драбини, чистка пристрою, SLA, запис карт — усе
компілювалось і не виконувалось.
Тепер є `scripts/dbtest.sh`: піднімає одноразовий Postgres, котить
міграції, ганяє `./internal/...`. Прогнано на стенді проти чистої бази —
**63 міграції, усі пакети зелені**, включно з новими перевірками
підтвердження посеред партії, оновлення правила й посилань на канали.
`scripts/check.sh` лишається швидким (без Docker) — його ганяють на кожну
правку. Цей — перед розгортанням.
**Що ще НЕ покрито:** доставка ескалації блокує весь такт движка (мертвий
вебхук одного кабінету затримує обчислення правил усім); алерт, народжений
під заглушенням або в тиху годину, не отримує ні першого сповіщення, ні
драбини — ніколи; втрата advisory-lock може дати подвійне сповіщення, бо
`ApplyEscalation` не звіряє оренду; та сама відсутність перевірки каналів
живе в `alr.rules.channel_ids` і `alr.routes.channel_ids`.

70
scripts/dbtest.sh Normal file
View file

@ -0,0 +1,70 @@
#!/bin/sh
# Прогін тестів проти СПРАВЖНЬОЇ бази.
#
# ЧОМУ ЦЕ ОКРЕМИЙ СКРИПТ
#
# Тести проти бази мовчки пропускаються без NETPULSE_TEST_DSN — і саме
# тому вони півтора року нічого не перевіряли: `go test ./...` показував
# «ok», а всередині кожного з них стояв t.Skip. «Пропущено» в підсумку
# виглядає рівно як «пройдено», і це найдорожча тиха відмова в проєкті:
# перевірка ізоляції кабінетів, стелі тарифу, гасіння алертів і драбин
# ескалації існували, компілювались і не виконувались.
#
# scripts/check.sh лишається швидким (без Docker і без бази) — його
# ганяють на кожну правку. Цей скрипт повільніший і потребує Postgres,
# тож викликається окремо: перед розгортанням і в CI.
#
# БАЗА МУСИТЬ БУТИ ОДНОРАЗОВОЮ. Тести пишуть, видаляють і перемикають
# ролі; напрямляти їх на робочу базу не можна. Скрипт свою базу створює
# сам і дропає на початку кожного прогону.
#
# ВИКОРИСТАННЯ
# scripts/dbtest.sh # підніме свій Postgres у Docker
# NETPULSE_TEST_DSN=... scripts/dbtest.sh # проти готової бази
#
set -u
SRC="$(cd "$(dirname "$0")/.." && pwd)"
OWN_DB=0
if [ -z "${NETPULSE_TEST_DSN:-}" ]; then
# Своя база на час прогону. Порт випадковий-таки ні: фіксований, але
# нетиповий, щоб не зіткнутись із локальним Postgres розробника.
PORT=${NETPULSE_TEST_PORT:-55433}
NAME=netpulse-dbtest
echo "== піднімаю одноразовий Postgres :$PORT"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -p "$PORT:5432" \
-e POSTGRES_USER=netpulse -e POSTGRES_PASSWORD=probe \
-e POSTGRES_DB=netpulse_probe \
timescale/timescaledb:2.17.2-pg16 >/dev/null || exit 1
OWN_DB=1
NETPULSE_TEST_DSN="postgres://netpulse:probe@127.0.0.1:$PORT/netpulse_probe?sslmode=disable"
export NETPULSE_TEST_DSN
n=0
until docker exec "$NAME" pg_isready -h 127.0.0.1 -U netpulse >/dev/null 2>&1; do
n=$((n+1)); [ "$n" -gt 60 ] && { echo "база не піднялась"; exit 1; }
sleep 1
done
fi
cleanup() {
[ "$OWN_DB" = 1 ] && docker rm -f netpulse-dbtest >/dev/null 2>&1
return 0
}
trap cleanup EXIT INT TERM
echo "== накат міграцій"
( cd "$SRC/server" && NETPULSE_DSN="$NETPULSE_TEST_DSN" go run ./cmd/netpulse-migrate ) || exit 1
echo "== тести"
( cd "$SRC/server" && go test ./internal/... -count=1 )
rc=$?
echo
if [ "$rc" = 0 ]; then
echo "усе зелене проти бази"
else
echo "!!! тести проти бази не пройшли"
fi
exit $rc

View file

@ -107,6 +107,7 @@ JWT — ні.
| `POST` | `/api/v1/mutes` | заглушити пристрій (`alerts:ack`) | | `POST` | `/api/v1/mutes` | заглушити пристрій (`alerts:ack`) |
| `GET` | `/api/v1/alert-rules` | правила з лічильником активних | | `GET` | `/api/v1/alert-rules` | правила з лічильником активних |
| `POST` | `/api/v1/alert-rules` | створити правило (`alerts:write`) | | `POST` | `/api/v1/alert-rules` | створити правило (`alerts:write`) |
| `PUT` | `/api/v1/alert-rules/{id}` | замінити правило цілком (`alerts:write`) |
| `PATCH` | `/api/v1/alert-rules/{id}` | увімкнути/вимкнути (`alerts:write`) | | `PATCH` | `/api/v1/alert-rules/{id}` | увімкнути/вимкнути (`alerts:write`) |
| `DELETE` | `/api/v1/alert-rules/{id}` | видалити правило (`alerts:write`) | | `DELETE` | `/api/v1/alert-rules/{id}` | видалити правило (`alerts:write`) |
| `GET` | `/api/v1/channels` | канали доставки (без секретів) | | `GET` | `/api/v1/channels` | канали доставки (без секретів) |
@ -1172,6 +1173,17 @@ JSON у таблиці правил.
`PUT /api/v1/alert-rules/{id}` замінює правило цілком. `PUT /api/v1/alert-rules/{id}` замінює правило цілком.
**`enabled` на правці не домислюється.** Поля немає в тілі — стан
перемикача лишається таким, яким був. Раніше сервер підставляв `true`
кожному, хто поля не надіслав, і правка вимкненого правила мовчки його
вмикала. На СТВОРЕННІ відсутнє поле досі означає «увімкнене»: правило,
заведене вимкненим, не робить нічого й виглядає як забуте.
**Вимкнення правила гасить його активні алерти** — і через `PATCH`, і
через `PUT`. Без цього вони висіли б у `firing` вічно: вимкнене правило
випадає з вибірки движка, тобто закрити їх немає кому, а драбина
ескалації справно будила б за ними людей тижнями.
### Ескалація ### Ескалація
Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати. Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати.

View file

@ -54,46 +54,101 @@ func (e *Engine) escalate(ctx context.Context) {
// може бути десяток. // може бути десяток.
channels := map[string]map[string]store.Channel{} channels := map[string]map[string]store.Channel{}
now := time.Now()
for _, snap := range due { for _, snap := range due {
d := store.PlanEscalation(snap, now) // Час береться на кожну сходинку, а не на партію: між першою і
// останньою може пройти скільки завгодно — кожна доставка має
// власний таймаут. Застарілий момент зсував би стелю життя й
// підлогу інтервалу рівно на цю затримку.
d := store.PlanEscalation(snap, time.Now())
// Запис ДО надсилання — той самий порядок, що й у журналі // Канали читаються ДО просування стану. Порядок не косметичний:
// доставки, і з тієї ж причини: якщо процес упаде між ними, // якщо їх не вдалось прочитати, сходинка має лишитись належною й
// краще не надіслати сходинку, ніж надіслати її вдруге. // повторитись наступного тіку. У зворотному порядку тимчасова
if err := e.st.ApplyEscalation(ctx, snap, d); err != nil { // помилка бази списувала б сходинку назавжди — і в журналі
// стояло б «надіслано».
var byID map[string]store.Channel
if d.Action == store.EscFire {
var err error
byID, err = e.escalationChannels(ctx, snap.TenantID, channels)
if err != nil {
e.log.Error("читання каналів для ескалації — сходинку відкладено",
"tenant", snap.TenantID, "алерт", snap.AlertID, "помилка", err)
continue
}
}
applied, err := e.st.ApplyEscalation(ctx, snap, d)
if err != nil {
e.log.Error("запис рішення ескалації", "алерт", snap.AlertID, "помилка", err) e.log.Error("запис рішення ескалації", "алерт", snap.AlertID, "помилка", err)
continue continue
} }
if !applied {
// Драбину зупинили, поки сходинка чекала своєї черги —
// найчастіше людина натиснула «Прийняти». Це не помилка, це
// той випадок, заради якого кнопка й існує.
e.log.Info("сходинку скасовано: драбину вже зупинено",
"алерт", snap.AlertID, "сходинка", d.StepIdx+1)
continue
}
if d.Action != store.EscFire { if d.Action != store.EscFire {
e.logStep(ctx, snap, d, d.Outcome, d.Detail)
e.log.Debug("ескалацію не продовжено", "алерт", snap.AlertID, e.log.Debug("ескалацію не продовжено", "алерт", snap.AlertID,
"причина", d.Outcome, "деталі", d.Detail) "причина", d.Outcome, "деталі", d.Detail)
continue continue
} }
byID, ok := channels[snap.TenantID] if sent := e.notifier.deliverEscalation(ctx, snap, d, byID); sent == 0 {
if !ok { // Сходинка списана — інакше вона поверталася б щотіку. Але в
cs, err := e.st.LoadChannels(ctx, snap.TenantID, e.ring) // журнал іде правда, а не намір: «надіслано» на сходинці, яка
if err != nil { // нікуди не пішла, — саме та мовчазна відмова, від якої
e.log.Error("читання каналів для ескалації", // ескалація рятує.
"tenant", snap.TenantID, "помилка", err) e.logStep(ctx, snap, d, "no_channels",
channels[snap.TenantID] = map[string]store.Channel{} "жоден канал сходинки не прийняв повідомлення (видалено, вимкнено або поріг серйозності)")
continue continue
}
byID = make(map[string]store.Channel, len(cs))
for _, c := range cs {
byID[c.ID] = c
}
channels[snap.TenantID] = byID
} }
e.logStep(ctx, snap, d, d.Outcome, d.Detail)
e.notifier.deliverEscalation(ctx, snap, d, byID)
} }
} }
// deliverEscalation шле сходинку в її канали. // escalationChannels читає канали кабінету, кешуючи лише успіх.
//
// Кешувати помилку не можна: порожня мапа в кеші означала б, що одна
// тимчасова невдача з'їдає всі належні сходинки кабінету за цей тік, і
// кожна з них виглядала б доставленою.
func (e *Engine) escalationChannels(ctx context.Context, tenantID string,
cache map[string]map[string]store.Channel) (map[string]store.Channel, error) {
if byID, ok := cache[tenantID]; ok {
return byID, nil
}
cs, err := e.st.LoadChannels(ctx, tenantID, e.ring)
if err != nil {
return nil, err
}
byID := make(map[string]store.Channel, len(cs))
for _, c := range cs {
byID[c.ID] = c
}
cache[tenantID] = byID
return byID, nil
}
// logStep пише рядок журналу й не дає невдалому запису зупинити чергу.
func (e *Engine) logStep(ctx context.Context, snap store.EscalationSnapshot,
d store.EscalationDecision, outcome, detail string) {
if err := e.st.LogEscalationStep(ctx, snap, d, outcome, detail); err != nil {
e.log.Error("журнал ескалації", "алерт", snap.AlertID, "помилка", err)
}
}
// deliverEscalation шле сходинку в її канали й повертає, скільком дійшло.
//
// Кількість потрібна тому, хто пише журнал: сходинка без жодного каналу
// має лишити слід «нікуди не пішло», а не «надіслано».
func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationSnapshot, func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationSnapshot,
d store.EscalationDecision, byID map[string]store.Channel) { d store.EscalationDecision, byID map[string]store.Channel) int {
a := snap.Alert a := snap.Alert
head := escalationHeader(snap, d) head := escalationHeader(snap, d)
@ -117,14 +172,11 @@ func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationS
} }
if sent == 0 { if sent == 0 {
// Сходинка вже списана (рішення записано до надсилання), і це
// правильно: інакше вона поверталася б щотіку. Але мовчазна
// втрата сходинки — саме те, від чого ескалація рятує, тож слід
// лишається в журналі процесу.
n.log.Warn("сходинка ескалації не мала куди піти", n.log.Warn("сходинка ескалації не мала куди піти",
"алерт", snap.AlertID, "сходинка", d.StepIdx+1, "алерт", snap.AlertID, "сходинка", d.StepIdx+1,
"каналів у сходинці", len(d.ChannelIDs)) "каналів у сходинці", len(d.ChannelIDs))
} }
return sent
} }
// escalationHeader пояснює людині, чому вона це читає. // escalationHeader пояснює людині, чому вона це читає.

View file

@ -303,10 +303,24 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
Condition: string(req.Condition), Condition: string(req.Condition),
ForSeconds: req.ForSeconds, ForSeconds: req.ForSeconds,
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology, DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
Enabled: req.Enabled == nil || *req.Enabled, // `enabled` передається як є, разом з «поля не було».
ChannelIDs: req.ChannelIDs, //
NotifySchedule: string(req.NotifySchedule), // На створенні відсутність поля лишається згодою ввімкнути — це
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve, // розумно й так було завжди (правило, заведене вимкненим, не
// робить нічого). На ОНОВЛЕННІ домислювати вже не можна: сервер
// ставив `true` кожному, хто поля не надіслав, тож правка опису
// вимкненого правила мовчки його вмикала — і людина дізнавалась
// про це зі сповіщення, а не з форми.
//
// Обрано саме «не було = не чіпати», а не «не було = 400»: PUT
// без `enabled` шле і власна форма, і будь-який чужий скрипт,
// написаний за цим API, тож вимога поля зламала б їх усі одним
// оновленням. Прапорець при цьому лишається керованим — його
// явно шлють і форма (тепер), і PATCH-перемикач.
Enabled: req.Enabled,
ChannelIDs: req.ChannelIDs,
NotifySchedule: string(req.NotifySchedule),
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
AutoCloseSeconds: autoClose, AutoCloseSeconds: autoClose,
MinIntervalSeconds: minInterval, MinIntervalSeconds: minInterval,
@ -424,6 +438,20 @@ func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *P
if channels == nil { if channels == nil {
channels = []store.Channel{} channels = []store.Channel{}
} }
// Драбини, що спираються на канал, — частина відповіді на питання
// «що зламається, якщо його видалити». Те саме питання вже має
// відповідь у переліку драбин (rule_count), і тут вона потрібна з
// тієї ж причини.
//
// Невдача не валить перелік: без назв драбин сторінка лишається
// робочою, а без каналів — ні.
if refs, err := s.store.ChannelEscalationRefs(r.Context(), p.TenantID); err != nil {
s.log.Error("драбини на каналах", "err", err)
} else {
for i := range channels {
channels[i].Escalations = refs[channels[i].ID]
}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": channels}) writeJSON(w, http.StatusOK, map[string]any{"channels": channels})
} }
@ -685,6 +713,15 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено") writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
return return
} }
// Відмова від store — це відмова людині, а не збій сервера:
// саме тут повертається «сходинка N посилається на канал, якого
// немає в цьому кабінеті». Без цієї гілки вона вийшла б назовні
// як «внутрішня помилка», тобто перевірка спрацювала б, а
// причини ніхто б не побачив.
if errors.Is(err, store.ErrInvalid) {
writeError(w, http.StatusBadRequest, "bad_steps", err.Error())
return
}
if isUniqueViolation(err) { if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є") writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є")
return return

View file

@ -0,0 +1,168 @@
package store
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
)
// Канали в сходинках драбини ПРОТИ БАЗИ.
//
// Чиста ValidateStepChannels перевіряє рішення, і це половина. Друга
// половина існує тільки в базі й рішенням не перевіряється взагалі:
//
// - чи справді перелік каналів, який отримує перевірка, звужений до
// кабінету (тобто чи не проходить UUID сусіда);
// - чи прибирає видалення каналу посилання на нього зі сходинок —
// на JSONB зовнішнього ключа немає, і ON DELETE SET NULL, яким
// 0066 чистить драбину з правила, тут не спрацює.
//
// Мовчки пропускається без NETPULSE_TEST_DSN. Запускати треба на
// ОДНОРАЗОВІЙ базі — тест створює два кабінети й видаляє їх з усім
// вмістом:
//
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
// go test ./internal/store/ -run ChannelRefs -v
func TestChannelRefsAgainstDB(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)
stamp := strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
newTenant := func(prefix string) string {
t.Helper()
var id string
if err := st.pool.QueryRow(ctx, `
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
`, prefix+"-"+stamp, "Перевірка каналів драбини").Scan(&id); err != nil {
t.Fatalf("кабінет %s: %v", prefix, err)
}
t.Cleanup(func() {
_, _ = st.pool.Exec(context.Background(), `DELETE FROM core.tenants WHERE id = $1`, id)
})
return id
}
newChannel := func(tenantID, name string) string {
t.Helper()
var id 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, name).Scan(&id); err != nil {
t.Fatalf("канал %s: %v", name, err)
}
return id
}
ours := newTenant("chref-a")
theirs := newTenant("chref-b")
duty := newChannel(ours, "черговий")
lead := newChannel(ours, "старший зміни")
alien := newChannel(theirs, "чужий канал")
// --- Канал чужого кабінету не зберігається -------------------------
//
// Найдорожча з двох вад. Ззовні це не витік (доставити в чужий
// канал усе одно нікуди — движок читає канали лише свого кабінету),
// а гірше: драбина виглядає налаштованою й мовчить. Закривати це в
// HTTP-шарі марно — обійде будь-який інший шлях запису, тому тест
// б'є прямо в store.
_, err = st.SaveEscalationPolicy(ctx, ours, "", EscalationPolicy{
Name: "Підсунутий сусід",
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{alien}}},
})
if err == nil {
t.Fatal("драбина з каналом ЧУЖОГО кабінету збереглася")
}
if !errors.Is(err, ErrInvalid) {
t.Fatalf("відмова має бути ErrInvalid: %v", err)
}
// --- Неіснуючий канал не зберігається ------------------------------
_, err = st.SaveEscalationPolicy(ctx, ours, "", EscalationPolicy{
Name: "Привид",
Steps: []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{duty}},
{AfterMin: 45, ChannelIDs: []string{"00000000-0000-4000-8000-0000000000ff"}},
},
})
if err == nil || !strings.Contains(err.Error(), "сходинка 2") {
t.Fatalf("драбина з неіснуючим каналом на другій сходинці: %v", err)
}
// --- Справна драбина зберігається ----------------------------------
policyID, err := st.SaveEscalationPolicy(ctx, ours, "", EscalationPolicy{
Name: "Нічне чергування",
Steps: []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{duty}},
{AfterMin: 45, ChannelIDs: []string{duty, lead}},
},
})
if err != nil {
t.Fatalf("справна драбина не збереглася: %v", err)
}
// --- Перелік «хто на кого спирається» ------------------------------
//
// Це те, що бачить людина перед видаленням каналу. Порожній перелік
// означав би те саме мовчазне видалення, з якого все й почалось.
refs, err := st.ChannelEscalationRefs(ctx, ours)
if err != nil {
t.Fatalf("посилання драбин: %v", err)
}
if len(refs[duty]) != 1 || refs[duty][0] != "Нічне чергування" {
t.Fatalf("канал чергового не показано як зайнятий драбиною: %v", refs[duty])
}
// Канал згадано у двох сходинках однієї драбини — назва має бути
// одна: людині цікаво, ЩО зламається, а не скільки разів.
if len(refs[lead]) != 1 {
t.Fatalf("драбина порахована двічі: %v", refs[lead])
}
if len(refs[alien]) != 0 {
t.Fatal("у перелік кабінету потрапив чужий канал")
}
// --- Видалення каналу чистить сходинки -----------------------------
if err := st.DeleteChannel(ctx, ours, duty); err != nil {
t.Fatalf("видалення каналу: %v", err)
}
ps, err := st.ListEscalationPolicies(ctx, ours)
if err != nil {
t.Fatalf("перелік драбин: %v", err)
}
var got EscalationPolicy
for _, p := range ps {
if p.ID == policyID {
got = p
}
}
if len(got.Steps) != 2 {
t.Fatalf("видалення каналу зсунуло драбину: %d сходинок замість 2", len(got.Steps))
}
// Перша сходинка лишається — порожня й видима. Саме порожня, а не
// викинута: зникла сходинка мовчки змінила б чергування, якого
// ніхто не міняв.
if len(got.Steps[0].ChannelIDs) != 0 {
t.Fatalf("посилання на видалений канал лишилось у сходинці 1: %v", got.Steps[0].ChannelIDs)
}
if len(got.Steps[1].ChannelIDs) != 1 || got.Steps[1].ChannelIDs[0] != lead {
t.Fatalf("сходинка 2 має лишитись зі старшим зміни: %v", got.Steps[1].ChannelIDs)
}
if got.Steps[0].AfterMin != 15 || got.Steps[1].AfterMin != 45 {
t.Fatalf("хвилини сходинок змінились: %v", got.Steps)
}
}

View file

@ -24,6 +24,12 @@ type Channel struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Secret string `json:"-"` Secret string `json:"-"`
HasSecret bool `json:"has_secret"` HasSecret bool `json:"has_secret"`
// Назви драбин ескалації, сходинки яких посилаються на цей канал.
//
// Заповнюється лише для переліку в UI (ChannelEscalationRefs), а не
// в LoadChannels: движку доставки це не потрібно, а рахувати на
// кожному тіку — платити без причини.
Escalations []string `json:"escalations,omitempty"`
} }
// Route — правило маршрутизації алерту в канали. // Route — правило маршрутизації алерту в канали.
@ -320,6 +326,113 @@ func (s *Store) CreateChannel(ctx context.Context, tenantID string, in ChannelIn
return id, err return id, err
} }
// escalationLadder — драбина в тому вигляді, в якому її читає чистка
// посилань: ідентифікатор, назва й розібрані сходинки.
type escalationLadder struct {
id string
name string
steps []EscalationStep
}
// readLadders читає драбини кабінету з уже розібраними сходинками.
//
// Читання окремо від запису навмисно: pgx не дає слати новий запит,
// поки не дочитано попередній, а чистка посилань — це саме «прочитати
// всі, переписати деякі».
func readLadders(ctx context.Context, tx pgx.Tx, tenantID string) ([]escalationLadder, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, name, steps::text
FROM alr.escalation_policies WHERE tenant_id = $1 ORDER BY name
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []escalationLadder
for rows.Next() {
var l escalationLadder
var steps string
if err := rows.Scan(&l.id, &l.name, &steps); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(steps), &l.steps); err != nil {
return nil, fmt.Errorf("політика %s: сходинки: %w", l.name, err)
}
out = append(out, l)
}
return out, rows.Err()
}
// removeChannelFromSteps прибирає канал зі сходинок і каже, чи щось
// змінилось.
//
// Сходинка, яка лишилась без каналів, ЛИШАЄТЬСЯ порожньою, а не
// зникає. Викинута сходинка мовчки зсунула б усе чергування нижче,
// якого людина не міняла; порожню видно і в переліку драбин, і у формі
// (вона не збережеться, поки канал не оберуть), а движок пише про неї
// в журнал окремим рядком.
func removeChannelFromSteps(steps []EscalationStep, channelID string) ([]EscalationStep, bool) {
changed := false
out := make([]EscalationStep, len(steps))
for i, s := range steps {
out[i] = s
kept := make([]string, 0, len(s.ChannelIDs))
for _, id := range s.ChannelIDs {
if id == channelID {
changed = true
continue
}
kept = append(kept, id)
}
out[i].ChannelIDs = kept
}
return out, changed
}
// ChannelEscalationRefs каже, які драбини посилаються на які канали:
// ідентифікатор каналу → назви драбин.
//
// Потрібне рівно для одного: щоб «Видалити канал» показало те саме, що
// вже показує «Видалити драбину» — скільки чужих налаштувань зараз
// перестане працювати. Мовчазне видалення каналу, на який спирається
// нічне чергування, коштує однієї пропущеної аварії, і дізнаються про
// це не в момент видалення.
func (s *Store) ChannelEscalationRefs(ctx context.Context, tenantID string) (map[string][]string, error) {
refs := map[string][]string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ladders, err := readLadders(ctx, tx, tenantID)
if err != nil {
return err
}
for _, l := range ladders {
// Драбину називаємо один раз, скільки б сходинок у неї не
// вело в цей канал: людині перед видаленням цікаво, ЩО
// зламається, а не скільки разів воно згадане.
for _, id := range ladderChannelIDs(l.steps) {
refs[id] = append(refs[id], l.name)
}
}
return nil
})
return refs, err
}
// ladderChannelIDs — канали драбини без повторів, у порядку появи.
func ladderChannelIDs(steps []EscalationStep) []string {
var out []string
seen := map[string]bool{}
for _, s := range steps {
for _, id := range s.ChannelIDs {
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
}
return out
}
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error { func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Секрет видаляється разом із каналом: залишений «на всякий // Секрет видаляється разом із каналом: залишений «на всякий
@ -348,6 +461,38 @@ func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) e
if deleted == 0 { if deleted == 0 {
return ErrAlertNotFound return ErrAlertNotFound
} }
// Посилання зі сходинок драбин прибираємо руками, бо прибрати їх
// нікому: 0066 чистить драбину з правила через ON DELETE SET
// NULL, але на масив усередині JSONB зовнішнього ключа немає.
// Залишений UUID видаленого каналу — це сходинка, яка виглядає
// налаштованою й не йде нікуди, тобто рівно та мовчазна
// обіцянка, заради якої ескалацію й заводили.
//
// Переписуємо в Go, а не запитом по JSONB: рішення «що саме
// лишається в сходинці» перевіряється тоді без Postgres, а
// запити зводяться до читання й запису.
ladders, err := readLadders(ctx, tx, tenantID)
if err != nil {
return err
}
for _, l := range ladders {
steps, changed := removeChannelFromSteps(l.steps, channelID)
if !changed {
continue
}
raw, err := json.Marshal(steps)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE alr.escalation_policies
SET steps = $3::jsonb, updated_at = now()
WHERE tenant_id = $1 AND id = $2
`, tenantID, l.id, string(raw)); err != nil {
return err
}
}
return nil return nil
}) })
} }

View file

@ -91,6 +91,65 @@ func ValidateEscalationSteps(steps []EscalationStep) error {
return nil return nil
} }
// ValidateStepChannels відмовляє в драбині, сходинка якої посилається на
// канал, якого в цьому кабінеті немає.
//
// Перевірка окремо від ValidateEscalationSteps, бо вона єдина потребує
// бази: решта драбини перевіряється як текст, а «чи є такий канал» —
// лише запитом. Розділення дозволяє тримати першу половину чистою й
// перевіреною без Postgres.
//
// Ціна відсутності цієї перевірки — рівно та сама мовчазна обіцянка,
// заради якої й написана вся 0066. Сходинка з неіснуючим UUID
// виглядає в переліку налаштованою, движок не знаходить для неї жодного
// каналу, списує її й пише в журнал «нікуди не пішло» — але читає той
// журнал уже той, хто прийшов розбиратися вранці, а не той, кого мали
// розбудити вночі.
//
// known — ідентифікатори каналів ЦЬОГО кабінету. Саме тому перевірка
// закриває й підстановку чужого UUID: перелік читається під RLS у тій
// самій транзакції, що й запис.
func ValidateStepChannels(steps []EscalationStep, known map[string]bool) error {
for i, s := range steps {
for _, id := range s.ChannelIDs {
if known[id] {
continue
}
// Ідентифікатор у тексті лишаємо навмисно: у формі канали
// обираються галочками, тож людина, яка це побачила,
// надсилає драбину не з форми — і їй потрібно знати, який
// саме рядок не прийнято.
return fmt.Errorf("%w: сходинка %d посилається на канал %s, якого немає в цьому "+
"кабінеті — оберіть канал зі списку на сторінці «Канали»", ErrInvalid, i+1, id)
}
}
return nil
}
// tenantChannelIDs — ідентифікатори каналів кабінету, як їх бачить ця
// транзакція.
//
// Читається саме в транзакції запису, а не окремим викликом до неї:
// інакше між перевіркою й записом лишалась би щілина, в якій канал
// встигає зникнути.
func tenantChannelIDs(ctx context.Context, tx pgx.Tx, tenantID string) (map[string]bool, error) {
rows, err := tx.Query(ctx,
`SELECT id::text FROM alr.channels WHERE tenant_id = $1`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
known := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
known[id] = true
}
return known, rows.Err()
}
// ListEscalationPolicies читає політики кабінету. // ListEscalationPolicies читає політики кабінету.
func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) { func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) {
var out []EscalationPolicy var out []EscalationPolicy
@ -142,6 +201,21 @@ func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p
} }
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Обидві перевірки тут, а не лише в HTTP: драбина — це список
// людей, яких будять уночі, і єдине місце, де він може бути
// перевірений раз і назавжди, — це запис у базу. Перевірка,
// продубльована в кожному обробнику, розходиться на першому ж
// новому шляху запису (імпорт, шаблон, API-токен).
if err := ValidateEscalationSteps(p.Steps); err != nil {
return err
}
known, err := tenantChannelIDs(ctx, tx, tenantID)
if err != nil {
return err
}
if err := ValidateStepChannels(p.Steps, known); err != nil {
return err
}
if id == "" { if id == "" {
return tx.QueryRow(ctx, ` return tx.QueryRow(ctx, `
INSERT INTO alr.escalation_policies INSERT INTO alr.escalation_policies
@ -550,22 +624,38 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
return out, rows.Err() return out, rows.Err()
} }
// ApplyEscalation записує рішення й веде журнал. // ApplyEscalation просуває стан драбини й повертає, чи вдалось.
// //
// Записується ДО надсилання. Порядок той самий, що в RecordNotification, // Стан записується ДО надсилання — з тієї ж причини, що й у
// і з тієї ж причини: падіння між записом і надсиланням лишає слід // RecordNotification: падіння між записом і надсиланням лишає
// «сходинку пройдено» на недоставленому повідомленні, а зворотний // непройдену сходинку, а зворотний порядок лишав би драбину на місці, і
// порядок лишав би драбину на місці — і після підйому вона надіслала б // після підйому вона надіслала б те саме вдруге. Краще не надіслати, ніж
// те саме вдруге. // надіслати двічі о третій ночі.
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) error { //
// А от ЖУРНАЛ пишеться після доставки (LogEscalationStep), і це окреме
// рішення. Журнал існує рівно для відповіді на «чому мене розбудили» та
// «чому не розбудили», тож рядок «надіслано» на сходинці, якій не
// знайшлось жодного каналу, гірший за відсутній: він перетворює
// доказ на брехню. Ціна — рідкісний випадок падіння між просуванням і
// записом: сходинка лишиться без рядка. Це видно (step_idx більший за
// кількість рядків) і це чесно.
//
// stopped_at IS NULL — не оптимізація, а перевірка стану. Між тим, як
// сходинку взяли в чергу, і тим, як до неї дійшли руки, людина могла
// натиснути «Прийняти»: партія обробляється послідовно, кожна доставка
// має власний таймаут, і розрив вимірюється хвилинами. Без цієї умови
// UPDATE зняв би stopped_at і воскресив зупинену драбину — тобто
// розбудив би саме того, хто щойно сказав «я цим займаюсь».
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) {
var next any var next any
if d.NextAt != nil { if d.NextAt != nil {
next = *d.NextAt next = *d.NextAt
} }
fired := d.Action == EscFire fired := d.Action == EscFire
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error { applied := false
if _, err := tx.Exec(ctx, ` err := s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE alr.alert_escalations UPDATE alr.alert_escalations
SET step_idx = $2, SET step_idx = $2,
repeat_idx = $3, repeat_idx = $3,
@ -576,16 +666,34 @@ func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d
stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END, stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END,
stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END
WHERE alert_id = $1 WHERE alert_id = $1
AND stopped_at IS NULL
`, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart, `, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart,
next, fired, d.Outcome); err != nil { next, fired, d.Outcome)
if err != nil {
return err return err
} }
applied = tag.RowsAffected() > 0
return nil
})
return applied, err
}
// LogEscalationStep записує в журнал те, що СПРАВДІ сталося зі сходинкою.
//
// Викликається після доставки, тому outcome тут може відрізнятись від
// того, що планувалось: сходинка, чиї канали видалили, вимкнули або
// підняли їм поріг серйозності, отримує 'no_channels', а не 'sent'.
// Саме заради цієї різниці журнал і винесено з ApplyEscalation.
func (s *Store) LogEscalationStep(ctx context.Context, snap EscalationSnapshot,
d EscalationDecision, outcome, detail string) error {
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
INSERT INTO alr.escalation_steps INSERT INTO alr.escalation_steps
(tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail) (tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
`, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID), `, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID),
d.StepIdx, d.RepeatIdx, d.Outcome, nullString(d.Detail)) d.StepIdx, d.RepeatIdx, outcome, nullString(detail))
return err return err
}) })
} }

View file

@ -0,0 +1,166 @@
package store
import (
"errors"
"strings"
"testing"
)
// Перевірки сходинки, яка посилається на канал.
//
// Ці тести описують одну ваду й одну її ціну. Драбина зберігалась із
// будь-яким рядком у channel_ids — хоч із UUID видаленого каналу, хоч
// із UUID каналу чужого кабінету. Форма показувала таку сходинку
// налаштованою; движок не знаходив для неї жодного каналу, списував її
// й ішов далі. Тобто драбина існувала, виглядала робочою і не будила
// нікого — рівно той стан, від якого ескалацію й заводили.
//
// ValidateEscalationSteps перевіряє форму драбини й не має доступу до
// бази; ValidateStepChannels перевіряє належність каналів і отримує
// перелік готовим. Розділені саме тому, що перша половина має лишитись
// перевіреною без Postgres — а без другої перша дає хибну впевненість.
func knownChannels(ids ...string) map[string]bool {
m := map[string]bool{}
for _, id := range ids {
m[id] = true
}
return m
}
// Головний випадок: канал, якого в кабінеті немає.
func TestStepChannelUnknownRefused(t *testing.T) {
steps := []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
{AfterMin: 45, ChannelIDs: []string{"ch-lead", "ch-gone"}},
}
err := ValidateStepChannels(steps, knownChannels("ch-duty", "ch-lead"))
if err == nil {
t.Fatal("драбина з неіснуючим каналом збереглася — вона нікого не розбудить")
}
if !errors.Is(err, ErrInvalid) {
t.Fatalf("відмова має бути ErrInvalid (інакше HTTP віддасть 500): %v", err)
}
// Відмова без номера сходинки марна: у драбині їх до десяти, і
// «щось не так із каналами» не каже людині, що саме виправляти.
if !strings.Contains(err.Error(), "сходинка 2") {
t.Fatalf("відмова не називає сходинку: %q", err)
}
}
// Дзеркальний випадок: усе на місці — відмови бути не має.
func TestStepChannelsKnownAccepted(t *testing.T) {
steps := []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
{AfterMin: 45, ChannelIDs: []string{"ch-duty", "ch-lead"}},
}
if err := ValidateStepChannels(steps, knownChannels("ch-duty", "ch-lead", "ch-boss")); err != nil {
t.Fatalf("справна драбина не збереглася: %v", err)
}
}
// Канал чужого кабінету — той самий випадок, і це головне в ньому.
//
// Перевірка не знає слова «чужий»: їй дають перелік каналів ЦЬОГО
// кабінету, прочитаний під RLS у транзакції запису. Тому підставлений
// UUID сусіда не проходить не як окремий випадок, а як частина
// загального правила — і його не можна забути закрити окремо.
func TestStepChannelFromOtherTenantRefused(t *testing.T) {
const foreign = "00000000-0000-4000-8000-000000000001"
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{foreign}}}
err := ValidateStepChannels(steps, knownChannels("ch-duty"))
if err == nil {
t.Fatal("канал чужого кабінету прийнято в сходинку")
}
if !strings.Contains(err.Error(), foreign) {
t.Fatalf("відмова не називає ідентифікатор: %q", err)
}
}
// Вимкнений канал — не привід відмовляти.
//
// Перелік каналів кабінету не фільтрується за enabled навмисно:
// «вимкнув Telegram на час переїзду» не має ламати збереження драбини,
// у якій він стоїть. Про вимкнений канал говорить форма, і це інша
// розмова, ніж «такого каналу немає».
func TestStepChannelDisabledStillValid(t *testing.T) {
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch-off"}}}
if err := ValidateStepChannels(steps, knownChannels("ch-off")); err != nil {
t.Fatalf("вимкнений канал відхилено: %v", err)
}
}
// --- Чистка сходинок при видаленні каналу ----------------------------
//
// Друга з підтверджених вад: видалення каналу не чіпало сходинок, що на
// нього посилались. Зовнішнього ключа на масив усередині JSONB немає,
// тож ON DELETE SET NULL, яким 0066 прибирає драбину з правила, тут не
// спрацьовує — і в сходинці лишався UUID каналу, якого вже немає.
func TestRemoveChannelKeepsEmptyStep(t *testing.T) {
steps := []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-gone"}},
{AfterMin: 45, ChannelIDs: []string{"ch-gone", "ch-lead"}},
}
got, changed := removeChannelFromSteps(steps, "ch-gone")
if !changed {
t.Fatal("посилання на видалений канал лишилось у драбині")
}
// Сходинка лишається на місці порожньою. Викинута зникла б
// безслідно й мовчки зсунула б усе чергування нижче: «через 45» на
// другій сходинці стало б першим підйомом, якого ніхто не просив.
if len(got) != 2 {
t.Fatalf("драбину зсунуто: %d сходинок замість 2", len(got))
}
if len(got[0].ChannelIDs) != 0 {
t.Fatalf("сходинка 1 мала лишитись порожньою: %v", got[0].ChannelIDs)
}
if got[0].AfterMin != 15 || got[1].AfterMin != 45 {
t.Fatalf("хвилини сходинок змінились: %+v", got)
}
if len(got[1].ChannelIDs) != 1 || got[1].ChannelIDs[0] != "ch-lead" {
t.Fatalf("сходинка 2 втратила чужий канал: %v", got[1].ChannelIDs)
}
}
// Драбина, яка каналу не знає, не має переписуватись.
//
// Не заради швидкості: зайвий UPDATE зсунув би updated_at і в переліку
// правок виглядав би як зміна чергування, якої не було.
func TestRemoveChannelUntouchedLadder(t *testing.T) {
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch-duty"}}}
got, changed := removeChannelFromSteps(steps, "ch-gone")
if changed {
t.Fatal("драбину без цього каналу оголошено зміненою")
}
if len(got[0].ChannelIDs) != 1 {
t.Fatalf("канали чужої драбини змінились: %v", got[0].ChannelIDs)
}
}
// Драбина називається один раз, скільки б сходинок у неї не вело в цей
// канал: перед видаленням цікаво, ЩО зламається, а не скільки разів
// воно згадане.
func TestLadderChannelIDsDeduplicated(t *testing.T) {
ids := ladderChannelIDs([]EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
{AfterMin: 45, ChannelIDs: []string{"ch-duty", "ch-lead"}},
})
if len(ids) != 2 || ids[0] != "ch-duty" || ids[1] != "ch-lead" {
t.Fatalf("перелік каналів драбини: %v", ids)
}
}
// Порожня сходинка лишається справою ValidateEscalationSteps.
//
// Тут вона проходить — і має проходити: дві перевірки не мають
// дублювати одна одну, інакше повідомлення розійдуться.
func TestStepChannelsEmptyStepIsOtherCheck(t *testing.T) {
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: nil}}
if err := ValidateStepChannels(steps, knownChannels()); err != nil {
t.Fatalf("порожню сходинку має ловити ValidateEscalationSteps, а не ця перевірка: %v", err)
}
if err := ValidateEscalationSteps(steps); err == nil {
t.Fatal("порожню сходинку не спіймала жодна перевірка")
}
}

View file

@ -59,9 +59,22 @@ func TestEscalationAgainstDB(t *testing.T) {
t.Fatalf("хост: %v", err) 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{ policy := EscalationPolicy{
Name: "Нічне чергування", Name: "Нічне чергування",
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch"}}, {AfterMin: 45, ChannelIDs: []string{"ch"}}}, Steps: []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{channelID}},
{AfterMin: 45, ChannelIDs: []string{channelID}},
},
} }
policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy) policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy)
if err != nil { if err != nil {
@ -139,9 +152,16 @@ func TestEscalationAgainstDB(t *testing.T) {
if d.Action != EscFire { if d.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome) t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
} }
if err := st.ApplyEscalation(ctx, due[0], d); err != nil { applied, err := st.ApplyEscalation(ctx, due[0], d)
if err != nil {
t.Fatalf("запис рішення: %v", err) 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 stepIdx int
var leased *time.Time var leased *time.Time
@ -201,6 +221,60 @@ func TestEscalationAgainstDB(t *testing.T) {
} }
} }
// --- Підтвердження ПОСЕРЕД партії не має воскрешати драбину --------
//
// Найдовший розрив у механізмі: сходинку взяли в чергу, і поки до неї
// дійшли руки (кожна доставка попередніх — з власним таймаутом),
// людина натиснула «Прийняти». Тут відтворено саме цей порядок:
// знімок узято ДО 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)
}
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ---- // --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
a2 := newAlert(ruleID + ":dev:" + deviceID + ":2") a2 := newAlert(ruleID + ":dev:" + deviceID + ":2")
if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil { if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil {

View file

@ -350,7 +350,17 @@ type RuleInput struct {
Condition string Condition string
ForSeconds int ForSeconds int
DependsOnTopology bool DependsOnTopology bool
Enabled bool // Enabled — вказівник, бо «поле не прийшло» і «поле прийшло зі
// значенням false» — це різні наміри, а bool їх не розрізняє.
//
// nil на СТВОРЕННІ означає «увімкнене»: правило, заведене вимкненим,
// не робить нічого й виглядає як забуте.
//
// nil на ОНОВЛЕННІ означає «не чіпати». Домислювати тут `true` —
// саме та вада, через яку правка опису мовчки вмикала вимкнене
// правило: форма поля не надсилала, а сервер читав його відсутність
// як згоду ввімкнути.
Enabled *bool
// Куди слати. Порожньо — за загальними маршрутами тенанта. // Куди слати. Порожньо — за загальними маршрутами тенанта.
ChannelIDs []string ChannelIDs []string
NotifySchedule string NotifySchedule string
@ -440,7 +450,7 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule
RETURNING id::text RETURNING id::text
`, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity, `, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity,
in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology, in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology,
in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule, in.Enabled == nil || *in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule,
in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds, in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds,
nullUUID(in.EscalationPolicyID)).Scan(&id) nullUUID(in.EscalationPolicyID)).Scan(&id)
}) })
@ -520,12 +530,57 @@ type jsonRaw string
func (j jsonRaw) MarshalJSON() ([]byte, error) { return []byte(j), nil } func (j jsonRaw) MarshalJSON() ([]byte, error) { return []byte(j), nil }
// planRuleEnabled рахує, яким стане прапорець «увімкнено» після правки
// правила й чи треба при цьому погасити його активні алерти.
//
// Винесене окремою функцією, бо це рішення, а не запит: перевіряти його
// на живій базі означало б стенд із правилом, алертом і драбиною на
// кожен із чотирьох випадків, а помилка в будь-якому з них не видна
// одразу — вона видна за тиждень, коли комусь дзвонять о третій ночі за
// алертом правила, вимкненого в понеділок.
//
// current — стан у базі ДО правки; want — те, що прийшло в запиті
// (nil = поля не було, стан не чіпаємо).
func planRuleEnabled(current bool, want *bool) (enabled, resolve bool) {
enabled = current
if want != nil {
enabled = *want
}
// Гасити треба рівно на ПЕРЕХОДІ «увімкнене → вимкнене». Не на
// кожному збереженні вимкненого правила: його алерти вже погашені
// тим переходом, який його вимкнув, і повторний прохід був би
// зайвим записом у event_outbox на кожну правку.
return enabled, current && !enabled
}
// UpdateRule замінює правило цілком. // UpdateRule замінює правило цілком.
// //
// Цілком, а не полями: форма показує повний стан правила, і часткові // Цілком, а не полями: форма показує повний стан правила, і часткові
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила. // оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) error { func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Стан ДО правки читається окремо й під замком.
//
// Окремо — бо після UPDATE його вже не відновити, а рішення про
// алерти приймається саме за переходом, а не за новим значенням.
// Під замком (FOR UPDATE) — бо між читанням і записом уміщається
// перемикач «Увімк.» зі списку правил: без замка два записи
// могли б лягти в порядку, у якому правило лишається вимкненим,
// а гасіння не спрацьовує в жодному з них.
var was bool
err := tx.QueryRow(ctx, `
SELECT enabled FROM alr.rules
WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, ruleID, tenantID).Scan(&was)
if isNoRows(err) {
return ErrNotFound
}
if err != nil {
return err
}
enabled, resolve := planRuleEnabled(was, in.Enabled)
ct, err := tx.Exec(ctx, ` ct, err := tx.Exec(ctx, `
UPDATE alr.rules SET UPDATE alr.rules SET
name = $3, description = $4, source = $5::alr.rule_source, name = $3, description = $4, source = $5::alr.rule_source,
@ -542,7 +597,7 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
WHERE id = $1 AND tenant_id = $2 WHERE id = $1 AND tenant_id = $2
`, ruleID, tenantID, in.Name, nullString(in.Description), in.Source, `, ruleID, tenantID, in.Name, nullString(in.Description), in.Source,
in.Severity, in.Selector, in.Condition, in.ForSeconds, in.Severity, in.Selector, in.Condition, in.ForSeconds,
in.DependsOnTopology, in.Enabled, in.ChannelIDs, in.NotifySchedule, in.DependsOnTopology, enabled, in.ChannelIDs, in.NotifySchedule,
in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds, in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds,
nullUUID(in.EscalationPolicyID)) nullUUID(in.EscalationPolicyID))
if err != nil { if err != nil {
@ -551,6 +606,15 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
if ct.RowsAffected() == 0 { if ct.RowsAffected() == 0 {
return ErrNotFound return ErrNotFound
} }
// Те саме, що робить SetRuleEnabled, і з тієї ж причини:
// вимкнене правило випадає з ActiveRules, тобто ResolveMissing
// за ним більше не біжить і закрити свої алерти воно вже не
// зможе. Без цього рядка алерти висіли б у firing вічно — а
// драбина ескалації справно будила б за ними людей, з повторами
// на тижні вперед.
if resolve {
return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено")
}
return nil return nil
}) })
} }

View file

@ -0,0 +1,210 @@
package store
import (
"context"
"os"
"strings"
"testing"
"time"
)
// Правка правила ПРОТИ БАЗИ.
//
// planRuleEnabled поруч покриває рішення, і це головна половина. Друга
// половина рішенням не перевіряється взагалі: чи справді UpdateRule
// читає стан ДО запису, чи справді гасить алерти в тій самій
// транзакції й чи не вмикає правило, якого його не просили вмикати. Це
// властивість трьох запитів, а не функції.
//
// Мовчки пропускається без 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 UpdateRuleAgainstDB -v
func TestUpdateRuleAgainstDB(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 := "upd-" + 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.78.0.1', 'switch') RETURNING id::text
`, tenantID, slug+"-sw").Scan(&deviceID); err != nil {
t.Fatalf("хост: %v", err)
}
// Правило й алерт до нього — рівно те, що бачить людина на екрані
// перед тим, як відкрити форму й натиснути «Зберегти».
newRule := func(name string, enabled bool) string {
t.Helper()
var id string
if err := st.pool.QueryRow(ctx, `
INSERT INTO alr.rules (tenant_id, name, source, severity, condition, enabled)
VALUES ($1, $2, 'icmp', 'high',
'{"metric":"loss_pct","op":">","value":20}'::jsonb, $3)
RETURNING id::text
`, tenantID, name, enabled).Scan(&id); err != nil {
t.Fatalf("правило %s: %v", name, err)
}
return id
}
newAlert := func(ruleID 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)
VALUES ($1, $2, $3, 'high', 'firing', 'ядро не відповідає', $4)
RETURNING id::text
`, tenantID, ruleID, deviceID, ruleID+":dev:"+deviceID).Scan(&id); err != nil {
t.Fatalf("алерт: %v", err)
}
return id
}
state := func(alertID string) string {
t.Helper()
var s string
if err := st.pool.QueryRow(ctx,
`SELECT state::text FROM alr.alerts WHERE id = $1`, alertID).Scan(&s); err != nil {
t.Fatalf("стан алерту: %v", err)
}
return s
}
ruleEnabled := func(ruleID string) bool {
t.Helper()
var e bool
if err := st.pool.QueryRow(ctx,
`SELECT enabled FROM alr.rules WHERE id = $1`, ruleID).Scan(&e); err != nil {
t.Fatalf("стан правила: %v", err)
}
return e
}
base := func(name string) RuleInput {
return RuleInput{
Name: name,
Source: "icmp",
Severity: "high",
Selector: "{}",
Condition: `{"metric":"loss_pct","op":">","value":20}`,
ForSeconds: 60,
DependsOnTopology: true,
ChannelIDs: []string{},
NotifyOnResolve: true,
}
}
off, on := false, true
// --- ВАДА А: вимкнення правкою гасить алерти ----------------------
//
// Вимкнене правило випадає з ActiveRules, тобто ResolveMissing за
// ним більше не біжить. Якщо алерт не погасити тут, він лишиться в
// firing назавжди — а драбина ескалації будитиме за ним людей
// повторами на тижні вперед.
r1 := newRule(slug+"-перше", true)
a1 := newAlert(r1)
in := base(slug + "-перше")
in.Description = "правку опису самої по собі мало б бути видно лише в описі"
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
t.Fatalf("правка без зміни стану: %v", err)
}
if !ruleEnabled(r1) {
t.Fatal("правка без поля `enabled` вимкнула увімкнене правило")
}
if state(a1) != "firing" {
t.Fatalf("правка опису погасила алерт (стан %q) — проблема ж не зникла", state(a1))
}
in.Enabled = &off
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
t.Fatalf("вимкнення правкою: %v", err)
}
if ruleEnabled(r1) {
t.Fatal("правило не вимкнулось")
}
if got := state(a1); got != "resolved" {
t.Fatalf("алерт вимкненого правила лишився в %q: закрити його вже нікому, "+
"а ескалація за ним працює далі", got)
}
var outbox int
if err := st.pool.QueryRow(ctx, `
SELECT count(*)::int FROM core.event_outbox
WHERE tenant_id = $1 AND topic = 'alert.resolved'
AND payload->>'alert_id' = $2
`, tenantID, a1).Scan(&outbox); err != nil {
t.Fatalf("черга подій: %v", err)
}
if outbox != 1 {
t.Fatalf("подій alert.resolved для алерту: %d, очікували 1 — "+
"без неї екран показуватиме закритий алерт активним", outbox)
}
// Повторне збереження вже вимкненого правила не має додавати ще
// одну подію: гасити нічого, а зайвий запис у чергу — це зайве
// сповіщення «відновлено» тим, хто підписаний на канал.
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
t.Fatalf("повторна правка вимкненого: %v", err)
}
if err := st.pool.QueryRow(ctx, `
SELECT count(*)::int FROM core.event_outbox
WHERE tenant_id = $1 AND topic = 'alert.resolved'
AND payload->>'alert_id' = $2
`, tenantID, a1).Scan(&outbox); err != nil {
t.Fatalf("черга подій: %v", err)
}
if outbox != 1 {
t.Fatalf("повторне збереження додало подій: %d", outbox)
}
// --- ВАДА Б: відсутнє поле не вмикає правило ----------------------
//
// Саме так виглядав кожен PUT із форми правил: поля `enabled` у
// тілі не було взагалі, а сервер читав його відсутність як згоду
// ввімкнути.
r2 := newRule(slug+"-друге", false)
in2 := base(slug + "-друге")
in2.Description = "поправили опис вимкненого правила"
if err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
t.Fatalf("правка вимкненого правила: %v", err)
}
if ruleEnabled(r2) {
t.Fatal("правка без поля `enabled` увімкнула вимкнене правило — " +
"моніторинг, який навмисно зупинили, тихо запрацював знову")
}
// І другий бік тієї ж пари: явне `true` мусить вмикати.
in2.Enabled = &on
if err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
t.Fatalf("увімкнення правкою: %v", err)
}
if !ruleEnabled(r2) {
t.Fatal("явне `enabled: true` не ввімкнуло правило")
}
}

View file

@ -0,0 +1,92 @@
package store
import "testing"
// Рішення про прапорець «увімкнено» при правці правила.
//
// ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Обидві вади, які він закриває, тихі: система
// після них не падає й нічого не пише в журнал. Видно їх тільки з
// телефона о третій ночі — або, що гірше, не видно взагалі.
//
// 1. Правило вимкнули через PUT (форма надсилає правило цілком) — і
// його алерти лишились у firing НАЗАВЖДИ: вимкнене правило випадає
// з ActiveRules, тобто ResolveMissing за ним більше не біжить і
// погасити їх немає кому. Драбина ескалації при цьому працює далі —
// з повторами вона будить людей тижнями за проблемою, за якою вже
// ніхто не стежить.
// 2. Форма не надсилала `enabled` взагалі, а сервер читав відсутність
// поля як `true`. Тобто відкрити вимкнене правило, поправити в ньому
// будь-що й зберегти — означало мовчки його ввімкнути.
//
// Перевіряється чистою функцією, а не базою, з тієї ж причини, що й
// planRestore поруч: на живій базі кожен із чотирьох випадків — це
// окремий стенд із правилом, алертом і драбиною, а ціна помилки в
// будь-якому з них платиться не тут і не одразу.
func TestPlanRuleEnabledResolvesOnDisable(t *testing.T) {
// Головний випадок: увімкнене правило вимикають правкою. Алерти
// треба гасити тут же — іншої нагоди в них не буде.
off := false
enabled, resolve := planRuleEnabled(true, &off)
if enabled {
t.Fatal("правило мало лишитись вимкненим")
}
if !resolve {
t.Fatal("алерти не гасяться: після вимкнення їх нікому закрити, " +
"і драбина ескалації будитиме людей за ними далі")
}
}
func TestPlanRuleEnabledKeepsAlertsWhenStillDisabled(t *testing.T) {
// Правку вимкненого правила зберігають повторно. Гасити нема чого:
// його алерти закрив той перехід, який його вимкнув, а зайвий
// прохід — це ще один запис у event_outbox на кожне збереження.
off := false
enabled, resolve := planRuleEnabled(false, &off)
if enabled {
t.Fatal("правило мало лишитись вимкненим")
}
if resolve {
t.Fatal("гасити нічого: правило вже було вимкнене до правки")
}
}
func TestPlanRuleEnabledKeepsAlertsWhenEnabling(t *testing.T) {
// Увімкнення — не привід чіпати алерти: рішення про них ухвалить
// найближчий тік движка, і саме він знає, чи проблема ще триває.
on := true
enabled, resolve := planRuleEnabled(false, &on)
if !enabled {
t.Fatal("правило мало ввімкнутись")
}
if resolve {
t.Fatal("увімкнення гасить алерти — цього не мало статись")
}
}
func TestPlanRuleEnabledLeavesDisabledRuleAlone(t *testing.T) {
// ВАДА Б. Поля `enabled` у тілі немає — саме так виглядав кожен
// PUT із форми правил. Вимкнене правило мусить лишитись вимкненим:
// «поля не було» означає «не чіпайте», а не «вмикайте».
enabled, resolve := planRuleEnabled(false, nil)
if enabled {
t.Fatal("правка без поля `enabled` увімкнула вимкнене правило — " +
"людина дізнається про це зі сповіщення, а не з форми")
}
if resolve {
t.Fatal("гасити нічого: стан не змінився")
}
}
func TestPlanRuleEnabledLeavesEnabledRuleAlone(t *testing.T) {
// Другий бік тієї ж пари, і без нього перший нічого не доводить:
// функція, яка завжди повертає false, пройшла б попередній тест.
enabled, resolve := planRuleEnabled(true, nil)
if !enabled {
t.Fatal("правка без поля `enabled` вимкнула увімкнене правило — " +
"це мовчазна зупинка моніторингу")
}
if resolve {
t.Fatal("гасити нічого: стан не змінився")
}
}

View file

@ -949,6 +949,13 @@ export const api = {
selector?: Record<string, unknown> selector?: Record<string, unknown>
for_seconds: number for_seconds: number
depends_on_topology: boolean depends_on_topology: boolean
/**
* Стан перемикача. Необов'язкове лише для СТВОРЕННЯ, де сервер
* бере `true`; на правці мовчання означає «не чіпати», і покластись
* на це замість явного значення те саме, що не надіслати поле,
* яке форма насправді знає.
*/
enabled?: boolean
channel_ids?: string[] channel_ids?: string[]
notify_schedule?: Record<string, unknown> | null notify_schedule?: Record<string, unknown> | null
notify_on_resolve?: boolean notify_on_resolve?: boolean

View file

@ -123,6 +123,25 @@ export function ChannelsPage() {
<span className="text-slate-600"></span> <span className="text-slate-600"></span>
), ),
}, },
{
// Той самий стовпчик, що «Тригерів» у переліку драбин, і
// з тієї ж причини: перед видаленням має бути видно, що
// на цьому каналі тримається чиєсь нічне чергування.
key: 'esc',
header: 'Драбини',
hideOnMobile: true,
cell: (c) =>
c.escalations?.length ? (
<span
className="tabular-nums text-slate-200"
title={c.escalations.join(', ')}
>
{c.escalations.length}
</span>
) : (
<span className="text-slate-600"></span>
),
},
{ {
key: 'state', key: 'state',
header: 'Стан', header: 'Стан',
@ -172,8 +191,9 @@ export function ChannelsPage() {
Канал <b>{c.name}</b> буде видалено разом із його токеном. Канал <b>{c.name}</b> буде видалено разом із його токеном.
</> </>
), ),
detail: detail: c.escalations?.length
'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.', ? `Алерти, які йшли сюди, більше не доставлятимуться. На цей канал спираються драбини ескалації (${c.escalations.length}): ${c.escalations.join(', ')} — їхні сходинки лишаться без каналу й нікого не розбудять, поки в них не оберуть інший.`
: 'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
onConfirm: async () => { onConfirm: async () => {
await api.deleteChannel(c.id) await api.deleteChannel(c.id)
await reload() await reload()

View file

@ -56,7 +56,21 @@ export function EscalationsPage() {
void reload() void reload()
}, [reload]) }, [reload])
const channelName = (id: string) => channels.find((c) => c.id === id)?.name ?? '—' // Канал, якого вже немає, показуємо словами, а не прочерком.
//
// Прочерк читається як «нічого не налаштовано», тобто як спокійний
// стан. А це протилежне: сходинка налаштована, виглядає робочою і не
// йде нікуди. Драбини, збережені до перевірки каналів, — єдине місце,
// де таке ще може лишитись.
const channelCell = (id: string) => {
const c = channels.find((x) => x.id === id)
if (c) return <span key={id}>{c.name}</span>
return (
<span key={id} className="text-amber-500" title={`Каналу ${id} більше немає`}>
канал видалено
</span>
)
}
return ( return (
<> <>
@ -117,7 +131,21 @@ export function EscalationsPage() {
<div key={i} className="text-[11px] text-slate-400"> <div key={i} className="text-[11px] text-slate-400">
<span className="tabular-nums text-slate-300">+{s.after_min} хв</span> <span className="tabular-nums text-slate-300">+{s.after_min} хв</span>
{' → '} {' → '}
{s.channel_ids.map(channelName).join(', ')} {s.channel_ids.length === 0 ? (
<span
className="text-amber-500"
title="Сходинка без каналів: драбина порахує її пройденою, не розбудивши нікого"
>
жодного каналу
</span>
) : (
s.channel_ids.map((id, k) => (
<span key={id}>
{k > 0 && ', '}
{channelCell(id)}
</span>
))
)}
</div> </div>
))} ))}
</div> </div>
@ -225,9 +253,32 @@ function PolicyForm({
}) { }) {
const [name, setName] = useState(policy?.name ?? '') const [name, setName] = useState(policy?.name ?? '')
const [description, setDescription] = useState(policy?.description ?? '') const [description, setDescription] = useState(policy?.description ?? '')
const [steps, setSteps] = useState<EscalationStep[]>(
policy?.steps?.length ? policy.steps : [{ after_min: 15, channel_ids: [] }], // Ідентифікатори видалених каналів прибираємо ще при відкритті форми.
) //
// Інакше вийшов би глухий кут: галочки для неіснуючого каналу в
// переліку немає, тобто прибрати його руками неможливо, а сервер
// драбину з ним уже не приймає — форма відмовлялась би зберігатись і
// не показувала б, через що.
//
// Тільки коли канали справді прочитались: порожній перелік буває й
// від невдалого запиту, і тоді «чистка» стерла б драбину цілком.
const [dropped] = useState(() => {
if (!policy?.steps?.length || channels.length === 0) return 0
let n = 0
for (const s of policy.steps) {
n += s.channel_ids.filter((id) => !channels.some((c) => c.id === id)).length
}
return n
})
const [steps, setSteps] = useState<EscalationStep[]>(() => {
if (!policy?.steps?.length) return [{ after_min: 15, channel_ids: [] }]
if (channels.length === 0) return policy.steps
return policy.steps.map((s) => ({
...s,
channel_ids: s.channel_ids.filter((id) => channels.some((c) => c.id === id)),
}))
})
const [repeatAfter, setRepeatAfter] = useState(String(policy?.repeat_after_min ?? 0)) const [repeatAfter, setRepeatAfter] = useState(String(policy?.repeat_after_min ?? 0))
const [maxRepeats, setMaxRepeats] = useState(String(policy?.max_repeats ?? 0)) const [maxRepeats, setMaxRepeats] = useState(String(policy?.max_repeats ?? 0))
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -320,6 +371,13 @@ function PolicyForm({
сповіщенням і виглядала б як здвоєне повідомлення. сповіщенням і виглядала б як здвоєне повідомлення.
</p> </p>
{dropped > 0 && (
<p className="text-[11px] leading-relaxed text-amber-500">
У драбині лишались посилання на видалені канали ({dropped}) їх прибрано з форми.
Перевірте сходинки нижче: та, що лишилась без каналу, нікого не розбудить.
</p>
)}
{channels.length === 0 && ( {channels.length === 0 && (
<p className="text-[11px] leading-relaxed text-amber-500"> <p className="text-[11px] leading-relaxed text-amber-500">
Каналів ще немає. Заведіть їх на сторінці «Канали» без жодного каналу сходинка Каналів ще немає. Заведіть їх на сторінці «Канали» без жодного каналу сходинка

View file

@ -467,6 +467,13 @@ function RuleForm({
// найчастіший випадок, і вимагати вибору означало б змусити // найчастіший випадок, і вимагати вибору означало б змусити
// людину відмічати всі групи по черзі. // людину відмічати всі групи по черзі.
selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {}, selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {},
// Прапорець «увімкнено» ця форма не показує — його перемикають
// у списку, — але надсилати його вона мусить: PUT замінює
// правило ЦІЛКОМ, і поле, про яке клієнт змовчав, сервер мусив
// би домислити. Раніше домислював `true`, і правка вимкненого
// правила мовчки його вмикала: моніторинг, який навмисно
// зупинили, знову починав будити людей.
enabled: rule?.enabled ?? true,
for_seconds: Number(forSec) || 60, for_seconds: Number(forSec) || 60,
// Кореляція за топологією рахується з того, які хости зараз // Кореляція за топологією рахується з того, які хости зараз
// лежать, — а подія не робить хост мертвим. Для подієвих правил // лежать, — а подія не робить хост мертвим. Для подієвих правил

View file

@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EscalationsPage } from '../pages/EscalationsPage'
import { ChannelsPage } from '../pages/ChannelsPage'
import { session } from '../api/session'
import { fetchRouter, res, type Call } from './support'
/**
* Канал, на який посилається сходинка драбини.
*
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Драбина зберігалась із будь-яким
* ідентифікатором каналу, а видалення каналу не чіпало сходинок, що на
* нього посилались. Ззовні це виглядало як робоче нічне чергування:
* сходинка стоїть, хвилини стоять, у стовпчику каналів прочерк, який
* читається як «не заповнено» й нікого не турбує. Насправді ця
* сходинка не йде нікуди й списується мовчки.
*
* У формі це давало ще й глухий кут: галочки для видаленого каналу в
* переліку немає, тобто прибрати його руками неможливо, а сервер
* (після виправлення) такої драбини вже не приймає.
*
* Форма підставних відповідей звірена з
* `server/internal/httpapi/alerts.go` (`handleListEscalationPolicies`,
* `handleListChannels`) і зі `store.EscalationPolicy` / `store.Channel`.
*/
const duty = {
id: 'ch-duty',
kind: 'telegram',
name: 'Черговий',
config: {},
min_severity: 'warning',
enabled: true,
has_secret: true,
}
/** Драбина, друга сходинка якої посилається на вже видалений канал. */
const policy = {
id: 'p-1',
name: 'Нічне чергування',
description: '',
steps: [
{ after_min: 15, channel_ids: ['ch-duty'] },
{ after_min: 45, channel_ids: ['ch-gone'] },
],
repeat_after_min: 0,
max_repeats: 0,
rule_count: 2,
}
beforeEach(() => {
vi.stubGlobal('WebSocket', class {})
session.set('tok', {
userID: 'u-me',
username: 'me',
tenantID: 't-1',
permissions: ['alerts:read', 'alerts:write'],
})
})
describe('сходинка, що посилається на видалений канал', () => {
it('у переліку її видно словами, а не прочерком', async () => {
fetchRouter({
'GET /api/v1/escalation-policies': { policies: [policy] },
'GET /api/v1/channels': { channels: [duty] },
})
render(<EscalationsPage />)
await screen.findByText('Черговий')
// Прочерк читався б як «нічого не налаштовано», тобто як спокійний
// стан. Тут стан протилежний.
expect(await screen.findByText('канал видалено')).toBeTruthy()
})
it('форма прибирає привида, говорить про це і не шле його на сервер', async () => {
const srv = fetchRouter({
'GET /api/v1/escalation-policies': { policies: [policy] },
'GET /api/v1/channels': { channels: [duty] },
'PUT /api/v1/escalation-policies/p-1': () => res(200, { id: 'p-1' }),
})
render(<EscalationsPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
await screen.findByText('Драбина: Нічне чергування')
expect(screen.getByText(/лишались посилання на видалені канали/)).toBeTruthy()
// Друга сходинка лишилась без каналу — і форма має сказати про це
// ДО збереження, а не віддати серверу відмову без пояснення.
expect(screen.getByText(/сходинка 2 не має жодного каналу/)).toBeTruthy()
// Дали сходинці канал — тепер зберігається.
const boxes = screen.getAllByRole('checkbox')
fireEvent.click(boxes[boxes.length - 1])
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
let put: Call | undefined
await waitFor(() => {
put = srv.calls.find((c) => c.method === 'PUT')
expect(put).toBeTruthy()
})
const body = put!.body as { steps: { channel_ids: string[] }[] }
expect(body.steps.map((s) => s.channel_ids)).toEqual([['ch-duty'], ['ch-duty']])
})
})
describe('видалення каналу, на який спирається драбина', () => {
it('називає драбини, які через це замовкнуть', async () => {
fetchRouter({
'GET /api/v1/channels': {
channels: [{ ...duty, escalations: ['Нічне чергування', 'Вихідні'] }],
},
})
render(<ChannelsPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
// Те саме, що вже показує видалення драбини: скільки чужих
// налаштувань зараз перестане працювати, і яких саме.
expect(await screen.findByText(/Нічне чергування, Вихідні/)).toBeTruthy()
})
it('без драбин попередження про них не вигадується', async () => {
fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } })
render(<ChannelsPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
await screen.findByText(/більше не доставлятимуться/)
expect(screen.queryByText(/драбини ескалації/)).toBeNull()
})
})

102
web/src/test/rules.test.tsx Normal file
View file

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { RulesPage } from '../pages/RulesPage'
import { session } from '../api/session'
import { fetchRouter, res, type Call } from './support'
/**
* Збереження тригера з форми.
*
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Форма показує тригер цілком і надсилає його
* цілком (`PUT /api/v1/alert-rules/{id}` замінює правило), але
* перемикача «увімкнено» в ній немає його місце в списку. Через це
* поле `enabled` у тілі запиту не було ВЗАГАЛІ, а сервер читав його
* відсутність як згоду ввімкнути.
*
* Наслідок тихий і найгіршого можливого сорту: людина відкриває
* НАВМИСНО вимкнений тригер, правит у ньому будь-що, натискає
* «Зберегти» і моніторинг, який зупинили свідомо, знову починає
* будити людей. У формі при цьому не змінюється нічого: вона про цей
* прапорець не знає й не показує його ні до, ні після.
*
* Перевірка йде парою вимкнений і увімкнений тригер. Один бік
* окремо не доводить нічого: `enabled: false`, вписане намертво,
* пройшло б перший тест і зупинило б моніторинг усім.
*/
const disabledRule = {
id: 'r-off',
name: 'Втрати пакетів на магістралі',
description: '',
source: 'icmp',
severity: 'high',
selector: {},
condition: { metric: 'loss_pct', op: '>', value: 20 },
for_seconds: 180,
depends_on_topology: true,
enabled: false,
active_alerts: 0,
channel_ids: [],
notify_on_resolve: true,
}
const enabledRule = { ...disabledRule, id: 'r-on', name: 'Ядро не відповідає', enabled: true }
/** Довідники форми: порожні — це робочий стан, а не збій. */
const refs = {
'GET /api/v1/device-groups': { groups: [] },
'GET /api/v1/channels': { channels: [] },
'GET /api/v1/escalation-policies': { policies: [] },
'GET /api/v1/traps/meta': { names: [] },
}
beforeEach(() => {
// liveEvents підписується на WebSocket одразу: без заглушки jsdom
// пішов би у справжню мережу, і тест залежав би від того, що зараз
// слухає localhost.
vi.stubGlobal('WebSocket', class {})
session.set('tok', {
userID: 'u-me',
username: 'me',
tenantID: 't-1',
permissions: ['alerts:read', 'alerts:write'],
})
})
/** Відкрити тригер на правку й одразу зберегти, нічого не змінивши. */
async function saveWithoutChanges(rule: typeof disabledRule) {
const srv = fetchRouter({
'GET /api/v1/alert-rules': { rules: [rule] },
...refs,
[`PUT /api/v1/alert-rules/${rule.id}`]: () => res(200, { id: rule.id }),
})
render(<RulesPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
await screen.findByText(`Тригер: ${rule.name}`)
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
let put: Call | undefined
await waitFor(() => {
put = srv.calls.find((c) => c.method === 'PUT')
expect(put).toBeDefined()
})
return put!.body as Record<string, unknown>
}
describe('форма тригера', () => {
it('не вмикає вимкнений тригер збереженням', async () => {
const body = await saveWithoutChanges(disabledRule)
expect(
body.enabled,
'форма змовчала про `enabled` — сервер домислить його сам, і вимкнений тригер увімкнеться',
).toBe(false)
})
it('лишає увімкнений тригер увімкненим', async () => {
const body = await saveWithoutChanges(enabledRule)
expect(body.enabled, 'збереження вимкнуло робочий тригер — це тиха зупинка моніторингу').toBe(
true,
)
})
})

View file

@ -838,6 +838,13 @@ export interface Channel {
enabled: boolean enabled: boolean
/** Чи збережено секрет. Сам секрет назад не віддається ніколи. */ /** Чи збережено секрет. Сам секрет назад не віддається ніколи. */
has_secret: boolean has_secret: boolean
/**
* Назви драбин ескалації, сходинки яких шлють у цей канал.
*
* Потрібне лише перед видаленням: канал, на який спирається нічне
* чергування, не має зникати мовчки. Приходить лише в переліку.
*/
escalations?: string[]
} }
/** /**