Netpulse_SasS/server/internal/store/template_triggers.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
2026-08-27 17:32:49 +03:00

260 lines
10 KiB
Go
Raw Permalink 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"
"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
})
}