За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
740 lines
34 KiB
Go
740 lines
34 KiB
Go
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
|
||
}
|
||
|
||
// RuleRef — те, що дія запису розповідає про правило журналу аудиту.
|
||
//
|
||
// Повертається з самої дії, а не читається окремим запитом поруч, з
|
||
// двох причин. Після DELETE назву взяти вже нізвідки — а запис «видалено
|
||
// правило 7f3a…» не відповідає на питання, заради якого журнал і
|
||
// відкривають. І перехід «було ввімкнене → стало вимкнене» видно лише
|
||
// зсередини транзакції: окремий SELECT до неї побачив би стан, який до
|
||
// моменту запису вже нічий.
|
||
type RuleRef struct {
|
||
ID string
|
||
Name string
|
||
// WasEnabled — стан ДО дії, Enabled — після. Журнал відрізняє
|
||
// вимкнення правила від звичайної правки саме за цією парою.
|
||
WasEnabled bool
|
||
Enabled bool
|
||
}
|
||
|
||
func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) (RuleRef, error) {
|
||
var ref RuleRef
|
||
err := 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
|
||
}
|
||
err := tx.QueryRow(ctx, `
|
||
DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2
|
||
RETURNING id::text, name, enabled
|
||
`, tenantID, ruleID).Scan(&ref.ID, &ref.Name, &ref.WasEnabled)
|
||
if isNoRows(err) {
|
||
return ErrAlertNotFound
|
||
}
|
||
return err
|
||
})
|
||
return ref, err
|
||
}
|
||
|
||
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) (RuleRef, error) {
|
||
ref := RuleRef{ID: ruleID, Enabled: enabled}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Читання під замком у тій же транзакції, що й запис, — з тієї
|
||
// ж причини, що в UpdateRule нижче: між читанням і UPDATE
|
||
// уміщається збереження форми правила, і без FOR UPDATE журнал
|
||
// назвав би дією те, чого не сталось.
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT name, enabled FROM alr.rules
|
||
WHERE tenant_id = $1 AND id = $2
|
||
FOR UPDATE
|
||
`, tenantID, ruleID).Scan(&ref.Name, &ref.WasEnabled)
|
||
if isNoRows(err) {
|
||
return ErrAlertNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE alr.rules SET enabled = $3, updated_at = now()
|
||
WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, ruleID, enabled); err != nil {
|
||
return err
|
||
}
|
||
// Вимкнене правило випадає з вибірки движка, тому закрити свої
|
||
// алерти вже не зможе — це треба зробити тут і зараз.
|
||
if !enabled {
|
||
return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено")
|
||
}
|
||
return nil
|
||
})
|
||
return ref, err
|
||
}
|
||
|
||
// 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) (RuleRef, error) {
|
||
ref := RuleRef{ID: ruleID, Name: in.Name}
|
||
err := 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)
|
||
ref.WasEnabled, ref.Enabled = was, 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
|
||
})
|
||
return ref, err
|
||
}
|