Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
1124 lines
42 KiB
Go
1124 lines
42 KiB
Go
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,
|
||
¶ms); 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, ¶ms); 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
|
||
}
|