Netpulse_SasS/server/internal/store/alerts_query.go
byrsapty cedf1d261d
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
Ескалації: журнал більше не бреше, ack не воскрешає драбину
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:

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

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

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

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

620 lines
27 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"
"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
}
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 {
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), 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
}
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)
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, 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
})
}