Netpulse_SasS/server/internal/store/alerts_query.go
byrsapty 954be1d643
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m15s
CI / server (push) Successful in 1m34s
CI / agent (push) Successful in 2m59s
Ескалації: закриваю те, що минулого разу закрив наполовину
За другою рецензією:

* scripts/dbtest.sh писав у шапці «не напрямляйте на робочу базу» й
  нічого для цього не робив — перевірено, пішов котити міграції на базу
  з бойовим іменем. Тепер вимагає probe/test в імені.
* sendText ковтав помилку, тож журнал ескалацій писав «надіслано» на
  сходинці, жодне повідомлення якої не дійшло. Три результати замість
  двох: no_channels, failed, sent.
* stopped_at IS NULL рятував лише від ack; гасіння правилом і
  ResolveMissing рядка драбини не чіпають, і сходинка дзвонила за
  погашеним алертом. Додано перевірку стану алерту в тому ж UPDATE.
* escalate() блокував весь тік движка — мертвий вебхук одного кабінету
  зупиняв обчислення правил усім. Винесено в RunEscalations.
* алерт, народжений під заглушенням, не сповіщався ніколи: ні при
  народженні, ні коли вікно скінчилось. Тепер перехід suppressed→firing
  сповіщається, а драбина рахує час від першого сповіщення.
* alr.rules.channel_ids приймав чужі канали, глушачи і сповіщення, і
  драбину. Перевірка як для сходинок; DeleteChannel чистить посилання.

І перше, що зловив прогін проти справжньої бази: nil-зріз каналів їде
явним NULL повз DEFAULT '{}' — правило без каналів давало 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:44:38 +03:00

706 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ErrNotFound — об'єкта немає або він належить іншому тенанту.
// Обидва випадки навмисно нерозрізненні: відповідь «не ваш» сама по
// собі підтверджує, що такий id існує.
var ErrAlertNotFound = errors.New("алерт не знайдено")
// AlertFilter — параметри вибірки для списку.
type AlertFilter struct {
States []string
MinSeverity string
DeviceID string
Limit int
// Scope звужує вибірку до пристроїв, доступних цій людині. Алерт
// без пристрою (наприклад, про сам зонд) видно всім: сховати його
// від обмеженого користувача означало б приховати аварію, до якої
// групи не мають стосунку.
Scope Scope
}
// severityRank повторює порядок ENUM alr.severity. Тримати його в Go
// потрібно, бо порівняння «не нижче за warning» у SQL інакше вимагало б
// приведення до тексту й втрати індексу.
var severityRank = map[string]int{
"info": 0, "warning": 1, "average": 2, "high": 3, "disaster": 4,
}
// ListAlerts повертає активні алерти тенанта.
func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) ([]Alert, error) {
if f.Limit <= 0 || f.Limit > 500 {
f.Limit = 200
}
states := f.States
if len(states) == 0 {
states = []string{"firing", "acknowledged", "suppressed"}
}
var out []Alert
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT a.id::text, COALESCE(a.rule_id::text,''), COALESCE(r.name,''),
COALESCE(a.device_id::text,''), COALESCE(d.name,''),
COALESCE(a.interface_id::text,''),
a.severity::text, a.state::text, a.title, COALESCE(a.message,''),
a.dedup_key, a.value, a.threshold, a.context::text,
COALESCE(a.root_alert_id::text,''), COALESCE(a.suppressed_by,''),
a.started_at, a.acked_at, COALESCE(u.email::text,''),
COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at,
a.notify_count, a.event_count,
COALESCE(ep.name,''), COALESCE(e.step_idx,0), COALESCE(e.repeat_idx,0),
COALESCE(jsonb_array_length(ep.steps),0),
e.next_at, e.stopped_at, COALESCE(e.stop_reason,''),
(e.alert_id IS NOT NULL)
FROM alr.alerts a
LEFT JOIN alr.rules r ON r.id = a.rule_id
LEFT JOIN inv.devices d ON d.id = a.device_id
LEFT JOIN core.users u ON u.id = a.acked_by
-- Стан драбини ескалації. LEFT JOIN, бо ескалація типово
-- вимкнена: більшість алертів рядка тут не має, і це
-- нормальний стан, а не відсутність даних.
LEFT JOIN alr.alert_escalations e ON e.alert_id = a.id
LEFT JOIN alr.escalation_policies ep ON ep.id = e.policy_id
WHERE a.tenant_id = $1
AND a.state::text = ANY($2::text[])
AND ($3::uuid IS NULL OR a.device_id = $3::uuid)
AND ($5::boolean OR a.device_id IS NULL OR a.device_id = ANY($6::uuid[]))
ORDER BY
CASE a.severity
WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2
WHEN 'warning' THEN 3 ELSE 4 END,
a.started_at DESC
LIMIT $4
`, tenantID, states, nullUUID(f.DeviceID), f.Limit,
f.Scope.Unrestricted, nonNilIDs(f.Scope.Readable))
if err != nil {
return err
}
defer rows.Close()
minRank := -1
if f.MinSeverity != "" {
if r, ok := severityRank[f.MinSeverity]; ok {
minRank = r
}
}
for rows.Next() {
var a Alert
var ctxJSON string
var esc AlertEscalation
var hasEsc bool
if err := rows.Scan(&a.ID, &a.RuleID, &a.RuleName, &a.DeviceID, &a.DeviceName,
&a.InterfaceID, &a.Severity, &a.State, &a.Title, &a.Message,
&a.DedupKey, &a.Value, &a.Threshold, &ctxJSON,
&a.RootAlertID, &a.SuppressedBy, &a.StartedAt, &a.AckedAt,
&a.AckedByEmail, &a.AckComment, &a.ResolvedAt, &a.LastSeenAt,
&a.NotifyCount, &a.EventCount,
&esc.PolicyName, &esc.Step, &esc.Repeat, &esc.Total,
&esc.NextAt, &esc.StoppedAt, &esc.StopReason, &hasEsc); err != nil {
return err
}
if minRank >= 0 && severityRank[a.Severity] < minRank {
continue
}
if hasEsc {
e := esc
a.Escalation = &e
}
a.Context = []byte(ctxJSON)
out = append(out, a)
}
return rows.Err()
})
return out, err
}
// AlertCounts — зведення для шапки UI.
type AlertCounts struct {
Firing int `json:"firing"`
Acknowledged int `json:"acknowledged"`
Suppressed int `json:"suppressed"`
Disaster int `json:"disaster"`
High int `json:"high"`
}
func (s *Store) CountAlerts(ctx context.Context, tenantID string, sc Scope) (AlertCounts, error) {
var c AlertCounts
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE state = 'firing'),
count(*) FILTER (WHERE state = 'acknowledged'),
count(*) FILTER (WHERE state = 'suppressed'),
count(*) FILTER (WHERE severity = 'disaster' AND state = 'firing'),
count(*) FILTER (WHERE severity = 'high' AND state = 'firing')
FROM alr.alerts
WHERE tenant_id = $1 AND state IN ('firing','acknowledged','suppressed')
AND ($2::boolean OR device_id IS NULL OR device_id = ANY($3::uuid[]))
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)).
Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High)
})
return c, err
}
// AckAlert підтверджує алерт.
//
// Підтвердження не гасить проблему — воно зупиняє ескалацію й показує
// решті команди, що хтось уже взявся. Тому стан лишається активним, а
// не переходить у resolved.
func (s *Store) AckAlert(ctx context.Context, tenantID, alertID, userID, comment string) (Alert, error) {
var a Alert
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
UPDATE alr.alerts
SET state = 'acknowledged', acked_at = now(), acked_by = $3, ack_comment = $4
WHERE tenant_id = $1 AND id = $2 AND state IN ('firing','suppressed')
RETURNING id::text, title, severity::text, state::text,
COALESCE(device_id::text,''), started_at, acked_at, last_seen_at
`, tenantID, alertID, nullUUID(userID), nullString(comment)).
Scan(&a.ID, &a.Title, &a.Severity, &a.State, &a.DeviceID,
&a.StartedAt, &a.AckedAt, &a.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAlertNotFound
}
if err != nil {
return err
}
// Драбина зупиняється тут же, у тій самій транзакції: людина
// натиснула «Прийняти» рівно для цього, і побачити «ескалацію
// зупинено» вона має негайно, а не за півхвилини. Безпеку при
// цьому тримає не цей рядок, а перевірка стану перед кожною
// сходинкою — див. PlanEscalation.
if err := stopEscalationTx(ctx, tx, tenantID, a.ID, "acked",
"алерт підтверджено людиною"); err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO core.event_outbox (tenant_id, topic, payload)
VALUES ($1, 'alert.acked', jsonb_build_object('alert_id', $2::text))
`, tenantID, a.ID)
return err
})
return a, err
}
// CloseAlert закриває алерт руками.
//
// Потрібне для правил, чия умова більше ніколи не стане хибною сама
// (разова подія на кшталт «конфіг змінено»): без ручного закриття такий
// алерт висів би на екрані вічно.
func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE alr.alerts
SET state = 'resolved', resolved_at = now()
WHERE tenant_id = $1 AND id = $2
AND state IN ('firing','acknowledged','suppressed')
`, tenantID, alertID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrAlertNotFound
}
// Закритий алерт не має воскресати сходинкою ескалації.
if err := stopEscalationTx(ctx, tx, tenantID, alertID, "closed",
"алерт закрито людиною"); err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO core.event_outbox (tenant_id, topic, payload)
VALUES ($1, 'alert.resolved', jsonb_build_object('alert_id', $2::text))
`, tenantID, alertID)
return err
})
}
// MaxMute — стеля ручного заглушення.
//
// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути
// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень
// достатньо для будь-яких робіт і замало, щоб забути назовсім.
//
// Живе в шарі store, а не в обробнику HTTP, бо кнопку «Заглушити»
// натискають уже з двох місць — з інтерфейсу й з Telegram. Стеля,
// продубльована в другому місці, розійшлася б із першою на наступній
// же правці, і найгірше в цьому те, що розбіжність було б видно лише
// тому, хто відкриє обидва файли поруч.
const MaxMute = 7 * 24 * time.Hour
// MuteDevice глушить пристрій на визначений час.
func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reason string, until time.Time) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO alr.mutes (tenant_id, device_id, until, reason, created_by)
VALUES ($1, $2, $3, $4, $5)
`, tenantID, deviceID, until, nullString(reason), nullUUID(userID))
return err
})
}
// ActiveMute каже, чи пристрій уже заглушений і до котрої.
//
// Потрібне не для показу, а для ідемпотентності: кнопка в Telegram
// приходить від пальця, а палець натискає двічі — і мережа теж уміє
// доставити те саме натискання повторно. Без цієї перевірки друге
// натискання клало б другий ряд у alr.mutes, і «заглушено на годину»
// тихо перетворювалось би на дві години.
func (s *Store) ActiveMute(ctx context.Context, tenantID, deviceID string) (time.Time, bool, error) {
var until time.Time
var found bool
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
SELECT until FROM alr.mutes
WHERE tenant_id = $1 AND device_id = $2 AND until > now()
ORDER BY until DESC LIMIT 1
`, tenantID, deviceID).Scan(&until)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
found = true
return nil
})
return until, found, err
}
// AlertAck — стан підтвердження алерту.
//
// Окреме читання перед AckAlert, а не після: до підтвердження треба
// знати пристрій (щоб звірити його з доступом людини) і поточний стан
// (щоб повторне натискання відповіло «вже підтверджено Іваном о 03:12»,
// а не помилкою «алерт не знайдено» — з телефона друге читається як
// поломка).
type AlertAck struct {
ID string
DeviceID string
DeviceName string
Title string
State string
AckedAt *time.Time
AckedByEmail string
}
// AlertAckState читає алерт разом із тим, хто його вже підтвердив.
func (s *Store) AlertAckState(ctx context.Context, tenantID, alertID string) (AlertAck, error) {
var a AlertAck
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
SELECT a.id::text, COALESCE(a.device_id::text,''), COALESCE(d.name,''),
a.title, a.state::text, a.acked_at, COALESCE(u.email::text,'')
FROM alr.alerts a
LEFT JOIN inv.devices d ON d.id = a.device_id
LEFT JOIN core.users u ON u.id = a.acked_by
WHERE a.tenant_id = $1 AND a.id = $2
`, tenantID, alertID).Scan(&a.ID, &a.DeviceID, &a.DeviceName,
&a.Title, &a.State, &a.AckedAt, &a.AckedByEmail)
if errors.Is(err, pgx.ErrNoRows) {
return ErrAlertNotFound
}
return err
})
return a, err
}
// DeviceNameInTenant повертає ім'я хоста, якщо він належить кабінету.
//
// Зовнішній ключ alr.mutes.device_id перевіряє лише те, що хост існує
// десь у базі, — не те, що він у цьому кабінеті. Для запиту з UI цього
// досить, бо там ідентифікатор уже пройшов через scope; для натискання
// з Telegram — ні: у callback_data лежить рівно те, що ми туди
// поклали, але прислати його може будь-хто, хто бачив формат.
func (s *Store) DeviceNameInTenant(ctx context.Context, tenantID, deviceID string) (string, error) {
var name string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
SELECT name FROM inv.devices
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
`, tenantID, deviceID).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
})
return name, err
}
// ---------------------------------------------------------------------
// Правила: CRUD для UI
// ---------------------------------------------------------------------
type RuleInput struct {
Name string
Description string
Source string
Severity string
Selector string
Condition string
ForSeconds int
DependsOnTopology bool
// Enabled — вказівник, бо «поле не прийшло» і «поле прийшло зі
// значенням false» — це різні наміри, а bool їх не розрізняє.
//
// nil на СТВОРЕННІ означає «увімкнене»: правило, заведене вимкненим,
// не робить нічого й виглядає як забуте.
//
// nil на ОНОВЛЕННІ означає «не чіпати». Домислювати тут `true` —
// саме та вада, через яку правка опису мовчки вмикала вимкнене
// правило: форма поля не надсилала, а сервер читав його відсутність
// як згоду ввімкнути.
Enabled *bool
// Куди слати. Порожньо — за загальними маршрутами тенанта.
ChannelIDs []string
NotifySchedule string
NotifyOnResolve bool
// Тільки для подієвих джерел: через скільки алерт гасне сам (0 —
// тільки руками) і як рідко можна доливати в нього події.
AutoCloseSeconds int
MinIntervalSeconds int
// Драбина ескалації. Порожньо — без ескалації, і це типове
// значення: оновлення не має раптом почати будити людей.
EscalationPolicyID string
}
func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]any, error) {
var out []map[string]any
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT r.id::text, r.name, COALESCE(r.description,''), r.source::text,
r.severity::text, r.selector::text, r.condition::text,
r.for_seconds, r.depends_on_topology, r.enabled,
r.channel_ids::text[], COALESCE(r.notify_schedule::text,''),
r.notify_on_resolve,
r.auto_close_seconds, r.min_interval_seconds,
COALESCE(r.escalation_policy_id::text,''), COALESCE(ep.name,''),
(SELECT count(*) FROM alr.alerts a
WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged')),
COALESCE(r.template_id::text,''), COALESCE(t.name,'')
FROM alr.rules r
LEFT JOIN tpl.templates t ON t.id = r.template_id
LEFT JOIN alr.escalation_policies ep ON ep.id = r.escalation_policy_id
WHERE r.tenant_id = $1
ORDER BY r.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id, name, desc, source, sev, sel, cond, sched string
var tplID, tplName, escID, escName string
var forSec, active, autoClose, minInterval int
var topo, enabled, onResolve bool
var channelIDs []string
if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond,
&forSec, &topo, &enabled, &channelIDs, &sched, &onResolve,
&autoClose, &minInterval, &escID, &escName,
&active, &tplID, &tplName); err != nil {
return err
}
if channelIDs == nil {
channelIDs = []string{}
}
out = append(out, map[string]any{
"id": id, "name": name, "description": desc, "source": source,
"severity": sev, "selector": rawJSON(sel), "condition": rawJSON(cond),
"for_seconds": forSec, "depends_on_topology": topo,
"enabled": enabled, "active_alerts": active,
"channel_ids": channelIDs, "notify_on_resolve": onResolve,
"notify_schedule": rawJSON(sched),
"auto_close_seconds": autoClose, "min_interval_seconds": minInterval,
// Порожньо — без ескалації. Ім'я віддаємо поруч із
// ідентифікатором, щоб перелік правил не мусив другим
// запитом з'ясовувати, як називається драбина.
"escalation_policy_id": escID, "escalation_policy_name": escName,
// Непорожній шаблон означає, що правило породжене його
// тригером: у списку його видно, але правлять його там,
// де воно описане один раз на клас пристроїв.
"template_id": tplID, "template_name": tplName,
})
}
return rows.Err()
})
return out, err
}
// ValidateRuleChannels відмовляє в правилі, яке шле в канал, якого в
// цьому кабінеті немає.
//
// Та сама перевірка, що ValidateStepChannels робить для сходинок
// драбини, і зроблена навмисно так само: alr.rules.channel_ids має тип
// uuid[], а на масив зовнішнього ключа в Postgres немає — тобто в базу
// лягав би будь-який UUID, хоч вигаданий, хоч підставлений із сусіднього
// кабінету.
//
// Ціна помилки тут ВИЩА, ніж у драбині, і це головне. Сходинка з
// невідомим каналом глушить одну сходинку. Правило з невідомим каналом
// глушить усе: targets() (див. alerting/notify.go) для явно вказаних
// каналів правила маршрути вже не питає, жодного каналу не знаходить,
// sent лишається нулем — а через нуль shouldArmEscalation не взводить
// навіть драбину. Один переплутаний ідентифікатор вимикає і сповіщення,
// і резервний механізм, заведений рівно на випадок, коли сповіщення не
// спрацювало.
//
// Порожній перелік — не помилка й перевіряти його тут нічим: він
// означає «за загальними маршрутами кабінету» і є типовим станом
// правила.
//
// known — ідентифікатори каналів ЦЬОГО кабінету, прочитані під RLS у
// транзакції запису (tenantChannelIDs). Саме тому перевірка закриває й
// підстановку чужого UUID, не знаючи слова «чужий».
func ValidateRuleChannels(ids []string, known map[string]bool) error {
for _, id := range ids {
if known[id] {
continue
}
// Ідентифікатор у тексті лишаємо навмисно, з тієї ж причини, що
// й у сходинках: у формі канали обираються галочками, тож той,
// хто це побачив, шле правило не з форми — і має знати, який
// саме рядок не прийнято.
return fmt.Errorf("%w: правило шле в канал %s, якого немає в цьому кабінеті — "+
"оберіть канал зі списку на сторінці «Канали»", ErrInvalid, id)
}
return nil
}
func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in RuleInput) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Перевірка тут, а не лише в HTTP: обробник — не єдиний шлях
// запису, і кожен наступний (імпорт, шаблон, API-токен) забув
// би її повторити. Показово, що в тому самому обробнику
// escalation_policy_id уже перевірявся на належність кабінету, а
// канали — ні.
known, err := tenantChannelIDs(ctx, tx, tenantID)
if err != nil {
return err
}
if err := ValidateRuleChannels(in.ChannelIDs, known); err != nil {
return err
}
return tx.QueryRow(ctx, `
INSERT INTO alr.rules
(tenant_id, name, description, source, severity, selector, condition,
for_seconds, depends_on_topology, enabled, created_by,
channel_ids, notify_schedule, notify_on_resolve,
auto_close_seconds, min_interval_seconds, escalation_policy_id)
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb,
$8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14,$15,$16,$17)
RETURNING id::text
`, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity,
in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology,
in.Enabled == nil || *in.Enabled, nullUUID(userID), uuidArray(in.ChannelIDs), in.NotifySchedule,
in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds,
nullUUID(in.EscalationPolicyID)).Scan(&id)
})
return id, err
}
func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Спершу закриваємо алерти, і лише потім саме правило.
//
// Порядок принциповий: alr.alerts.rule_id має ON DELETE SET NULL,
// тож після видалення правила його алерти лишаються без жодного
// зв'язку — движок їх більше не бачить і закрити не може, а на
// екрані вони висять вічно.
if err := resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило видалено"); err != nil {
return err
}
tag, err := tx.Exec(ctx,
`DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2`, tenantID, ruleID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrAlertNotFound
}
return nil
})
}
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE alr.rules SET enabled = $3, updated_at = now()
WHERE tenant_id = $1 AND id = $2
`, tenantID, ruleID, enabled)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrAlertNotFound
}
// Вимкнене правило випадає з вибірки движка, тому закрити свої
// алерти вже не зможе — це треба зробити тут і зараз.
if !enabled {
return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено")
}
return nil
})
}
// resolveRuleAlerts закриває всі активні алерти правила й повідомляє UI.
func resolveRuleAlerts(ctx context.Context, tx pgx.Tx, tenantID, ruleID, reason string) error {
_, err := tx.Exec(ctx, `
WITH closed AS (
UPDATE alr.alerts
SET state = 'resolved', resolved_at = now()
WHERE tenant_id = $1 AND rule_id = $2
AND state IN ('firing','acknowledged','suppressed')
RETURNING id
)
INSERT INTO core.event_outbox (tenant_id, topic, payload)
SELECT $1, 'alert.resolved',
jsonb_build_object('alert_id', id::text, 'reason', $3::text)
FROM closed
`, tenantID, ruleID, reason)
return err
}
// uuidArray готує перелік каналів до запису.
//
// Стовпець `channel_ids uuid[] NOT NULL DEFAULT '{}'`, і саме через
// DEFAULT здається, що з порожнім переліком усе гаразд. Але DEFAULT
// спрацьовує лише на ВІДСУТНІЙ стовпець; nil-зріз із Go їде як явний
// NULL і валить запис. Правило без власних каналів — типовий стан
// («сповіщати за загальними маршрутами кабінету»), тож ця невідповідність
// означала 500 на цілком звичайній дії.
//
// Знайдено прогоном проти справжньої бази; жоден тест на структурах
// цього побачити не міг.
func uuidArray(ids []string) []string {
if ids == nil {
return []string{}
}
return ids
}
func rawJSON(s string) any {
if s == "" {
return nil
}
return jsonRaw(s)
}
type jsonRaw string
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 замінює правило цілком.
//
// Цілком, а не полями: форма показує повний стан правила, і часткові
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) 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)
// Канали перевіряємо ПІСЛЯ того, як переконались, що правило
// існує: інакше правка чужого правила з поганим каналом сказала
// б «немає такого каналу» замість «немає такого правила», тобто
// відповіла б не на те питання, яке поставили.
//
// Перелік читається в цій же транзакції — між перевіркою й
// записом не лишається щілини, в якій канал встигає зникнути.
known, err := tenantChannelIDs(ctx, tx, tenantID)
if err != nil {
return err
}
if err := ValidateRuleChannels(in.ChannelIDs, known); err != nil {
return err
}
ct, err := tx.Exec(ctx, `
UPDATE alr.rules SET
name = $3, description = $4, source = $5::alr.rule_source,
severity = $6::alr.severity, selector = $7::jsonb,
condition = $8::jsonb, for_seconds = $9,
depends_on_topology = $10, enabled = $11,
channel_ids = $12::uuid[],
notify_schedule = NULLIF($13,'')::jsonb,
notify_on_resolve = $14,
auto_close_seconds = $15,
min_interval_seconds = $16,
escalation_policy_id = $17,
updated_at = now()
WHERE id = $1 AND tenant_id = $2
`, ruleID, tenantID, in.Name, nullString(in.Description), in.Source,
in.Severity, in.Selector, in.Condition, in.ForSeconds,
in.DependsOnTopology, enabled, uuidArray(in.ChannelIDs), in.NotifySchedule,
in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds,
nullUUID(in.EscalationPolicyID))
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
// Те саме, що робить SetRuleEnabled, і з тієї ж причини:
// вимкнене правило випадає з ActiveRules, тобто ResolveMissing
// за ним більше не біжить і закрити свої алерти воно вже не
// зможе. Без цього рядка алерти висіли б у firing вічно — а
// драбина ескалації справно будила б за ними людей, з повторами
// на тижні вперед.
if resolve {
return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено")
}
return nil
})
}