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

864 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"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
)
// Масова правка й масове видалення хостів.
//
// Дві дії в одному файлі, бо в них спільне найважче — добір набору.
// Оператор бачить перелік, відмічає в ньому рядки й очікує, що діятиме
// саме на них. Між тим, як він подивився, і тим, як натиснув, минає
// час: хтось міг завести хост, забрати доступ, видалити інший. Тому
// набір тут резолвиться так само, як у масовому виконанні команд
// (див. ResolveCommandTargets): клієнт шле перелік id РАЗОМ із
// фільтром, сервер бере перетин, а те, що відпало, повертає окремо.
//
// Друга спільна річ — межі доступу. Береться Scope.Writable, а не
// Readable: «бачу хост» і «можу його змінити» — різні права, і хост,
// до якого людині дали лише читання, не має мовчки потрапити в набір
// масової дії. Мовчки — ключове слово: він не просто відсіюється, а
// повертається у списку відсіяних, інакше людина рахувала б, що
// змінила п'ятдесят, а змінила сорок.
// MaxBulkDevices — стеля на одну масову дію.
//
// Двісті, а не «скільки влізе». Причина не в продуктивності запиту:
// UPDATE на десять тисяч рядків Postgres зробить швидко. Причина в
// тому, що вся правка йде однією транзакцією, а разом із хостами вона
// чіпає членство в групах, доступи й розклади бекапів — тобто тримає
// блокування на пів-інвентарю, поки диспетчер колектора намагається
// прочитати з тих самих таблиць свій план на наступний такт.
//
// Двісті — це також приблизно те, що людина здатна побачити в
// підтвердженні. Масова дія, перелік якої не вміщається в жоден екран,
// підтверджується наосліп, а «підтверджено наосліп» і «підтверджено» —
// різні речі. Хто справді міняє тисячу хостів, зробить це п'ятьма
// заходами й побачить результат кожного.
const MaxBulkDevices = 200
// BulkDeviceTarget — хост у наборі масової дії.
//
// Разом із тим, що до нього прив'язане. Ці числа потрібні саме тут, а
// не після дії: питання «що станеться з перевірками, розкладом і
// вузлом на мапі» людина ставить ДО натискання, і відповідь має бути
// на тому ж екрані, де кнопка.
type BulkDeviceTarget struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Address string `json:"address,omitempty"`
Kind string `json:"kind"`
Vendor string `json:"vendor,omitempty"`
SiteName string `json:"site_name,omitempty"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
// Скільки чеків опитує цей хост. Після видалення вони лишаються в
// базі, але планувальник їх не бачить — він бере лише живі хости.
Checks int `json:"checks"`
// Активні алерти: firing / acknowledged / suppressed.
ActiveAlerts int `json:"active_alerts"`
// Скільки вузлів на мапах посилаються на цей хост.
MapNodes int `json:"map_nodes"`
// Чи є власний розклад збору конфігів.
HasBackupPolicy bool `json:"has_backup_policy"`
// Скільки версій конфігу вже зібрано. Архівне видалення їх не
// чіпає, повне — стирає разом із зашифрованими тілами.
Configs int `json:"configs"`
// Далі — числа, які має сенс читати саме перед ПОВНИМ видаленням.
//
// Вони тут, а не окремою ручкою, з тієї ж причини, з якої тут
// стоять перевірки й вузли мап: людина обирає між «прибрати з
// переліку» й «видалити назавжди» в одному вікні, і різниця між
// цими двома діями виражається саме числами. Дві ручки означали б,
// що половина відповіді приходить після того, як вибір уже зроблено.
// Сумарний розмір збережених конфігів. Байти, а не версії: «12
// версій» нічого не каже про те, скільки місця звільниться.
ConfigBytes int64 `json:"config_bytes"`
// Рядів метрик — те, що на сторінці «Метрики» виглядає як графіки.
Series int `json:"series"`
// Вимірювань у цих рядах. Рахується зі стелею (див.
// purgeCountCap): точне число тут не варте повного перегляду
// гіпертаблиці, а «понад мільйон» лякає рівно так само.
Samples int64 `json:"samples"`
// SamplesCapped — лічильник уперся в стелю, справжнє число більше.
SamplesCapped bool `json:"samples_capped,omitempty"`
// Усі алерти, а не лише активні: історія розбору інцидентів по
// цьому хосту зникає разом із ним.
Alerts int `json:"alerts"`
// Портів у інвентарі.
Interfaces int `json:"interfaces"`
// Гілка в Git-архіві. Порожньо — хост жодного разу не комітився,
// тобто прибирати на дзеркалі нема чого.
Branch string `json:"branch,omitempty"`
}
// purgeCountCap — стеля лічильника вимірювань.
//
// Точний count(*) по гіпертаблиці за місяць даних для двохсот хостів —
// це хвилини читання заради числа у вікні підтвердження. Питання, на
// яке людина відповідає цим вікном, звучить «багато чи мало», а не
// «скільки саме»: «понад 1 000 000» відповідає на нього так само точно,
// як 1 348 512, і коштує рівно стелю.
const purgeCountCap = 1_000_000
// ResolveBulkTargets — перетин «що обрала людина» і «що їй дозволено».
//
// Фільтр той самий DeviceFilter, що й у масовому виконанні команд.
// Другої мови фільтрів у продукті бути не повинно: людина, яка
// відібрала в переліку хостів «усі проблемні в групі Миронівка», має
// відібрати те саме будь-де ще тими самими полями.
//
// На відміну від ResolveCommandTargets, тут перелік id обов'язковий.
// Різниця не косметична: команду запускають ПО ФІЛЬТРУ («прогнати на
// всіх Huawei»), а хости правлять і видаляють ПО ВІДМІЧЕНИХ РЯДКАХ.
// Дозволити тут порожній перелік означало б дати спосіб видалити
// півінвентарю запитом, у якому не названо жодного хоста.
func (s *Store) ResolveBulkTargets(ctx context.Context, tenantID string,
sc Scope, f DeviceFilter, ids []string) ([]BulkDeviceTarget, error) {
return s.resolveBulkTargets(ctx, tenantID, sc, f, ids, false)
}
// ResolveBulkPurgeTargets — те саме, але бачить і прибрані хости.
//
// Без цього «прибрати з переліку» перетворювалось на пастку: хост
// зникав з усіх переліків, а разом із ним зникала й будь-яка можливість
// його добити. Зібране лишалось у базі назавжди — рівно те сміття,
// заради якого повне видалення й заводили.
//
// Стосується ЛИШЕ повного видалення. Масова правка прибраного хоста
// сенсу не має: міняти налаштування тому, кого вже немає, — це спосіб
// повернути його наполовину.
func (s *Store) ResolveBulkPurgeTargets(ctx context.Context, tenantID string,
sc Scope, f DeviceFilter, ids []string) ([]BulkDeviceTarget, error) {
return s.resolveBulkTargets(ctx, tenantID, sc, f, ids, true)
}
func (s *Store) resolveBulkTargets(ctx context.Context, tenantID string,
sc Scope, f DeviceFilter, ids []string, withDeleted bool) ([]BulkDeviceTarget, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid)
}
if len(ids) > MaxBulkDevices {
return nil, fmt.Errorf("%w: за раз можна взяти не більше %d хостів, обрано %d",
ErrInvalid, MaxBulkDevices, len(ids))
}
var out []BulkDeviceTarget
// Умова фільтра — та сама вставка, що й у масовому виконанні команд
// (device_filter.go): один фільтр — одне тлумачення. Хост, який там
// потрапив під «усе, що не на цій прошивці», має потрапити під нього
// й тут.
// $2 — стеля лічильника вимірювань; далі перелік id, межі доступу,
// ознака «бачити прибраних» і лише потім умови фільтра.
cond, condArgs := deviceFilterSQL(f, 7)
args := append([]any{
tenantID, purgeCountCap, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable),
withDeleted,
}, condArgs...)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT d.id::text, d.name, COALESCE(host(d.address),''), d.kind::text,
COALESCE(d.vendor,''), COALESCE(st.name,''),
d.status::text, d.enabled,
(SELECT count(*) FROM core.checks c WHERE c.device_id = d.id),
(SELECT count(*) FROM alr.alerts a
WHERE a.device_id = d.id
AND a.state IN ('firing','acknowledged','suppressed')),
(SELECT count(*) FROM topo.map_nodes n WHERE n.device_id = d.id),
EXISTS (SELECT 1 FROM ncm.device_policies p WHERE p.device_id = d.id),
(SELECT count(*) FROM ncm.configs cf WHERE cf.device_id = d.id),
(SELECT COALESCE(sum(cf.size_bytes),0) FROM ncm.configs cf
WHERE cf.device_id = d.id),
(SELECT count(*) FROM ts.series s WHERE s.device_id = d.id),
-- Стеля в підзапиті, а не count(*) по всьому: див.
-- purgeCountCap. LIMIT усередині — саме те, що
-- перетворює повний перегляд на обмежений.
(SELECT count(*) FROM (
SELECT 1 FROM ts.samples sm
WHERE sm.series_id IN (SELECT s2.id FROM ts.series s2
WHERE s2.device_id = d.id)
LIMIT $2::int) q),
(SELECT count(*) FROM alr.alerts a2 WHERE a2.device_id = d.id),
(SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id),
COALESCE((SELECT cf.branch FROM ncm.configs cf
WHERE cf.device_id = d.id AND cf.branch <> ''
ORDER BY cf.collected_at DESC LIMIT 1), '')
FROM inv.devices d
LEFT JOIN inv.sites st ON st.id = d.site_id
WHERE d.tenant_id = $1 AND (d.deleted_at IS NULL OR $6::boolean)
AND d.id = ANY($3::uuid[])
AND ($4::boolean OR d.id = ANY($5::uuid[]))`+cond+`
ORDER BY d.name
`, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t BulkDeviceTarget
if err := rows.Scan(&t.DeviceID, &t.Name, &t.Address, &t.Kind, &t.Vendor,
&t.SiteName, &t.Status, &t.Enabled,
&t.Checks, &t.ActiveAlerts, &t.MapNodes, &t.HasBackupPolicy,
&t.Configs, &t.ConfigBytes, &t.Series, &t.Samples,
&t.Alerts, &t.Interfaces, &t.Branch); err != nil {
return err
}
// Гілка, під якою хост комітився, могла ще не оновитись
// після перейменування — тоді показуємо ту, яку він мав би
// зараз. Видалення прибирає обидві (див. purgeOne), але
// показати треба ту, що впізнається в дзеркалі.
if t.Branch == "" && t.Configs > 0 {
t.Branch = DeviceBranch(t.DeviceID, t.Name, t.Address)
}
t.SamplesCapped = t.Samples >= purgeCountCap
out = append(out, t)
}
return rows.Err()
})
return out, err
}
// ---------------------------------------------------------------------
// Форма масової правки
//
// Головне рішення всієї функції: «не чіпати» — це не «стерти».
//
// Форма на десять полів, застосована до п'ятдесяти хостів, не має
// занулити те, чого людина не заповнювала. Тому кожне поле має три
// стани, і всі три названі явно:
//
// поля немає в JSON — не чіпати (значення кожного хоста лишається своїм)
// {"op":"set", ...} — задати всім однакове
// {"op":"clear"} — очистити всім
//
// Порожній рядок як ознака «очистити» тут не годиться, хоч так і
// зроблено в одиничній формі: там людина бачить поточне значення в
// полі й стирає його свідомо, а тут поле порожнє ЗАВЖДИ — у п'ятдесяти
// хостів немає спільного «поточного значення», яке можна було б
// показати. Порожнє поле в масовій формі означає «я про це не
// говорив», і трактувати його як «зітри в усіх» — найдорожча помилка,
// яку ця функція може зробити.
//
// Той самий підхід уже несе патч мапи (див. maps_write.go): там nil
// означає «не чіпати», бо перетягування вузла шле лише координати.
// Різниця в тому, що там третього стану не потрібно, а тут потрібен.
// ---------------------------------------------------------------------
// StringOp — текстове або посилальне поле.
type StringOp struct {
Op string `json:"op"`
Value string `json:"value,omitempty"`
}
// BoolOp — прапорець. Очищати нема чого: false — це значення, а не
// відсутність значення.
type BoolOp struct {
Op string `json:"op"`
Value bool `json:"value"`
}
// ListOp — набір зв'язків (групи, шаблони, доступи).
//
// Тут трьох станів мало, і це не примха форми, а суть множини.
// «Додати до груп» і «прибрати з груп» — різні дії з різними
// наслідками, і жодна з них не виражається через «задати перелік»:
// хости в наборі мають РІЗНІ поточні групи, і спільного «задати» для
// них не існує, поки людина не скаже, що старе можна викинути.
//
// add — дописати, наявне лишити
// remove — прибрати саме ці, решту лишити
// replace — викинути все й поставити рівно це
//
// replace лишено навмисно, попри те що воно найнебезпечніше: без нього
// неможливо навести лад там, де групи вже наставлені абияк. Але в
// інтерфейсі воно окремим вибором зі своїм попередженням, а не
// поведінкою за замовчуванням.
type ListOp struct {
Op string `json:"op"`
IDs []string `json:"ids"`
}
// BulkBackupOp — розклад збору конфігів.
//
// Тут «задати» діє на весь об'єкт розкладу одразу, а не на кожне його
// поле окремо. Розклад — це одне рішення («щодня о третій, профілем
// huawei-vrp, доступом noc-ssh»), а не набір незалежних дрібниць:
// увімкнути збір, не сказавши чим і коли, означає завести політику,
// яка мовчки нічого не збирає. Три стани лишаються на рівні об'єкта:
// поля немає — розклади хостів не чіпаємо, set — ставимо всім один,
// clear — прибираємо розклад зовсім.
type BulkBackupOp struct {
Op string `json:"op"`
ProfileID string `json:"profile_id,omitempty"`
CredentialID string `json:"credential_id,omitempty"`
Enabled bool `json:"enabled"`
Cron string `json:"cron,omitempty"`
OnSyslog bool `json:"on_syslog"`
FollowsDefault bool `json:"follows_default"`
}
// BulkDeviceChanges — що саме міняється.
//
// Чого тут немає й не буде: імені та адреси. Вони унікальні на хост
// (ім'я — унікальним індексом, адреса — фізично), і масова зміна тут
// означала б або помилку оператора, або спробу зламати інвентар.
// Немає й нотаток: це людський текст про конкретний хост, і затерти
// п'ятдесят різних приміток одним абзацом — те саме, що затерти імена.
type BulkDeviceChanges struct {
// Зонд-опитувач. Найчастіша причина масової правки взагалі:
// дільницю перевели на інший зонд.
AgentID *StringOp `json:"agent_id,omitempty"`
// Тип хоста. Очищення немає: kind — NOT NULL з типовим 'other', і
// «очистити» тут означало б «поставити other», тобто те саме set.
Kind *StringOp `json:"kind,omitempty"`
// Виробник. Очищення осмислене: порожній виробник повертає хост
// під автоматичне розпізнавання, яке своє значення не перезаписує.
Vendor *StringOp `json:"vendor,omitempty"`
Enabled *BoolOp `json:"enabled,omitempty"`
Groups *ListOp `json:"groups,omitempty"`
Templates *ListOp `json:"templates,omitempty"`
Credentials *ListOp `json:"credentials,omitempty"`
Backup *BulkBackupOp `json:"backup,omitempty"`
// Ручні перевірки — див. devices_bulk_checks.go. Тут вони окремим
// об'єктом, а не переліком, бо «додати» і «прибрати» називають тип,
// якого стосуються, і змішувати їх в одну дію нема сенсу.
Checks *BulkChecksOp `json:"checks,omitempty"`
}
// Empty — чи форма взагалі щось каже.
//
// Порожня масова правка не є безпечним «нічого не сталось»: людина
// натиснула кнопку й чекає на результат, і мовчазне «готово» вона
// прочитає як «застосовано». Тому це помилка, а не успіх.
func (c BulkDeviceChanges) Empty() bool {
return c.AgentID == nil && c.Kind == nil && c.Vendor == nil &&
c.Enabled == nil && c.Groups == nil && c.Templates == nil &&
c.Credentials == nil && c.Backup == nil && c.Checks == nil
}
func (o *StringOp) valid() error {
if o == nil {
return nil
}
switch o.Op {
case "set", "clear":
return nil
}
return fmt.Errorf("%w: невідома дія над полем: %q", ErrInvalid, o.Op)
}
func (o *ListOp) valid() error {
if o == nil {
return nil
}
switch o.Op {
case "add", "remove", "replace":
default:
return fmt.Errorf("%w: невідома дія над переліком: %q", ErrInvalid, o.Op)
}
// «Додати нічого» і «прибрати нічого» — не помилка бази, а
// незаповнена форма. Мовчки виконати її означає показати «готово»
// там, де не сталось нічого.
if o.Op != "replace" && len(o.IDs) == 0 {
return fmt.Errorf("%w: не обрано жодного елемента для дії %q", ErrInvalid, o.Op)
}
return nil
}
// BulkUpdateDevices застосовує правку до набору хостів.
//
// Одна транзакція на скалярні поля, групи, доступи й розклад: це або
// сталося з усіма обраними хостами, або не сталося ні з ким. Половина
// дільниці, переведена на новий зонд, — гірший стан, ніж жодного
// переведеного, бо про неї ніхто не знає.
//
// Шаблони — окремо й після транзакції. Призначення шаблону тягне за
// собою перерахунок чеків і правил алертів (ReconcileDeviceTemplates,
// ReconcileTemplateRules), кожен зі своєю транзакцією. Затягнути це
// всередину означало б тримати блокування на core.checks і alr.rules
// стільки, скільки триває перерахунок для всіх двохсот хостів.
//
// Ручні перевірки — теж усередині транзакції (див.
// devices_bulk_checks.go): це кілька простих операторів над
// core.checks, а не перерахунок, і атомарність тут важить більше за
// час блокування. Результат по кожному хосту повертається окремо: на
// відміну від решти полів, «додати перевірку» має три різні наслідки —
// додано, уже було, не вдалося, — і жодного з них не видно в числі
// змінених хостів.
func (s *Store) BulkUpdateDevices(ctx context.Context, tenantID string, sc Scope,
ids []string, ch BulkDeviceChanges) ([]string, []string, *BulkChecksResult, error) {
if ch.Empty() {
return nil, nil, nil, fmt.Errorf("%w: не задано жодної зміни", ErrInvalid)
}
if len(ids) == 0 {
return nil, nil, nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid)
}
if len(ids) > MaxBulkDevices {
return nil, nil, nil, fmt.Errorf("%w: за раз можна змінити не більше %d хостів, обрано %d",
ErrInvalid, MaxBulkDevices, len(ids))
}
for _, o := range []*StringOp{ch.AgentID, ch.Kind, ch.Vendor} {
if err := o.valid(); err != nil {
return nil, nil, nil, err
}
}
if ch.Kind != nil && ch.Kind.Op == "clear" {
return nil, nil, nil, fmt.Errorf("%w: тип хоста не можна очистити — його можна лише задати",
ErrInvalid)
}
for _, o := range []*ListOp{ch.Groups, ch.Templates, ch.Credentials} {
if err := o.valid(); err != nil {
return nil, nil, nil, err
}
}
if ch.Backup != nil && ch.Backup.Op != "set" && ch.Backup.Op != "clear" {
return nil, nil, nil, fmt.Errorf("%w: невідома дія над розкладом: %q", ErrInvalid, ch.Backup.Op)
}
if err := ch.Checks.valid(); err != nil {
return nil, nil, nil, err
}
var (
final []string
names []string
targets []bulkCheckDevice
checkRes *BulkChecksResult
)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Набір звужується ще раз, уже під блокуванням транзакції:
// перевірка прав у обробнику відповідає на питання «чи можна
// було», а цей запит — на питання «чи ще існує».
//
// Адреса й доменне ім'я їдуть звідси ж: підстановка в
// параметри перевірки має брати їх у тому ж стані, у якому
// хост зафіксовано в наборі, а не окремим запитом після.
rows, err := tx.Query(ctx, `
SELECT id::text, name, COALESCE(host(address),''), COALESCE(fqdn,'')
FROM inv.devices
WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL
AND ($3::boolean OR id = ANY($4::uuid[]))
ORDER BY name
`, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable))
if err != nil {
return err
}
for rows.Next() {
var d bulkCheckDevice
if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.FQDN); err != nil {
rows.Close()
return err
}
final = append(final, d.ID)
names = append(names, d.Name)
targets = append(targets, d)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(final) == 0 {
return fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис",
ErrInvalid)
}
if err := bulkSetFields(ctx, tx, tenantID, final, ch); err != nil {
return err
}
if err := bulkSetGroups(ctx, tx, tenantID, final, ch.Groups); err != nil {
return err
}
if err := bulkSetCredentials(ctx, tx, tenantID, final, ch.Credentials); err != nil {
return err
}
if err := bulkSetTemplateLinks(ctx, tx, tenantID, final, ch.Templates); err != nil {
return err
}
if err := bulkSetBackup(ctx, tx, tenantID, final, ch.Backup); err != nil {
return err
}
checkRes, err = bulkApplyChecks(ctx, tx, tenantID, targets, ch.Checks)
return err
})
if err != nil {
return nil, nil, nil, err
}
// Перерахунок після коміту. Помилка тут не скасовує правку — вона
// вже в базі; і мовчати про неї теж не можна, бо хост із
// призначеним, але не розгорнутим шаблоном виглядає налаштованим і
// нічого не опитує.
if ch.Templates != nil {
for _, id := range final {
if err := s.ReconcileDeviceTemplates(ctx, tenantID, id); err != nil {
return final, names, checkRes, fmt.Errorf("розгортання шаблонів: %w", err)
}
}
// Правила алертів — раз на шаблон, а не раз на хост: вони
// належать шаблону, і перерахунок для другого хоста зробив би
// ту саму роботу вдруге.
for _, tplID := range ch.Templates.IDs {
if err := s.ReconcileTemplateRules(ctx, tenantID, tplID); err != nil {
return final, names, checkRes, fmt.Errorf("правила шаблону: %w", err)
}
}
}
return final, names, checkRes, nil
}
// bulkSetFields — скалярні поля одним UPDATE.
//
// CASE на кожне поле замість збирання SQL рядками: запит лишається
// одним і тим самим текстом для планувальника, а «не чіпати»
// перетворюється на присвоєння полю його ж значення — тобто на
// відсутність зміни, а не на її імітацію.
func bulkSetFields(ctx context.Context, tx pgx.Tx, tenantID string,
ids []string, ch BulkDeviceChanges) error {
if ch.AgentID == nil && ch.Kind == nil && ch.Vendor == nil && ch.Enabled == nil {
return nil
}
op := func(o *StringOp) (string, string) {
if o == nil {
return "", ""
}
return o.Op, o.Value
}
agentOp, agentVal := op(ch.AgentID)
kindOp, kindVal := op(ch.Kind)
vendorOp, vendorVal := op(ch.Vendor)
enabledOp, enabledVal := "", false
if ch.Enabled != nil {
enabledOp, enabledVal = "set", ch.Enabled.Value
}
// Права тут уже не перевіряються: ids — це той перелік, який
// щойно повернув запит на початку транзакції, тобто вже звужений
// і тенантом, і Scope.Writable.
_, err := tx.Exec(ctx, `
UPDATE inv.devices SET
agent_id = CASE $3::text
WHEN 'set' THEN NULLIF($4::text,'')::uuid
WHEN 'clear' THEN NULL
ELSE agent_id END,
kind = CASE $5::text
WHEN 'set' THEN $6::text::inv.device_kind
ELSE kind END,
vendor = CASE $7::text
WHEN 'set' THEN NULLIF(btrim($8::text),'')
WHEN 'clear' THEN NULL
ELSE vendor END,
enabled = CASE $9::text
WHEN 'set' THEN $10::boolean
ELSE enabled END,
updated_at = now()
WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL
`, tenantID, ids,
agentOp, agentVal, kindOp, kindVal, vendorOp, vendorVal,
enabledOp, enabledVal)
return err
}
// bulkSetGroups — членство в групах.
//
// Групи звіряються з тенантом усередині кожного запиту, а не перед
// ними: інакше чужий id у списку тихо переклав би хости в групу
// сусіда. Так само зроблено в SetDeviceGroups для одного хоста.
func bulkSetGroups(ctx context.Context, tx pgx.Tx, tenantID string,
ids []string, op *ListOp) error {
if op == nil {
return nil
}
if op.Op == "replace" || op.Op == "remove" {
q := `
DELETE FROM inv.device_group_members m
USING inv.device_groups g
WHERE m.group_id = g.id AND g.tenant_id = $1
AND m.device_id = ANY($2::uuid[])
`
args := []any{tenantID, ids}
if op.Op == "remove" {
q += ` AND m.group_id = ANY($3::uuid[])`
args = append(args, nonNilIDs(op.IDs))
}
if _, err := tx.Exec(ctx, q, args...); err != nil {
return err
}
if op.Op == "remove" {
return nil
}
}
if len(op.IDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_group_members (group_id, device_id)
SELECT g.id, d.id
FROM inv.device_groups g
CROSS JOIN unnest($2::uuid[]) AS d(id)
WHERE g.tenant_id = $1 AND g.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, tenantID, ids, nonNilIDs(op.IDs))
return err
}
// bulkSetCredentials — доступи до обладнання.
//
// Пріоритет 100 — той самий, що ставить одинична форма: масова правка
// не має заводити хостам порядок перебору, якого людина не бачила.
func bulkSetCredentials(ctx context.Context, tx pgx.Tx, tenantID string,
ids []string, op *ListOp) error {
if op == nil {
return nil
}
if op.Op == "replace" || op.Op == "remove" {
q := `
DELETE FROM inv.device_credentials dc
USING inv.credentials c
WHERE dc.credential_id = c.id AND c.tenant_id = $1
AND dc.device_id = ANY($2::uuid[])
`
args := []any{tenantID, ids}
if op.Op == "remove" {
q += ` AND dc.credential_id = ANY($3::uuid[])`
args = append(args, nonNilIDs(op.IDs))
}
if _, err := tx.Exec(ctx, q, args...); err != nil {
return err
}
if op.Op == "remove" {
return nil
}
}
if len(op.IDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_credentials (device_id, credential_id, priority)
SELECT d.id, c.id, 100
FROM inv.credentials c
CROSS JOIN unnest($2::uuid[]) AS d(id)
WHERE c.tenant_id = $1 AND c.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, tenantID, ids, nonNilIDs(op.IDs))
return err
}
// bulkSetTemplateLinks — самі зв'язки хост↔шаблон.
//
// Перерахунок чеків тут не робиться навмисно, він іде після коміту:
// див. коментар у BulkUpdateDevices.
//
// Шаблон звіряється не лише з тенантом: вбудовані шаблони спільні й
// мають tenant_id IS NULL, тож умова саме така, як у переліку шаблонів
// для форми. Без неї масова правка стала б способом причепити хосту
// чужий шаблон за вгаданим id.
func bulkSetTemplateLinks(ctx context.Context, tx pgx.Tx, tenantID string,
ids []string, op *ListOp) error {
if op == nil {
return nil
}
if op.Op == "replace" || op.Op == "remove" {
q := `DELETE FROM tpl.device_templates WHERE device_id = ANY($1::uuid[])`
args := []any{ids}
if op.Op == "remove" {
q += ` AND template_id = ANY($2::uuid[])`
args = append(args, nonNilIDs(op.IDs))
}
if _, err := tx.Exec(ctx, q, args...); err != nil {
return err
}
if op.Op == "remove" {
return nil
}
}
if len(op.IDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO tpl.device_templates (device_id, template_id, tenant_id)
SELECT d.id, t.id, $1
FROM tpl.templates t
CROSS JOIN unnest($2::uuid[]) AS d(id)
WHERE t.id = ANY($3::uuid[])
AND (t.tenant_id IS NULL OR t.tenant_id = $1)
ON CONFLICT DO NOTHING
`, tenantID, ids, nonNilIDs(op.IDs))
return err
}
// bulkSetBackup — розклад збору конфігів.
//
// next_backup_at обнуляється так само, як в одиничному SetBackupPolicy:
// розклад міг стати частішим, і лишити стару позначку означало б
// чекати за старим розкладом до першого спрацювання нового.
func bulkSetBackup(ctx context.Context, tx pgx.Tx, tenantID string,
ids []string, op *BulkBackupOp) error {
if op == nil {
return nil
}
if op.Op == "clear" {
// Саме DELETE, а не enabled = false. «Вимкнений розклад» і
// «розкладу немає» розрізняються в картці хоста, і масова дія
// не має лишати по собі стан, якого людина не просила.
_, err := tx.Exec(ctx, `
DELETE FROM ncm.device_policies
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[])
`, tenantID, ids)
return err
}
cron := strings.TrimSpace(op.Cron)
if cron == "" {
cron = "0 3 * * *"
}
_, err := tx.Exec(ctx, `
INSERT INTO ncm.device_policies
(device_id, tenant_id, profile_id, credential_id, enabled, cron,
on_syslog, follows_default, next_backup_at)
SELECT d.id, $1, $3, $4, $5, $6, $7, $8, NULL
FROM unnest($2::uuid[]) AS d(id)
ON CONFLICT (device_id) DO UPDATE SET
profile_id = EXCLUDED.profile_id,
credential_id = EXCLUDED.credential_id,
enabled = EXCLUDED.enabled,
cron = EXCLUDED.cron,
on_syslog = EXCLUDED.on_syslog,
follows_default = EXCLUDED.follows_default,
next_backup_at = NULL,
updated_at = now()
`, tenantID, ids, nullUUID(op.ProfileID), nullUUID(op.CredentialID),
op.Enabled, cron, op.OnSyslog, op.FollowsDefault)
return err
}
// ---------------------------------------------------------------------
// Масове видалення
// ---------------------------------------------------------------------
// BulkDeleteDevices — АРХІВНЕ видалення набору хостів.
//
// Одна з двох дій кнопки «Видалити»; друга — PurgeDevices
// (devices_purge.go), яка стирає хост назавжди. Тут — рівно те саме,
// що робить DeleteDevice для одного: deleted_at = now()
// і enabled = false. Розходитись із ним не можна принципово — «видалив
// п'ятдесят» і «видалив по одному п'ятдесят разів» мусять давати той
// самий стан бази, інакше половина системи знатиме про хост, а
// половина ні.
//
// Що з цього випливає для всього прив'язаного, і чому це саме так:
//
// - чеки лишаються рядками в core.checks, але планувальник бере лише
// живі й увімкнені хости — опитування спиняється;
// - розклад бекапів лишається, але DuePolicies так само дивиться на
// deleted_at — збір спиняється;
// - активні алерти закриються самі: рушій рахує кандидатів по живих
// хостах і закриває різницею множин (ResolveMissing), тобто на
// найближчому такті оцінки правила;
// - вузли на мапах ЛИШАЮТЬСЯ. Читання мапи не фільтрує видалені
// хости, тож вузол стоїть на місці з останнім відомим станом. Це
// не забутий випадок: мапа — це креслення, і дірка в ньому має
// з'явитись рішенням людини, а не наслідком дії в іншому розділі;
// - метрики й зібрані конфіги не чіпаються взагалі. Заради цього
// архівне видалення й існує окремо від повного;
// - гілка в Git-архіві лишається як була, і на дзеркалі теж. Хост,
// який колись повернуть, має продовжити СВОЮ історію конфігів, а
// не почати другу.
//
// Усе перелічене показується в підтвердженні числами по кожному хосту
// (див. BulkDeviceTarget) — щоб це читали ДО натискання.
func (s *Store) BulkDeleteDevices(ctx context.Context, tenantID string, sc Scope,
ids []string) ([]string, []string, error) {
if len(ids) == 0 {
return nil, nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid)
}
if len(ids) > MaxBulkDevices {
return nil, nil, fmt.Errorf("%w: за раз можна видалити не більше %d хостів, обрано %d",
ErrInvalid, MaxBulkDevices, len(ids))
}
var (
deleted []string
names []string
)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
UPDATE inv.devices
SET deleted_at = now(), enabled = false, archived_enabled = enabled
WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL
AND ($3::boolean OR id = ANY($4::uuid[]))
RETURNING id::text, name
`, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return err
}
deleted = append(deleted, id)
names = append(names, name)
}
if err := rows.Err(); err != nil {
return err
}
// Те саме, що робить DeleteDevice для одного хоста: увімкнений
// чек видаленого хоста план не бере, зате він назавжди лишається
// «таким, що пропустив свій такт» на сторінці «Черги».
//
// archived_off — щоб RestoreDevices знало, які саме чеки
// вимкнула ця дія, а які були вимкнені й до неї.
if len(deleted) > 0 {
if _, err := tx.Exec(ctx, `
UPDATE core.checks
SET enabled = false, archived_off = true, updated_at = now()
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) AND enabled
`, tenantID, nonNilIDs(deleted)); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, nil, err
}
if len(deleted) == 0 {
return nil, nil, fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис",
ErrInvalid)
}
return deleted, names, nil
}