Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
434 lines
19 KiB
Go
434 lines
19 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Масова дія над ручними перевірками хостів.
|
||
//
|
||
// Ручні перевірки колись лишили поза масовою формою з міркуванням, що
|
||
// вони поодинокі за визначенням — те, чого немає в шаблонах. Насправді
|
||
// поодинока тут причина, а не кількість: «додати http.status усім
|
||
// вебсерверам дільниці» і «прибрати icmp.ping із сорока хостів, яким
|
||
// його колись наставили руками» — це одне рішення на сорок рядків, тобто
|
||
// рівно те, заради чого масова форма й існує. Без неї людина або робить
|
||
// сорок однакових рухів, або не робить нічого — і другий варіант
|
||
// трапляється частіше.
|
||
//
|
||
// ---------------------------------------------------------------------
|
||
// Чому немає «замінити весь набір»
|
||
//
|
||
// Та сама межа, що проведена для груп, і з тієї самої причини, лише
|
||
// гострішої. У груп replace лишили, бо без нього неможливо навести лад
|
||
// там, де наставлено абияк, і бо групи видно в переліку хостів. Набори
|
||
// перевірок не видно ніде: людина, яка натисне «замінити», зітре те,
|
||
// чого не бачила й побачити не могла, — і дізнається про це тоді, коли
|
||
// перестане приходити алерт. Тому дій рівно три, і кожна називає тип,
|
||
// якого стосується.
|
||
//
|
||
// ---------------------------------------------------------------------
|
||
// Що таке «ручна» перевірка
|
||
//
|
||
// template_id IS NULL AND interface_id IS NULL — рівно та ж межа, яку
|
||
// проводить SetDeviceChecks, коли прибирає зняте у формі хоста.
|
||
// Перевірку, породжену шаблоном, міняють у шаблоні: правка тут зникла б
|
||
// на найближчій реконсиляції. Перевірку на інтерфейсі заводить
|
||
// автовиявлення, і вона взагалі не про хост, а про порт.
|
||
|
||
// BulkChecksOp — одна дія над одним типом перевірки.
|
||
//
|
||
// Тип називається явно й завжди один. Це не обмеження реалізації:
|
||
// «додати перевірку» без назви типу не є висловлюванням, а дозволити
|
||
// кілька типів за раз означало б показувати результат («додано /
|
||
// уже було / не вдалося») перехресною таблицею, яку ніхто не читає.
|
||
type BulkChecksOp struct {
|
||
// add — завести перевірку тим, у кого її немає
|
||
// remove — прибрати перевірку цього типу
|
||
// tune — змінити розклад наявної: інтервал, таймаут, повтори,
|
||
// увімкнено/вимкнено
|
||
Op string `json:"op"`
|
||
CheckType string `json:"check_type"`
|
||
|
||
// Параметри — тільки для add. Для tune їх свідомо немає: у
|
||
// http.status параметр — це URL, і масово задати той самий URL
|
||
// сорока хостам, у яких він уже різний, означає зламати сорок
|
||
// перевірок одним рухом. Змінити параметр — це рішення про
|
||
// конкретний хост, і робиться воно в його картці.
|
||
Params json.RawMessage `json:"params,omitempty"`
|
||
|
||
// nil означає «не чіпати» — той самий тристановий принцип, що й у
|
||
// решті масової форми, лише всередині однієї дії. Для add nil — це
|
||
// «взяти типове».
|
||
IntervalSec *int `json:"interval_sec,omitempty"`
|
||
TimeoutMs *int `json:"timeout_ms,omitempty"`
|
||
Retries *int `json:"retries,omitempty"`
|
||
Enabled *bool `json:"enabled,omitempty"`
|
||
}
|
||
|
||
// BulkCheckOutcome — хост, з яким нічого не сталося, і чому.
|
||
type BulkCheckOutcome struct {
|
||
Device string `json:"device"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
// BulkChecksResult — що вийшло по кожному хосту.
|
||
//
|
||
// Три списки, а не одне число. Масове додавання ГАРАНТОВАНО натрапляє
|
||
// на хости, у яких така перевірка вже є, і це не помилка, а звичайний
|
||
// стан: людина відібрала дільницю, а половину дільниці колись уже
|
||
// налаштували руками. Показати «додано 12» і промовчати про решту
|
||
// означає збрехати; показати помилку означає злякати на рівному місці.
|
||
// Тому те саме розрізнення, що й у dropped: зроблено / не було чого
|
||
// робити / не вдалося.
|
||
type BulkChecksResult struct {
|
||
Op string `json:"op"`
|
||
CheckType string `json:"check_type"`
|
||
// Хости, де дія справді щось змінила.
|
||
Done []string `json:"done"`
|
||
// Хости, де робити не було чого: перевірка вже є (add) або її
|
||
// немає (remove, tune).
|
||
Skipped []BulkCheckOutcome `json:"skipped"`
|
||
// Хости, де дію зробити не вдалося. Зараз єдина причина —
|
||
// підстановка, якій нема чим підставитись (хост без адреси).
|
||
Failed []BulkCheckOutcome `json:"failed"`
|
||
}
|
||
|
||
func (o *BulkChecksOp) valid() error {
|
||
if o == nil {
|
||
return nil
|
||
}
|
||
switch o.Op {
|
||
case "add", "remove", "tune":
|
||
default:
|
||
return fmt.Errorf("%w: невідома дія над перевірками: %q", ErrInvalid, o.Op)
|
||
}
|
||
if strings.TrimSpace(o.CheckType) == "" {
|
||
return fmt.Errorf("%w: не названо тип перевірки", ErrInvalid)
|
||
}
|
||
// «Змінити наявні», не сказавши що саме, — незаповнена форма, а не
|
||
// дія без наслідків. Мовчазне «готово» тут прочиталося б як
|
||
// «застосовано».
|
||
if o.Op == "tune" && o.IntervalSec == nil && o.TimeoutMs == nil &&
|
||
o.Retries == nil && o.Enabled == nil {
|
||
return fmt.Errorf("%w: не задано, що саме змінити в перевірці %s",
|
||
ErrInvalid, o.CheckType)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Підстановка
|
||
//
|
||
// Головне питання масової роботи з перевірками — параметри. Вони різні
|
||
// за типом: у icmp.ping це кількість пакетів, у http.status — URL.
|
||
// Кількість пакетів у сорока хостів однакова, і задати її масово
|
||
// осмислено. URL — ні: сорок хостів мають сорок різних URL, і спільного
|
||
// значення для них не існує.
|
||
//
|
||
// Заборонити типи з обов'язковими параметрами означало б заборонити
|
||
// рівно той випадок, з якого все почалося: у http.status url
|
||
// обов'язковий. Тому дозволено, але з єдиною підстановкою — на те, чим
|
||
// хости в наборі законно різняться, тобто на них самих:
|
||
//
|
||
// {{address}} — адреса хоста
|
||
// {{name}} — ім'я хоста
|
||
// {{fqdn}} — доменне ім'я, якщо задане
|
||
//
|
||
// Трьох достатньо й більше не буде. Будь-яке «різне», що не є самим
|
||
// хостом, — це не масова дія, а сорок окремих рішень, і робити їх треба
|
||
// в сорока картках.
|
||
// ---------------------------------------------------------------------
|
||
|
||
// bulkCheckDevice — хост у масовій дії над перевірками.
|
||
type bulkCheckDevice struct {
|
||
ID string
|
||
Name string
|
||
Address string
|
||
FQDN string
|
||
}
|
||
|
||
// expandCheckParams підставляє значення хоста в рядкові параметри.
|
||
//
|
||
// Лише рядки й елементи рядкових масивів: підставляти в число нема чого,
|
||
// а лізти в довільно вкладені структури означало б підмінювати те, про
|
||
// що форма не питала.
|
||
//
|
||
// Порожнє значення — помилка, а не порожня підстановка. URL
|
||
// «http:///status» ліг би в базу мовчки, а помітили б його через добу
|
||
// в переліку впалих перевірок — за кілометр від того місця, де його
|
||
// створили.
|
||
func expandCheckParams(raw json.RawMessage, d bulkCheckDevice) (string, error) {
|
||
text := strings.TrimSpace(string(raw))
|
||
if text == "" || text == "null" {
|
||
return "{}", nil
|
||
}
|
||
if !strings.Contains(text, "{{") {
|
||
return text, nil
|
||
}
|
||
|
||
var params map[string]any
|
||
if err := json.Unmarshal([]byte(text), ¶ms); err != nil {
|
||
return "", fmt.Errorf("%w: параметри перевірки не є об'єктом JSON", ErrInvalid)
|
||
}
|
||
|
||
subst := func(s string) (string, error) {
|
||
for _, t := range []struct {
|
||
token string
|
||
value string
|
||
}{
|
||
{"{{address}}", d.Address},
|
||
{"{{name}}", d.Name},
|
||
{"{{fqdn}}", d.FQDN},
|
||
} {
|
||
if !strings.Contains(s, t.token) {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(t.value) == "" {
|
||
return "", fmt.Errorf("немає чим підставити %s", t.token)
|
||
}
|
||
s = strings.ReplaceAll(s, t.token, t.value)
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
for k, v := range params {
|
||
switch val := v.(type) {
|
||
case string:
|
||
s, err := subst(val)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
params[k] = s
|
||
case []any:
|
||
for i, item := range val {
|
||
s, ok := item.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
out, err := subst(s)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
val[i] = out
|
||
}
|
||
}
|
||
}
|
||
|
||
out, err := json.Marshal(params)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(out), nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Виконання
|
||
// ---------------------------------------------------------------------
|
||
|
||
// bulkApplyChecks виконує дію над ручними перевірками набору хостів.
|
||
//
|
||
// Усередині тієї ж транзакції, що й решта масової правки: «половина
|
||
// дільниці отримала перевірку» — гірший стан, ніж «не отримав ніхто»,
|
||
// бо про нього ніхто не дізнається. Хости, яким не вдалося, транзакцію
|
||
// не валять: «немає чим підставити адресу» — це властивість хоста, а не
|
||
// збій запиту, і скасовувати через неї роботу для решти означало б
|
||
// зробити один недооформлений хост стопором для всієї дільниці.
|
||
func bulkApplyChecks(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
devices []bulkCheckDevice, op *BulkChecksOp) (*BulkChecksResult, error) {
|
||
|
||
if op == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
res := &BulkChecksResult{
|
||
Op: op.Op,
|
||
CheckType: op.CheckType,
|
||
Done: []string{},
|
||
Skipped: []BulkCheckOutcome{},
|
||
Failed: []BulkCheckOutcome{},
|
||
}
|
||
|
||
switch op.Op {
|
||
case "add":
|
||
return res, bulkAddChecks(ctx, tx, tenantID, devices, op, res)
|
||
case "remove":
|
||
return res, bulkTouchChecks(ctx, tx, tenantID, devices, op, res, true)
|
||
default:
|
||
return res, bulkTouchChecks(ctx, tx, tenantID, devices, op, res, false)
|
||
}
|
||
}
|
||
|
||
// bulkAddChecks — завести перевірку тим, у кого її ще немає.
|
||
//
|
||
// По хосту, а не одним запитом: параметри після підстановки в кожного
|
||
// свої, та й результат однаково потрібен поштучно. Двісті окремих
|
||
// INSERT-ів усередині однієї транзакції коштують менше, ніж один рядок
|
||
// невиразної відповіді.
|
||
func bulkAddChecks(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
devices []bulkCheckDevice, op *BulkChecksOp, res *BulkChecksResult) error {
|
||
|
||
interval := 60
|
||
if op.IntervalSec != nil {
|
||
interval = clampInterval(*op.IntervalSec)
|
||
}
|
||
timeout := 3000
|
||
if op.TimeoutMs != nil && *op.TimeoutMs > 0 {
|
||
timeout = *op.TimeoutMs
|
||
}
|
||
retries := 2
|
||
if op.Retries != nil && *op.Retries >= 0 {
|
||
retries = *op.Retries
|
||
}
|
||
enabled := op.Enabled == nil || *op.Enabled
|
||
|
||
for _, d := range devices {
|
||
params, err := expandCheckParams(op.Params, d)
|
||
if err != nil {
|
||
res.Failed = append(res.Failed, BulkCheckOutcome{Device: d.Name, Reason: err.Error()})
|
||
continue
|
||
}
|
||
|
||
// NOT EXISTS по ТИПУ, а не по параметрах. Унікальний індекс
|
||
// checks_uniq рахує ще й md5(params), тобто пропустив би другий
|
||
// http.status з іншим URL — і хост лишився б із двома
|
||
// перевірками того самого типу, яких у формі картки завести
|
||
// неможливо. ON CONFLICT DO NOTHING поруч лишається як остання
|
||
// сітка: перевірка на інтерфейсі з такими ж параметрами в
|
||
// NOT EXISTS не потрапляє, а в індекс — потрапляє, і без цієї
|
||
// гілки 23505 обірвав би всю транзакцію на одному хості.
|
||
var id string
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO core.checks
|
||
(tenant_id, device_id, check_type, params, interval_sec,
|
||
timeout_ms, retries, enabled)
|
||
SELECT $1, $2::uuid, $3, $4::jsonb, $5, $6, $7, $8
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM core.checks c
|
||
WHERE c.tenant_id = $1 AND c.device_id = $2::uuid
|
||
AND c.check_type = $3
|
||
AND c.interface_id IS NULL AND c.template_id IS NULL
|
||
)
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING id::text
|
||
`, tenantID, d.ID, op.CheckType, params, interval, timeout, retries, enabled).Scan(&id)
|
||
|
||
switch {
|
||
case err == nil:
|
||
res.Done = append(res.Done, d.Name)
|
||
case errors.Is(err, pgx.ErrNoRows):
|
||
res.Skipped = append(res.Skipped, BulkCheckOutcome{
|
||
Device: d.Name, Reason: "перевірка цього типу вже є",
|
||
})
|
||
default:
|
||
return fmt.Errorf("перевірка %s на хості %s: %w", op.CheckType, d.Name, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// bulkTouchChecks — прибрати або змінити наявні перевірки типу.
|
||
//
|
||
// Одним запитом на весь набір: тут нема чого підставляти, а RETURNING
|
||
// device_id сам розділяє «зроблено» і «не було чого робити» — саме те,
|
||
// що треба показати людині.
|
||
func bulkTouchChecks(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
devices []bulkCheckDevice, op *BulkChecksOp, res *BulkChecksResult, remove bool) error {
|
||
|
||
ids := make([]string, 0, len(devices))
|
||
for _, d := range devices {
|
||
ids = append(ids, d.ID)
|
||
}
|
||
|
||
var (
|
||
rows pgx.Rows
|
||
err error
|
||
)
|
||
if remove {
|
||
rows, err = tx.Query(ctx, `
|
||
DELETE FROM core.checks
|
||
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[])
|
||
AND check_type = $3
|
||
AND interface_id IS NULL AND template_id IS NULL
|
||
RETURNING device_id::text
|
||
`, tenantID, nonNilIDs(ids), op.CheckType)
|
||
} else {
|
||
// Саме типовані вказівники, а не any(nil): nil тут доїжджає до
|
||
// Postgres як NULL потрібного типу, і COALESCE нижче читає його
|
||
// як «не чіпати» без здогадок про тип параметра.
|
||
var interval *int
|
||
if op.IntervalSec != nil {
|
||
v := clampInterval(*op.IntervalSec)
|
||
interval = &v
|
||
}
|
||
var timeout *int
|
||
if op.TimeoutMs != nil && *op.TimeoutMs > 0 {
|
||
timeout = op.TimeoutMs
|
||
}
|
||
var retries *int
|
||
if op.Retries != nil && *op.Retries >= 0 {
|
||
retries = op.Retries
|
||
}
|
||
// COALESCE, а не CASE: NULL тут і є «не чіпати», і кожне поле
|
||
// відповідає на це питання окремо — рівно як решта масової
|
||
// форми відповідає на нього окремо по кожному полю хоста.
|
||
rows, err = tx.Query(ctx, `
|
||
UPDATE core.checks SET
|
||
interval_sec = COALESCE($4::int, interval_sec),
|
||
timeout_ms = COALESCE($5::int, timeout_ms),
|
||
retries = COALESCE($6::int, retries),
|
||
enabled = COALESCE($7::boolean, enabled),
|
||
updated_at = now()
|
||
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[])
|
||
AND check_type = $3
|
||
AND interface_id IS NULL AND template_id IS NULL
|
||
RETURNING device_id::text
|
||
`, tenantID, nonNilIDs(ids), op.CheckType, interval, timeout, retries, op.Enabled)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
touched := map[string]bool{}
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
touched[id] = true
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
reason := "такої ручної перевірки немає"
|
||
for _, d := range devices {
|
||
if touched[d.ID] {
|
||
res.Done = append(res.Done, d.Name)
|
||
} else {
|
||
res.Skipped = append(res.Skipped, BulkCheckOutcome{Device: d.Name, Reason: reason})
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// clampInterval тримає інтервал у межах CHECK-обмеження core.checks.
|
||
//
|
||
// Обрізаємо так само, як SetDeviceChecks: масова форма не має падати
|
||
// там, де одинична мовчки виправляє, — інакше та сама цифра в двох
|
||
// місцях означала б різне.
|
||
func clampInterval(v int) int {
|
||
if v < 5 {
|
||
return 60
|
||
}
|
||
if v > 86400 {
|
||
return 86400
|
||
}
|
||
return v
|
||
}
|