Netpulse_SasS/server/internal/store/templates.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

1124 lines
42 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"
"errors"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
)
// ErrBuiltin — спроба змінити вбудований шаблон.
//
// Вбудовані спільні для всіх тенантів: правка одного мовчки змінила б
// опитування в чужих мережах. Хто хоче свій варіант — робить копію.
var ErrBuiltin = errors.New("вбудований шаблон не редагується")
// Template — шаблон опитування.
type Template struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
IsBuiltin bool `json:"is_builtin"`
// Фільтр складу портів для snmp.if. nil — шаблон не має думки про
// порти; див. iffilter.go.
IfFilter *InterfaceFilter `json:"if_filter,omitempty"`
Items []TemplateItem `json:"items,omitempty"`
// Прототипи: «на кожен знайдений рядок таблиці — свій набір
// метрик». Їдуть тим самим документом, що й елементи, бо це той
// самий опис «як опитувати цей клас заліза»; див. prototypes.go.
Prototypes []TemplatePrototype `json:"prototypes,omitempty"`
Graphs []TemplateGraph `json:"graphs,omitempty"`
Triggers []TemplateTrigger `json:"triggers,omitempty"`
ItemCount int `json:"item_count"`
DeviceCount int `json:"device_count"`
}
// TemplateItem — одна перевірка шаблону.
//
// Для snmp.get це «один OID = одна метрика». Для решти типів (пінг,
// HTTP) OID немає взагалі, а поведінку задає Params — те саме, що
// лежить у core.checks.params.
type TemplateItem struct {
ID string `json:"id,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid,omitempty"`
MetricKey string `json:"metric_key,omitempty"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled bool `json:"enabled"`
Params json.RawMessage `json:"params,omitempty"`
}
// groupsByOID каже, чи збираються елементи цього типу в один чек.
//
// Пачкою ходять лише OID: агент питає їх однією PDU, і сотня окремих
// чеків замість однієї пачки — це сотня SNMP-сесій. Пінг у пачку не
// збирається: два пінги з різними параметрами — це просто два пінги.
func groupsByOID(checkType string) bool {
return checkType == "snmp.get"
}
// ---------------------------------------------------------------------
// Читання
// ---------------------------------------------------------------------
// ListTemplates — довідник шаблонів із лічильниками.
func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) {
var out []Template
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Власна копія ховає вбудований оригінал.
//
// Так виглядає «редагування вбудованого»: правка не змінює
// спільний для всіх тенантів рядок, а створює власний із тим
// самим ключем. Показувати обидва означало б питати людину, який
// із двох однакових шаблонів вона щойно змінила.
rows, err := tx.Query(ctx, `
SELECT t.id::text, t.key, t.name, COALESCE(t.description,''),
COALESCE(t.vendor,''), t.is_builtin, t.if_filter::text,
(SELECT count(*) FROM tpl.items i WHERE i.template_id = t.id),
(SELECT count(*) FROM tpl.device_templates dt WHERE dt.template_id = t.id)
FROM tpl.templates t
WHERE t.tenant_id IS NOT NULL
OR NOT EXISTS (
SELECT 1 FROM tpl.templates o
WHERE o.tenant_id = $1 AND o.key = t.key)
ORDER BY t.is_builtin DESC, t.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t Template
var rawFilter string
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description,
&t.Vendor, &t.IsBuiltin, &rawFilter, &t.ItemCount, &t.DeviceCount); err != nil {
return err
}
// Фільтр потрібен і в переліку: картка має сказати, що цей
// шаблон звужує склад портів, без відкривання форми.
if f := parseInterfaceFilter(rawFilter); !f.IsEmpty() {
t.IfFilter = &f
}
out = append(out, t)
}
return rows.Err()
})
return out, err
}
// GetTemplate читає шаблон разом з елементами.
func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template, error) {
var t Template
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var rawFilter string
err := tx.QueryRow(ctx, `
SELECT id::text, key, name, COALESCE(description,''),
COALESCE(vendor,''), is_builtin, if_filter::text
FROM tpl.templates WHERE id = $1
`, id).Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Vendor, &t.IsBuiltin, &rawFilter)
if err != nil {
return err
}
if f := parseInterfaceFilter(rawFilter); !f.IsEmpty() {
t.IfFilter = &f
}
items, err := templateItems(ctx, tx, id)
if err != nil {
return err
}
t.Items = items
t.ItemCount = len(items)
protos, err := templatePrototypes(ctx, tx, id)
if err != nil {
return err
}
t.Prototypes = protos
graphs, err := templateGraphs(ctx, tx, id)
if err != nil {
return err
}
t.Graphs = graphs
triggers, err := templateTriggers(ctx, tx, id)
if err != nil {
return err
}
t.Triggers = triggers
return tx.QueryRow(ctx, `
SELECT count(*) FROM tpl.device_templates WHERE template_id = $1
`, id).Scan(&t.DeviceCount)
})
if err != nil {
if isNoRows(err) {
return nil, ErrNotFound
}
return nil, err
}
return &t, nil
}
func templateItems(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateItem, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, key, name, check_type, COALESCE(oid,''),
COALESCE(metric_key,''), unit, scale, interval_sec, enabled,
params::text
FROM tpl.items WHERE template_id = $1 ORDER BY name
`, templateID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TemplateItem{}
for rows.Next() {
var i TemplateItem
var params string
if err := rows.Scan(&i.ID, &i.Key, &i.Name, &i.CheckType, &i.OID,
&i.MetricKey, &i.Unit, &i.Scale, &i.IntervalSec, &i.Enabled,
&params); err != nil {
return nil, err
}
if params != "" && params != "{}" {
i.Params = json.RawMessage(params)
}
items = append(items, i)
}
return items, rows.Err()
}
// ---------------------------------------------------------------------
// Запис
// ---------------------------------------------------------------------
// SaveTemplate створює або оновлює власний шаблон разом з елементами.
//
// Елементи замінюються цілком, а не додаються: форма редагує шаблон як
// один документ, і «прибрати метрику» має бути таким самим звичайним
// рухом, як «додати». Ключ елемента при цьому стабільний, тож рядок,
// який не змінився, лишається тим самим рядком.
func (s *Store) SaveTemplate(ctx context.Context, tenantID string, t Template) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if t.ID != "" {
var builtin bool
var owner *string
if err := tx.QueryRow(ctx, `
SELECT is_builtin, tenant_id::text FROM tpl.templates WHERE id = $1
`, t.ID).Scan(&builtin, &owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Правка вбудованого не змінює спільний рядок, а створює
// власну копію тенанта з тим самим ключем. Оригінал лишається
// недоторканим для решти тенантів, а список показує копію
// замість нього.
//
// Це чесніше за «вбудований не редагується»: людина хоче
// підправити OID під своє залізо, а не дізнатися про межі
// нашої моделі даних.
if owner == nil {
t.ID = ""
}
}
// Фільтр складу портів їде тим самим документом, що й решта
// шаблону: людина редагує його на сусідній вкладці однієї форми,
// і окрема ручка означала б, що шаблон можна зберегти
// наполовину.
ifFilter := "{}"
if t.IfFilter != nil && !t.IfFilter.IsEmpty() {
if err := ValidateInterfaceFilter(*t.IfFilter); err != nil {
return fmt.Errorf("%w: %s", ErrInvalid, err)
}
b, err := json.Marshal(t.IfFilter)
if err != nil {
return err
}
ifFilter = string(b)
}
if t.ID != "" {
if err := tx.QueryRow(ctx, `
UPDATE tpl.templates
SET key = $2, name = $3, description = NULLIF($4,''),
vendor = NULLIF($5,''), if_filter = $7::jsonb, updated_at = now()
WHERE id = $1 AND tenant_id = $6
RETURNING id::text
`, t.ID, t.Key, t.Name, t.Description, t.Vendor, tenantID, ifFilter).Scan(&id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM tpl.items WHERE template_id = $1`, id); err != nil {
return err
}
} else {
if err := tx.QueryRow(ctx, `
INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin, if_filter)
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false, $6::jsonb)
RETURNING id::text
`, tenantID, t.Key, t.Name, t.Description, t.Vendor, ifFilter).Scan(&id); err != nil {
return err
}
}
for _, i := range t.Items {
if i.Scale == 0 {
i.Scale = 1
}
if i.IntervalSec == 0 {
i.IntervalSec = 60
}
if i.CheckType == "" {
i.CheckType = "snmp.get"
}
params := "{}"
if len(i.Params) > 0 {
params = string(i.Params)
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params)
VALUES ($1,$2,$3,$4,NULLIF($5,''),NULLIF($6,''),$7,$8,$9,$10,$11::jsonb)
`, id, i.Key, i.Name, i.CheckType, i.OID, i.MetricKey, i.Unit,
i.Scale, i.IntervalSec, i.Enabled, params); err != nil {
return err
}
}
// Прототипи замінюються цілком, як і елементи. Знайдені рядки
// при цьому не чіпаються: вони тримаються за ключ прототипу, а
// не за його id, і мусять пережити перезбереження шаблону —
// інакше кожне «Зберегти» обривало б усі графіки дисків до
// наступного обходу.
if err := savePrototypes(ctx, tx, id, t.Prototypes); err != nil {
return err
}
// Останнім кроком — і всередині тієї самої транзакції: відмова
// має лишити шаблон рівно таким, яким він був. Перевіряти після
// коміту означало б повідомити «збережено, але зламано».
return checkFilterLeavesPorts(ctx, tx, id)
})
if err != nil {
return "", err
}
// Чеки на всіх прив'язаних хостах мають наздогнати нову редакцію:
// сенс шаблону саме в тому, що виправлений OID доїжджає сам.
if err := s.reconcileTemplate(ctx, tenantID, id); err != nil {
return id, err
}
return id, nil
}
// DeleteTemplate прибирає власний шаблон.
//
// Породжені чеки зникають каскадом через core.checks.template_id — саме
// для цього колонка й існує.
func (s *Store) DeleteTemplate(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var builtin bool
if err := tx.QueryRow(ctx, `
SELECT is_builtin FROM tpl.templates WHERE id = $1
`, id).Scan(&builtin); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if builtin {
return ErrBuiltin
}
_, err := tx.Exec(ctx, `
DELETE FROM tpl.templates WHERE id = $1 AND tenant_id = $2
`, id, tenantID)
return err
})
}
// ---------------------------------------------------------------------
// Прив'язка до хостів
// ---------------------------------------------------------------------
// DeviceTemplateIDs — які шаблони прив'язані до хоста.
func (s *Store) DeviceTemplateIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
ids := []string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT template_id::text FROM tpl.device_templates WHERE device_id = $1
`, deviceID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
ids = append(ids, id)
}
return rows.Err()
})
return ids, err
}
// SetDeviceTemplates замінює набір шаблонів хоста й перебудовує чеки.
func (s *Store) SetDeviceTemplates(ctx context.Context, tenantID, deviceID string, templateIDs []string) error {
// Що було прив'язане ДО зміни.
//
// Потрібне тому, що переглянути треба не лише новий набір, а й
// відчеплені шаблони: правила, які вони колись породили для цього
// хоста, інакше лишаються назавжди. Хост уже не має шаблону, а
// тригер із нього досі піднімає алерти — і знайти, звідки він
// узявся, нема де: у шаблонах хоста його вже немає.
var before []string
if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT template_id::text FROM tpl.device_templates
WHERE device_id = $1 AND tenant_id = $2
`, deviceID, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
before = append(before, id)
}
return rows.Err()
}); err != nil {
return err
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, deviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `
DELETE FROM tpl.device_templates WHERE device_id = $1
`, deviceID); err != nil {
return err
}
for _, id := range templateIDs {
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.device_templates (device_id, template_id, tenant_id)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
`, deviceID, id, tenantID); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
if err := s.ReconcileDeviceTemplates(ctx, tenantID, deviceID); err != nil {
return err
}
// Тригери призначених шаблонів теж мають перетворитись на правила.
//
// Саме тут, а не лише при збереженні шаблону: вбудовані шаблони
// нікому не належать і не зберігаються, тож без цього кроку хост із
// «icmp-basic» збирав би пінг і мовчав про недоступність — тобто
// коробка виглядала б робочою й не робила головного.
//
// Переглядаємо ОБ'ЄДНАННЯ старого й нового набору. Раніше тут стояв
// лише новий — і правила відчепленого шаблону лишались жити: хост
// уже не має шаблону, а тригер із нього досі піднімає алерти.
// Перевірки при цьому зникали справно, тож ззовні це виглядало як
// «шаблон прибрано, а щось від нього лишилось» — без жодної
// підказки, що саме й де це шукати.
seen := make(map[string]bool, len(before)+len(templateIDs))
for _, id := range append(append([]string{}, before...), templateIDs...) {
if seen[id] {
continue
}
seen[id] = true
if err := s.ReconcileTemplateRules(ctx, tenantID, id); err != nil {
return err
}
}
return nil
}
// ---------------------------------------------------------------------
// Реконсиляція: шаблони → core.checks
// ---------------------------------------------------------------------
// oidSpec — елемент params.oids для snmp.get. Імена полів мусять
// збігатися з agent/internal/modules/snmp.OIDSpec.
//
// Labels з'явились разом із прототипами (0059): рядки динамічної
// таблиці адресуються не окремими метриками, а мітками — саме за ними
// ts.series розрізняє ряди (labels_hash), тож без них два диски злилися
// б в один графік. Агент це поле читав завжди, писати його ми почали
// лише тепер.
//
// omitempty тут не косметика: у звичайного елемента шаблону міток
// немає, і без нього params усіх наявних snmp.get-чеків отримали б
// зайве "labels":null — інший md5(params), інший хеш плану й
// одноразовий перезалив усього парку заради нічого.
type oidSpec struct {
OID string `json:"oid"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit,omitempty"`
Scale float64 `json:"scale"`
Labels map[string]string `json:"labels,omitempty"`
}
type checkGroup struct {
templateID string
checkType string
intervalSec int
// Порожній для згрупованого snmp.get, ключ елемента — для решти.
itemKey string
oids []oidSpec
params string
}
// ReconcileDeviceTemplates приводить чеки хоста у відповідність до його
// шаблонів.
//
// Елементи snmp.get групуються за (шаблон, інтервал) в один чек: агент
// уміє питати список OID однією пачкою, і сотня окремих чеків замість
// однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.
// Інтервал у ключі групування, бо пачка ходить цілком: змішавши
// хвилинну метрику з п'ятихвилинною, ми або опитували б рідкісну надто
// часто, або часту — надто рідко.
//
// Решта типів (пінг, HTTP) групування не знає: елемент — це чек.
func (s *Store) ReconcileDeviceTemplates(ctx context.Context, tenantID, deviceID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := reconcileDevice(ctx, tx, tenantID, deviceID); err != nil {
return err
}
// Причепити шаблон із фільтром — це теж зміна складу портів,
// і побачити її треба зараз, а не після наступного обходу.
return applyInterfaceFiltersTx(ctx, tx, tenantID, deviceID)
})
}
func (s *Store) reconcileTemplate(ctx context.Context, tenantID, templateID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT device_id::text FROM tpl.device_templates WHERE template_id = $1
`, templateID)
if err != nil {
return err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, id := range ids {
if err := reconcileDevice(ctx, tx, tenantID, id); err != nil {
return err
}
// Склад портів шаблон теж задає — фільтром. Без цього
// рядка виправлений зразок доїжджав би до хостів лише з
// наступним обходом автовиявлення, тобто «зберегти» не
// давало б жодного видимого наслідку годинами.
if err := applyInterfaceFiltersTx(ctx, tx, tenantID, id); err != nil {
return err
}
}
return nil
})
}
func reconcileDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) error {
rows, err := tx.Query(ctx, `
SELECT i.template_id::text, i.check_type, i.interval_sec, i.key,
COALESCE(i.oid,''), COALESCE(i.metric_key,''), i.unit, i.scale,
i.params::text
FROM tpl.device_templates dt
JOIN tpl.items i ON i.template_id = dt.template_id
WHERE dt.device_id = $1 AND i.enabled
ORDER BY i.template_id, i.check_type, i.interval_sec, i.key, i.oid
`, deviceID)
if err != nil {
return err
}
groups := map[string]*checkGroup{}
for rows.Next() {
var (
templateID, checkType, itemKey, params string
intervalSec int
o oidSpec
)
if err := rows.Scan(&templateID, &checkType, &intervalSec, &itemKey,
&o.OID, &o.MetricKey, &o.Unit, &o.Scale, &params); err != nil {
rows.Close()
return err
}
g := &checkGroup{
templateID: templateID,
checkType: checkType,
intervalSec: intervalSec,
params: params,
}
if !groupsByOID(checkType) {
g.itemKey = itemKey
}
k := fmt.Sprintf("%s|%s|%d|%s", g.templateID, g.checkType, g.intervalSec, g.itemKey)
if groups[k] == nil {
g.oids = []oidSpec{}
groups[k] = g
}
if groupsByOID(checkType) && o.OID != "" {
groups[k].oids = append(groups[k].oids, o)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// Порядок ключів фіксуємо: params потрапляє в хеш плану, і
// «однаковий шаблон дав інший хеш» через порядок map — це
// перезалив плану на кожному тіку звірки.
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
kept := make([]string, 0, len(keys))
for _, k := range keys {
g := groups[k]
var params string
if groupsByOID(g.checkType) {
if len(g.oids) == 0 {
continue
}
b, err := json.Marshal(map[string]any{"oids": g.oids})
if err != nil {
return err
}
params = string(b)
} else {
params = g.params
if params == "" {
params = "{}"
}
}
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO core.checks
(tenant_id, device_id, template_id, template_item_key, check_type,
params, interval_sec, enabled)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, true)
ON CONFLICT (device_id, template_id, check_type, interval_sec, template_item_key)
WHERE template_id IS NOT NULL
DO UPDATE SET params = EXCLUDED.params, enabled = true, updated_at = now()
RETURNING id::text
`, tenantID, deviceID, g.templateID, g.itemKey, g.checkType,
params, g.intervalSec).Scan(&id); err != nil {
return err
}
kept = append(kept, id)
}
// Чеки прототипів рахуємо тим самим прибиранням, що й решту.
//
// Окремим проходом, бо їх складають не з елементів, а зі знайдених
// рядків; але в один список kept, бо DELETE нижче не розрізняє, хто
// саме породив чек, — він знає лише «шаблон більше цього не
// просить». Забувши додати їх сюди, ми стирали б власні чеки
// прототипів на кожній реконсиляції.
protoKept, err := reconcilePrototypes(ctx, tx, tenantID, deviceID)
if err != nil {
return err
}
kept = append(kept, protoKept...)
// Прибираємо те, що шаблони більше не породжують: відв'язаний
// шаблон, вимкнений елемент або змінений інтервал.
_, err = tx.Exec(ctx, `
DELETE FROM core.checks
WHERE device_id = $1 AND template_id IS NOT NULL
AND NOT (id = ANY($2::uuid[]))
`, deviceID, kept)
return err
}
// ---------------------------------------------------------------------
// Обмін шаблонами
// ---------------------------------------------------------------------
// TemplateDoc — документ обміну.
//
// Свій формат, а не Zabbix-YAML: там елемент описується ключем виду
// `snmp.get[...]`, до нього чіпляються препроцесинг, value maps і
// тригери — усього цього тут поки немає, і вдавати сумісність означало б
// мовчки втрачати половину імпортованого. Формат навмисно плаский і
// читається очима.
type TemplateDoc struct {
Format string `json:"format"`
Version int `json:"version"`
Exported time.Time `json:"exported_at"`
Templates []Template `json:"templates"`
}
const (
docFormat = "netpulse-templates"
docVersion = 1
)
// ExportTemplates збирає документ із заданих шаблонів.
//
// Порожній список — усі, включно з вбудованими: типовий сценарій
// «забрати все з одного стенду й перенести на інший».
func (s *Store) ExportTemplates(ctx context.Context, tenantID string, ids []string) (*TemplateDoc, error) {
doc := &TemplateDoc{
Format: docFormat,
Version: docVersion,
Exported: time.Now().UTC(),
Templates: []Template{},
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var rows pgx.Rows
var err error
if len(ids) == 0 {
rows, err = tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(description,''), COALESCE(vendor,''), if_filter::text
FROM tpl.templates ORDER BY name`)
} else {
rows, err = tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(description,''), COALESCE(vendor,''), if_filter::text
FROM tpl.templates WHERE id = ANY($1::uuid[]) ORDER BY name`, ids)
}
if err != nil {
return err
}
var list []Template
for rows.Next() {
var t Template
var rawFilter string
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Vendor, &rawFilter); err != nil {
rows.Close()
return err
}
// Фільтр портів їде документом разом із рештою: перенести
// шаблон на інший стенд і виявити там сотню портів у чеку —
// це не «перенесли шаблон».
if f := parseInterfaceFilter(rawFilter); !f.IsEmpty() {
t.IfFilter = &f
}
list = append(list, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for i := range list {
items, err := templateItems(ctx, tx, list[i].ID)
if err != nil {
return err
}
// Ідентифікатори з документа прибираємо: на іншому стенді
// вони нічого не значать, а лишені — створюють ілюзію, що
// імпорт «відновить те саме».
list[i].ID = ""
for j := range items {
items[j].ID = ""
}
list[i].Items = items
// Прототипи їдуть документом разом із рештою: перенести
// шаблон на інший стенд і виявити, що диски там ніхто не
// шукає, — це не «перенесли шаблон».
protos, err := templatePrototypes(ctx, tx, list[i].ID)
if err != nil {
return err
}
for j := range protos {
protos[j].ID = ""
}
list[i].Prototypes = protos
doc.Templates = append(doc.Templates, list[i])
}
return nil
})
return doc, err
}
// ImportResult — що зробив імпорт.
type ImportResult struct {
Created []string `json:"created"`
Updated []string `json:"updated"`
Skipped []string `json:"skipped"`
}
// ImportTemplates заливає документ.
//
// updateExisting вирішує долю збігів за ключем: без нього наявний
// шаблон пропускається. Мовчазне перезаписування — найшвидший спосіб
// втратити локальні правки, тому типово вимкнене.
//
// Вбудований шаблон із таким самим ключем ніколи не чіпається: він
// спільний для всіх тенантів. Такий збіг завжди йде у Skipped.
func (s *Store) ImportTemplates(ctx context.Context, tenantID string, doc TemplateDoc, updateExisting bool) (*ImportResult, error) {
if doc.Format != "" && doc.Format != docFormat {
return nil, fmt.Errorf("чужий формат документа: %s", doc.Format)
}
res := &ImportResult{Created: []string{}, Updated: []string{}, Skipped: []string{}}
for _, t := range doc.Templates {
if t.Key == "" || t.Name == "" {
return nil, fmt.Errorf("шаблон без ключа або назви")
}
var existingID string
var builtin bool
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT id::text, is_builtin FROM tpl.templates
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY is_builtin DESC LIMIT 1
`, t.Key, tenantID).Scan(&existingID, &builtin)
})
if err != nil && !isNoRows(err) {
return nil, err
}
switch {
case existingID != "" && builtin:
res.Skipped = append(res.Skipped, t.Key)
continue
case existingID != "" && !updateExisting:
res.Skipped = append(res.Skipped, t.Key)
continue
case existingID != "":
t.ID = existingID
default:
t.ID = ""
}
if _, err := s.SaveTemplate(ctx, tenantID, t); err != nil {
return nil, fmt.Errorf("шаблон %s: %w", t.Key, err)
}
if existingID != "" {
res.Updated = append(res.Updated, t.Key)
} else {
res.Created = append(res.Created, t.Key)
}
}
return res, nil
}
// ---------------------------------------------------------------------
// Графіки шаблону
// ---------------------------------------------------------------------
// TemplateGraph — опис графіка.
type TemplateGraph struct {
ID string `json:"id,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
Kind string `json:"kind"`
MetricKeys []string `json:"metric_keys"`
Options json.RawMessage `json:"options,omitempty"`
Position int `json:"position"`
}
// DeviceGraph — графік, розгорнутий під конкретний хост.
//
// Ключі метрик перетворені на справжні ряди: шаблон каже «покажи
// cpu.util_pct», а на хості це може бути один ряд або жоден.
type DeviceGraph struct {
TemplateGraph
TemplateName string `json:"template_name"`
SeriesIDs []int64 `json:"series_ids"`
}
func templateGraphs(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateGraph, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, key, name, kind::text, metric_keys::text, options::text, position
FROM tpl.graphs WHERE template_id = $1 ORDER BY position, name
`, templateID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TemplateGraph{}
for rows.Next() {
var g TemplateGraph
var keys, opts string
if err := rows.Scan(&g.ID, &g.Key, &g.Name, &g.Kind, &keys, &opts, &g.Position); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(keys), &g.MetricKeys); err != nil {
g.MetricKeys = []string{}
}
if opts != "" && opts != "{}" {
g.Options = json.RawMessage(opts)
}
out = append(out, g)
}
return out, rows.Err()
}
// SaveTemplateGraphs замінює графіки шаблону цілком.
func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error {
return 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
}
// Графіки вбудованого шаблону писати нікуди: сам шаблон при
// збереженні вже перетворився на власну копію, і клієнт присилає
// її id. Якщо все ж прийшов вбудований — це помилка виклику.
if owner == nil {
return ErrBuiltin
}
if _, err := tx.Exec(ctx, `DELETE FROM tpl.graphs WHERE template_id = $1`, templateID); err != nil {
return err
}
for i, g := range graphs {
keys, err := json.Marshal(g.MetricKeys)
if err != nil {
return err
}
opts := "{}"
if len(g.Options) > 0 {
opts = string(g.Options)
}
if g.Kind == "" {
g.Kind = "line"
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
VALUES ($1,$2,$3,$4::tpl.graph_kind,$5::jsonb,$6::jsonb,$7)
`, templateID, g.Key, g.Name, g.Kind, string(keys), opts, i); err != nil {
return err
}
}
return nil
})
}
// DeviceGraphs збирає графіки з усіх шаблонів хоста й підставляє ряди.
//
// Графік без жодного знайденого ряду не повертається: порожня рамка з
// підписом «Пам'ять» на хості, де пам'ять не збирається, — це обіцянка
// даних, яких немає.
func (s *Store) DeviceGraphs(ctx context.Context, tenantID, deviceID string) ([]DeviceGraph, error) {
out := []DeviceGraph{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT t.id::text, t.name
FROM tpl.device_templates dt
JOIN tpl.templates t ON t.id = dt.template_id
WHERE dt.device_id = $1
ORDER BY t.name
`, deviceID)
if err != nil {
return err
}
type tpl struct{ id, name string }
var tpls []tpl
for rows.Next() {
var t tpl
if err := rows.Scan(&t.id, &t.name); err != nil {
rows.Close()
return err
}
tpls = append(tpls, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// Ряди хоста один раз: графіків може бути десяток, і питати
// однакове по разу на кожен — це десяток однакових запитів.
byKey := map[string][]int64{}
srows, err := tx.Query(ctx, `
SELECT metric_key, id FROM ts.series
WHERE tenant_id = $1 AND device_id = $2
`, tenantID, deviceID)
if err != nil {
return err
}
for srows.Next() {
var k string
var id int64
if err := srows.Scan(&k, &id); err != nil {
srows.Close()
return err
}
byKey[k] = append(byKey[k], id)
}
srows.Close()
if err := srows.Err(); err != nil {
return err
}
for _, t := range tpls {
graphs, err := templateGraphs(ctx, tx, t.id)
if err != nil {
return err
}
for _, g := range graphs {
var ids []int64
for _, k := range g.MetricKeys {
ids = append(ids, byKey[k]...)
}
if len(ids) == 0 {
continue
}
out = append(out, DeviceGraph{
TemplateGraph: g, TemplateName: t.name, SeriesIDs: ids,
})
}
}
return nil
})
return out, err
}
// CloneTemplate робить власну копію шаблону.
//
// Ключ отримує суфікс: копія з тим самим ключем ховала б оригінал, а
// клонують саме тоді, коли потрібні обидва — «як було» і «як хочу».
func (s *Store) CloneTemplate(ctx context.Context, tenantID, id string) (string, error) {
var newID string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var t Template
var ifFilter string
if err := tx.QueryRow(ctx, `
SELECT key, name, COALESCE(description,''), COALESCE(vendor,''), if_filter::text
FROM tpl.templates WHERE id = $1
`, id).Scan(&t.Key, &t.Name, &t.Description, &t.Vendor, &ifFilter); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Вільний ключ шукаємо перебором, а не випадковим суфіксом:
// «snmp-mikrotik-2» читається, «snmp-mikrotik-a7f3» — ні.
base := t.Key
key := base + "-copy"
for i := 2; ; i++ {
var taken bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM tpl.templates
WHERE key = $1 AND tenant_id = $2)
`, key, tenantID).Scan(&taken); err != nil {
return err
}
if !taken {
break
}
key = fmt.Sprintf("%s-copy-%d", base, i)
}
// Фільтр портів копіюється як є, на відміну від тригерів: він
// нічого нікому не шле й не подвоює сповіщень, а копію роблять
// саме щоб підправити зразок — стерти його означало б змусити
// набирати все спочатку.
if err := tx.QueryRow(ctx, `
INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin, if_filter)
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false, $6::jsonb)
RETURNING id::text
`, tenantID, key, t.Name+" (копія)", t.Description, t.Vendor, ifFilter).Scan(&newID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params)
SELECT $1, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params
FROM tpl.items WHERE template_id = $2
`, newID, id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
SELECT $1, key, name, kind, metric_keys, options, position
FROM tpl.graphs WHERE template_id = $2
`, newID, id); err != nil {
return err
}
// Прототипи копіюються як є, з тієї ж причини, що й фільтр
// портів: копію роблять саме щоб підправити правило, і стерти
// його означало б змусити набирати все спочатку. Знайдені рядки
// не копіюються — вони належать парі «хост + прототип», а копія
// шаблону ще нікому не причеплена.
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.item_prototypes
(template_id, key, name, discover_oid, name_from, row_filter,
metrics, discovery_sec, interval_sec, max_rows, enabled)
SELECT $1, key, name, discover_oid, name_from, row_filter,
metrics, discovery_sec, interval_sec, max_rows, enabled
FROM tpl.item_prototypes WHERE template_id = $2
`, newID, id); err != nil {
return err
}
// Тригери копіюються вимкненими.
//
// Клонують, щоб щось змінити, і копія з живими тригерами почала
// б слати ті самі сповіщення вдвічі — рівно з моменту, коли її
// призначать першому хосту, і не пояснивши, звідки дубль.
_, err := tx.Exec(ctx, `
INSERT INTO tpl.triggers
(template_id, key, name, description, source, severity,
condition, recovery, for_seconds, depends_on_topology,
enabled, position)
SELECT $1, key, name, description, source, severity,
condition, recovery, for_seconds, depends_on_topology,
false, position
FROM tpl.triggers WHERE template_id = $2
`, newID, id)
return err
})
return newID, err
}