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 }) }