Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
278 lines
10 KiB
Go
278 lines
10 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// BackupPolicy — розклад бекапу одного хоста.
|
||
type BackupPolicy struct {
|
||
DeviceID string `json:"device_id"`
|
||
ProfileID string `json:"profile_id,omitempty"`
|
||
CredentialID string `json:"credential_id,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
Cron string `json:"cron"`
|
||
OnSyslog bool `json:"on_syslog"`
|
||
// Чи слідує хост глобальному розкладу. Прапорець, а не порівняння
|
||
// значень: власний розклад може випадково збігтися з глобальним.
|
||
FollowsDefault bool `json:"follows_default"`
|
||
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
|
||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||
}
|
||
|
||
// DuePolicy — політика, якій настав час.
|
||
type DuePolicy struct {
|
||
TenantID string
|
||
DeviceID string
|
||
Cron string
|
||
}
|
||
|
||
// DuePolicies повертає розклади, час яких настав.
|
||
//
|
||
// Порожній next_backup_at теж вважається настанням: так виглядає щойно
|
||
// створена політика, і чекати на неї добу означало б не зробити перший
|
||
// бекап тоді, коли він найпотрібніший.
|
||
func (s *Store) DuePolicies(ctx context.Context, limit int) ([]DuePolicy, error) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
rows, err := s.bg.Query(ctx, `
|
||
SELECT p.tenant_id::text, p.device_id::text, p.cron
|
||
FROM ncm.device_policies p
|
||
JOIN inv.devices d ON d.id = p.device_id
|
||
JOIN core.tenants t ON t.id = p.tenant_id
|
||
WHERE p.enabled
|
||
AND d.enabled AND d.deleted_at IS NULL
|
||
AND t.status NOT IN ('suspended','cancelled')
|
||
AND (p.next_backup_at IS NULL OR p.next_backup_at <= now())
|
||
ORDER BY p.next_backup_at NULLS FIRST
|
||
LIMIT $1
|
||
`, limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []DuePolicy
|
||
for rows.Next() {
|
||
var d DuePolicy
|
||
if err := rows.Scan(&d.TenantID, &d.DeviceID, &d.Cron); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, d)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// SetNextBackup переносить політику на наступний запуск.
|
||
//
|
||
// Викликається одразу після постановки завдання в чергу, а не після
|
||
// його виконання: інакше хост, до якого зонд не достукався, лишався б
|
||
// «простроченим» і породжував нове завдання щотіку.
|
||
func (s *Store) SetNextBackup(ctx context.Context, deviceID string, next time.Time) error {
|
||
_, err := s.bg.Exec(ctx, `
|
||
UPDATE ncm.device_policies SET next_backup_at = $2 WHERE device_id = $1
|
||
`, deviceID, next)
|
||
return err
|
||
}
|
||
|
||
// GetBackupPolicy читає політику хоста.
|
||
func (s *Store) GetBackupPolicy(ctx context.Context, tenantID, deviceID string) (*BackupPolicy, error) {
|
||
var p BackupPolicy
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT device_id::text, COALESCE(profile_id::text,''),
|
||
COALESCE(credential_id::text,''), enabled, cron, on_syslog,
|
||
follows_default, last_backup_at, next_backup_at
|
||
FROM ncm.device_policies
|
||
WHERE device_id = $1 AND tenant_id = $2
|
||
`, deviceID, tenantID).Scan(&p.DeviceID, &p.ProfileID, &p.CredentialID,
|
||
&p.Enabled, &p.Cron, &p.OnSyslog, &p.FollowsDefault,
|
||
&p.LastBackupAt, &p.NextBackupAt)
|
||
})
|
||
if err != nil {
|
||
if isNoRows(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// SetBackupPolicy створює або оновлює політику.
|
||
//
|
||
// next_backup_at обнуляється при кожній зміні: розклад міг стати
|
||
// частішим, і лишити стару позначку означало б чекати за старим
|
||
// розкладом до першого спрацювання нового.
|
||
func (s *Store) SetBackupPolicy(ctx context.Context, tenantID string, p BackupPolicy) error {
|
||
return 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)
|
||
`, p.DeviceID, tenantID).Scan(&owned); err != nil {
|
||
return err
|
||
}
|
||
if !owned {
|
||
return ErrNotFound
|
||
}
|
||
|
||
_, 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)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL)
|
||
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()
|
||
`, p.DeviceID, tenantID, nullUUID(p.ProfileID), nullUUID(p.CredentialID),
|
||
p.Enabled, p.Cron, p.OnSyslog, p.FollowsDefault)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ListProfiles — довідник профілів для форми.
|
||
func (s *Store) ListProfiles(ctx context.Context, tenantID string) ([]map[string]any, error) {
|
||
var out []map[string]any
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, key, name, COALESCE(vendor,''), is_builtin
|
||
FROM ncm.profiles
|
||
WHERE tenant_id IS NULL OR tenant_id = $1
|
||
ORDER BY vendor, name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var id, key, name, vendor string
|
||
var builtin bool
|
||
if err := rows.Scan(&id, &key, &name, &vendor, &builtin); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, map[string]any{
|
||
"id": id, "key": key, "name": name,
|
||
"vendor": vendor, "is_builtin": builtin,
|
||
})
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func isNoRows(err error) bool {
|
||
return err != nil && err.Error() == pgx.ErrNoRows.Error()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Глобальний розклад
|
||
// ---------------------------------------------------------------------
|
||
|
||
// BackupDefaults — розклад за замовчуванням для всього тенанта.
|
||
type BackupDefaults struct {
|
||
Enabled bool `json:"enabled"`
|
||
Cron string `json:"cron"`
|
||
ProfileID string `json:"profile_id,omitempty"`
|
||
CredentialID string `json:"credential_id,omitempty"`
|
||
// Скільки хостів зараз слідує глобальному розкладу, а скільки має
|
||
// власний. Без цих двох чисел форма не каже головного: кого саме
|
||
// зачепить зміна.
|
||
FollowingCount int `json:"following_count"`
|
||
CustomCount int `json:"custom_count"`
|
||
}
|
||
|
||
// GetBackupDefaults читає глобальний розклад разом із лічильниками.
|
||
func (s *Store) GetBackupDefaults(ctx context.Context, tenantID string) (*BackupDefaults, error) {
|
||
d := &BackupDefaults{Cron: "0 3 * * *"}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT enabled, cron, COALESCE(profile_id::text,''), COALESCE(credential_id::text,'')
|
||
FROM ncm.backup_defaults WHERE tenant_id = $1
|
||
`, tenantID).Scan(&d.Enabled, &d.Cron, &d.ProfileID, &d.CredentialID)
|
||
if err != nil && !isNoRows(err) {
|
||
return err
|
||
}
|
||
return tx.QueryRow(ctx, `
|
||
SELECT count(*) FILTER (WHERE follows_default),
|
||
count(*) FILTER (WHERE NOT follows_default)
|
||
FROM ncm.device_policies WHERE tenant_id = $1
|
||
`, tenantID).Scan(&d.FollowingCount, &d.CustomCount)
|
||
})
|
||
return d, err
|
||
}
|
||
|
||
// SetBackupDefaults записує глобальний розклад і протягує його на всі
|
||
// хости, що йому слідують.
|
||
//
|
||
// applyToAll підпорядковує глобальному розкладу навіть ті хости, які
|
||
// налаштували окремо. Це руйнівна дія, тому вона окремим прапорцем, а не
|
||
// побічним ефектом збереження.
|
||
func (s *Store) SetBackupDefaults(ctx context.Context, tenantID string, d BackupDefaults, applyToAll bool) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO ncm.backup_defaults
|
||
(tenant_id, enabled, cron, profile_id, credential_id)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||
enabled = EXCLUDED.enabled, cron = EXCLUDED.cron,
|
||
profile_id = EXCLUDED.profile_id,
|
||
credential_id = EXCLUDED.credential_id,
|
||
updated_at = now()
|
||
`, tenantID, d.Enabled, d.Cron, nullUUID(d.ProfileID), nullUUID(d.CredentialID)); err != nil {
|
||
return err
|
||
}
|
||
|
||
if applyToAll {
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE ncm.device_policies SET follows_default = true
|
||
WHERE tenant_id = $1
|
||
`, tenantID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
if !d.Enabled {
|
||
// Вимкнений глобальний розклад зупиняє лише тих, хто йому
|
||
// слідує. Хост із власним розкладом налаштували свідомо, і
|
||
// глобальний вимикач не має його чіпати.
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE ncm.device_policies
|
||
SET enabled = false, next_backup_at = NULL, updated_at = now()
|
||
WHERE tenant_id = $1 AND follows_default
|
||
`, tenantID)
|
||
return err
|
||
}
|
||
|
||
// Заводимо політику кожному придатному хосту, який її ще не має:
|
||
// глобальний розклад інакше не мав би де зберігати next_backup_at.
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO ncm.device_policies
|
||
(device_id, tenant_id, enabled, cron, follows_default)
|
||
SELECT d.id, $1, true, $2, true
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = $1 AND d.enabled AND d.deleted_at IS NULL
|
||
ON CONFLICT (device_id) DO NOTHING
|
||
`, tenantID, d.Cron); err != nil {
|
||
return err
|
||
}
|
||
|
||
// next_backup_at обнуляємо: розклад міг стати частішим.
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE ncm.device_policies
|
||
SET enabled = true, cron = $2,
|
||
profile_id = $3, credential_id = $4,
|
||
next_backup_at = NULL, updated_at = now()
|
||
WHERE tenant_id = $1 AND follows_default
|
||
`, tenantID, d.Cron, nullUUID(d.ProfileID), nullUUID(d.CredentialID))
|
||
return err
|
||
})
|
||
}
|