Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
260 lines
10 KiB
Go
260 lines
10 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// TemplateTrigger — тригер, описаний у шаблоні.
|
||
//
|
||
// Той самий набір полів, що в alr.rules, мінус селектор: до чого
|
||
// застосовується тригер, вирішує не він сам, а те, кому призначено
|
||
// шаблон. У цьому вся суть — умова описується один раз на клас
|
||
// пристроїв.
|
||
type TemplateTrigger struct {
|
||
ID string `json:"id,omitempty"`
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
Source string `json:"source"`
|
||
Severity string `json:"severity"`
|
||
Condition json.RawMessage `json:"condition"`
|
||
Recovery json.RawMessage `json:"recovery,omitempty"`
|
||
ForSeconds int `json:"for_seconds"`
|
||
DependsOnTopology bool `json:"depends_on_topology"`
|
||
Enabled bool `json:"enabled"`
|
||
Position int `json:"position"`
|
||
}
|
||
|
||
func templateTriggers(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateTrigger, error) {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, key, name, description, source::text, severity::text,
|
||
condition::text, COALESCE(recovery::text,''), for_seconds,
|
||
depends_on_topology, enabled, position
|
||
FROM tpl.triggers WHERE template_id = $1 ORDER BY position, name
|
||
`, templateID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []TemplateTrigger{}
|
||
for rows.Next() {
|
||
var (
|
||
t TemplateTrigger
|
||
condition, recov string
|
||
)
|
||
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Source,
|
||
&t.Severity, &condition, &recov, &t.ForSeconds,
|
||
&t.DependsOnTopology, &t.Enabled, &t.Position); err != nil {
|
||
return nil, err
|
||
}
|
||
t.Condition = json.RawMessage(condition)
|
||
if recov != "" && recov != "null" {
|
||
t.Recovery = json.RawMessage(recov)
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// SaveTemplateTriggers замінює набір тригерів шаблону цілком.
|
||
//
|
||
// Цілком, а не по одному: форма редагує шаблон як документ, і «прибрати
|
||
// тригер» має бути таким самим звичайним рухом, як «додати».
|
||
func (s *Store) SaveTemplateTriggers(ctx context.Context, tenantID, templateID string, triggers []TemplateTrigger) error {
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var owner *string
|
||
if err := tx.QueryRow(ctx,
|
||
`SELECT tenant_id::text FROM tpl.templates WHERE id = $1`, templateID).Scan(&owner); err != nil {
|
||
if isNoRows(err) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
if owner == nil {
|
||
return ErrBuiltin
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `DELETE FROM tpl.triggers WHERE template_id = $1`, templateID); err != nil {
|
||
return err
|
||
}
|
||
|
||
for i, t := range triggers {
|
||
if t.Key == "" || t.Name == "" {
|
||
return fmt.Errorf("%w: тригер без ключа або назви", ErrInvalid)
|
||
}
|
||
cond := "{}"
|
||
if len(t.Condition) > 0 {
|
||
cond = string(t.Condition)
|
||
}
|
||
var recovery any
|
||
if len(t.Recovery) > 0 && string(t.Recovery) != "null" {
|
||
recovery = string(t.Recovery)
|
||
}
|
||
if t.Source == "" {
|
||
t.Source = "metric"
|
||
}
|
||
if t.Severity == "" {
|
||
t.Severity = "warning"
|
||
}
|
||
// Тригер, який не може спрацювати, не має виглядати
|
||
// ввімкненим. Вимкнений — може лишатись: він нічого не
|
||
// обіцяє й ні в що не розгортається.
|
||
if t.Enabled {
|
||
if err := ValidateRuleCondition(t.Source, t.Condition); err != nil {
|
||
return fmt.Errorf("тригер %s: %w", t.Key, err)
|
||
}
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO tpl.triggers
|
||
(template_id, key, name, description, source, severity,
|
||
condition, recovery, for_seconds, depends_on_topology,
|
||
enabled, position)
|
||
VALUES ($1,$2,$3,$4,$5::alr.rule_source,$6::alr.severity,
|
||
$7::jsonb,$8::jsonb,$9,$10,$11,$12)
|
||
`, templateID, t.Key, t.Name, t.Description, t.Source, t.Severity,
|
||
cond, recovery, t.ForSeconds, t.DependsOnTopology,
|
||
t.Enabled, i); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.ReconcileTemplateRules(ctx, tenantID, templateID)
|
||
}
|
||
|
||
// ReconcileTemplateRules розгортає тригери шаблону в правила сповіщень
|
||
// для одного тенанта.
|
||
//
|
||
// Тенант у сигнатурі не зайвий навіть для власного шаблону: вбудовані
|
||
// шаблони спільні для всіх, і кожен кабінет отримує свій рядок правила
|
||
// з тим самим template_id.
|
||
//
|
||
// Одне правило на тригер, а не на пару «тригер + хост». Прив'язка йде
|
||
// через селектор `{"template_ids":[...]}`, тож хост, якому щойно
|
||
// призначили шаблон, одразу підпадає під його тригери — перегенеровувати
|
||
// нічого не треба.
|
||
//
|
||
// Правило на кожен хост дало б тисячі рядків, які довелося б тримати в
|
||
// синхроні з призначеннями шаблонів, а кожне розходження виглядало б як
|
||
// «алерт не спрацював» — найгірший спосіб дізнатись про помилку.
|
||
func (s *Store) ReconcileTemplateRules(ctx context.Context, tenantID, templateID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
triggers, err := templateTriggers(ctx, tx, templateID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var tplName, tplKey string
|
||
if err := tx.QueryRow(ctx,
|
||
`SELECT name, key FROM tpl.templates WHERE id = $1`, templateID).Scan(&tplName, &tplKey); err != nil {
|
||
if isNoRows(err) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
|
||
selector, err := json.Marshal(map[string]any{"template_ids": []string{templateID}})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
kept := make([]string, 0, len(triggers))
|
||
for _, t := range triggers {
|
||
if !t.Enabled {
|
||
continue
|
||
}
|
||
// Ім'я правила містить шаблон: у списку правил має бути
|
||
// видно, звідки воно взялося й де його правити.
|
||
name := tplName + ": " + t.Name
|
||
|
||
// Ім'я правила унікальне в межах тенанта, а імена шаблонів —
|
||
// ні. Два шаблони з однаковою назвою й однойменним тригером
|
||
// давали помилку унікальності, і збереження другого падало
|
||
// п'ятисоткою. Ключ шаблону унікальний завжди, тож ним і
|
||
// розрізняємо — але лише коли справді треба, інакше він
|
||
// заважав би читати список.
|
||
var clash bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM alr.rules
|
||
WHERE tenant_id = $1 AND name = $2
|
||
AND template_id IS DISTINCT FROM $3
|
||
)
|
||
`, tenantID, name, templateID).Scan(&clash); err != nil {
|
||
return err
|
||
}
|
||
if clash {
|
||
name = tplName + " [" + tplKey + "]: " + t.Name
|
||
}
|
||
|
||
var recovery any
|
||
if len(t.Recovery) > 0 && string(t.Recovery) != "null" {
|
||
recovery = string(t.Recovery)
|
||
}
|
||
cond := "{}"
|
||
if len(t.Condition) > 0 {
|
||
cond = string(t.Condition)
|
||
}
|
||
|
||
// Подієвий тригер шаблону успадковує ті самі умовчання, що
|
||
// й правило, заведене руками: доба до гасіння й хвилина між
|
||
// подіями. Інакше «конфіг змінився», описаний у шаблоні на
|
||
// сорок комутаторів, дав би сорок алертів, які ніколи не
|
||
// зникнуть.
|
||
autoClose, minInterval := 0, 0
|
||
if IsEventSource(t.Source) {
|
||
autoClose, minInterval = 86400, 60
|
||
}
|
||
|
||
var id string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO alr.rules
|
||
(tenant_id, name, description, source, severity, selector,
|
||
condition, recovery, for_seconds, depends_on_topology,
|
||
enabled, template_id, template_trigger_key,
|
||
auto_close_seconds, min_interval_seconds)
|
||
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,
|
||
$7::jsonb,$8::jsonb,$9,$10,true,$11,$12,$13,$14)
|
||
ON CONFLICT (tenant_id, template_id, template_trigger_key)
|
||
WHERE template_id IS NOT NULL
|
||
DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
description = EXCLUDED.description,
|
||
source = EXCLUDED.source,
|
||
severity = EXCLUDED.severity,
|
||
selector = EXCLUDED.selector,
|
||
condition = EXCLUDED.condition,
|
||
recovery = EXCLUDED.recovery,
|
||
for_seconds = EXCLUDED.for_seconds,
|
||
depends_on_topology = EXCLUDED.depends_on_topology,
|
||
auto_close_seconds = EXCLUDED.auto_close_seconds,
|
||
min_interval_seconds = EXCLUDED.min_interval_seconds,
|
||
enabled = true,
|
||
updated_at = now()
|
||
RETURNING id::text
|
||
`, tenantID, name, t.Description, t.Source, t.Severity, string(selector),
|
||
cond, recovery, t.ForSeconds, t.DependsOnTopology,
|
||
templateID, t.Key, autoClose, minInterval).Scan(&id); err != nil {
|
||
return fmt.Errorf("тригер %s: %w", t.Key, err)
|
||
}
|
||
kept = append(kept, id)
|
||
}
|
||
|
||
// Прибираємо правила зниклих і вимкнених тригерів. Видалення, а
|
||
// не вимкнення: правило, породжене шаблоном, не має власного
|
||
// життя — джерело істини лишається одне.
|
||
_, err = tx.Exec(ctx, `
|
||
DELETE FROM alr.rules
|
||
WHERE template_id = $1 AND NOT (id = ANY($2::uuid[]))
|
||
`, templateID, kept)
|
||
return err
|
||
})
|
||
}
|