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

477 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
)
// Попередження про вичерпання місця.
//
// ЧОМУ ЦЕ НЕ ПРАВИЛО МОНІТОРИНГУ
//
// Найдешевший на вигляд шлях — писати розмір бази й запас у добах
// звичайними метриками в ts.series й дати наявному метричному правилу
// працювати як є. Він перевірений і не працює; повний розбір — у
// коментарі до 0067_storage_alert.sql. Коротко: метричне правило
// обчислюється запитом із JOIN inv.devices, тобто вимагає ХОСТА, а
// хоста для тому бази не існує; штучний хост коштував би слота тарифу
// (bill.assert_device_limit) і сам підпав би під наявні правила «даних
// немає взагалі»; правила живуть у кабінеті, а том — в інсталяції; і
// головне — «лишилось N діб» не вимір, а частка, знаменник якої буває
// нулем і від'ємним, а ts.samples.value не вміє мовчати.
//
// Тому перевірка живе тут, у тому ж такті, що знімає розміри, і
// спирається рівно на ті самі числа, які показує сторінка. Одне число —
// одне значення: розходження між «сторінка каже 40 діб» і «алерт каже
// 12» не пояснити нікому, а виникло б воно від будь-якої другої
// формули.
//
// ЩО САМЕ ПЕРЕВІРЯЄТЬСЯ
//
// Три умови, і порядок між ними не косметичний.
//
// ЗАПАС У ДОБАХ — головна. Вона єдина знає ШВИДКІСТЬ. «Зайнято 85 %»
// на томі, що росте на 0,1 % за добу, — це ще пів року; ті самі
// 85 % при 3 % за добу — це п'ятниця. Одне й те саме число означає
// протилежні речі, і тільки нахил їх розрізняє.
//
// РІВЕНЬ ЗАЙНЯТОГО — не запасний варіант, а покриття сліпої зони.
// Прогноз мовчить рівно тоді, коли швидкості немає: спостережень ще
// не набралось, або приріст нульовий, або від'ємний — базу почистили
// чи видалили хост. Останній випадок найгірший: одне видалення
// робить приріст від'ємним на все вікно спостережень, і прогноз
// сліпне на місяць, поки база тим часом росте як росла. Рівень
// бачить це без будь-якої історії.
//
// МІСЦЯ МЕНШЕ ЗА max_wal_size — підлога, нижче якої відсотки
// безглузді. Postgres між контрольними точками має право написати
// до max_wal_size журналу; якщо стільки не влазить, він зупиняється
// незалежно від того, 90 це відсотків чи 99. Число береться з
// налаштувань самого сервера, а не з нашого уявлення про запас.
// StorageAlertConfig — як налаштоване попередження.
type StorageAlertConfig struct {
Enabled bool `json:"enabled"`
DaysWarn int `json:"days_warn"`
DaysCrit int `json:"days_crit"`
}
// StorageAlertState — те, що сторінка показує про попередження: і як
// воно налаштоване, і що воно сказало б прямо зараз.
type StorageAlertState struct {
StorageAlertConfig
// Level — вирок на цю мить: "" (тихо), "warning" або "disaster".
Level string `json:"level"`
// Reason — що саме спрацювало: days | level | wal.
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
// Blind — чому вирок зараз ні на що не спирається. Порожньо —
// спирається. Це поле важливіше за Level: «тихо» й «нічим міряти»
// на екрані виглядають однаково, а означають протилежне.
Blind string `json:"blind,omitempty"`
// FiringSince — коли алерт піднято; nil — не піднято зараз.
FiringSince *time.Time `json:"firing_since,omitempty"`
}
// storageDedupKey — ключ, за яким алерт вважається тим самим.
//
// Стала, а не похідна від чогось: алерт про том рівно один на кабінет,
// і будь-яка змінна складова в ключі означала б новий алерт щоразу, коли
// число трохи змінилось, тобто нове сповіщення щогодини.
const storageDedupKey = "storage:volume"
// storageForecastMinDays — скільки діб спостережень має бути, щоб
// прогнозу можна було вірити настільки, щоб на нього будити людину.
//
// Дві. Знімок береться щогодини, і за одну добу різниця країв ловить
// повний добовий цикл — нічні згортки, добовий збір конфігів, вивантаження
// дампа. Друга доба потрібна, щоб цей цикл повторився: інакше «плюс 2 ГБ»
// від одного нічного проходу читається як стала швидкість.
//
// Сторінка 0064 показує приріст уже з пів доби, і це правильно: вона
// довідка, її читає людина, яка бачить поруч «за 0,6 діб спостережень».
// Будити на такій підставі не можна.
const storageForecastMinDays = 2.0
// storageVerdict — чистий вирок за числами.
//
// Окремою функцією без бази навмисно, і не заради стилю: усі цікаві
// випадки тут — межі, яких на живій базі не відтворити. Нуль
// спостережень буває раз на інсталяцію в першу годину; від'ємний
// приріст — після прибирання, якого не замовиш; ділення на нуль — рівно
// тоді, коли база не росте, тобто в найтихішому зі станів. Перевірити їх
// можна лише так.
func storageVerdict(o *StorageOverview, cfg StorageAlertConfig) StorageAlertState {
st := StorageAlertState{StorageAlertConfig: cfg}
// 1. Чи є взагалі знаменник. Без вільного місця не рахується ані
// запас, ані відсоток — жодна з трьох умов не має сенсу.
if o.FreeBytes == nil {
st.Blind = "no_capacity"
return st
}
free := *o.FreeBytes
// 2. Підлога WAL. Перевіряється першою, бо вона єдина не залежить ні
// від історії, ні від налаштувань людини: це вимога самого Postgres
// до себе.
if o.MaxWalBytes > 0 && free < o.MaxWalBytes {
st.Level, st.Reason = "disaster", "wal"
st.Message = fmt.Sprintf(
"На томі лишилось %s — менше, ніж %s, які Postgres має право написати "+
"в журнал між контрольними точками (max_wal_size). Записи можуть "+
"зупинитись раніше, ніж том заповниться повністю.",
humanBytes(free), humanBytes(o.MaxWalBytes))
return st
}
// 3. Запас у добах — головна умова.
//
// Три причини не порахувати, і кожна називається своїм словом.
// Спільне «—» тут було б найдорожчою економією в цьому файлі:
// «прогноз мовчить» і «прогноз каже, що все гаразд» на екрані
// виглядають однаково.
switch {
case o.ObservedDays < storageForecastMinDays:
st.Blind = "no_observations"
case o.PerDayBytes <= 0:
st.Blind = "not_growing"
default:
days := float64(free) / o.PerDayBytes
switch {
case days < float64(cfg.DaysCrit):
st.Level, st.Reason = "disaster", "days"
case days < float64(cfg.DaysWarn):
st.Level, st.Reason = "warning", "days"
}
if st.Level != "" {
st.Message = fmt.Sprintf(
"Вільного місця на томі %s, база росте на %s за добу — вистачить "+
"приблизно на %s. Прогноз спирається на %.1f діб спостережень.",
humanBytes(free), humanBytes(int64(math.Round(o.PerDayBytes))),
humanDays(days), o.ObservedDays)
return st
}
}
// 4. Рівень зайнятого. Не «якщо не спрацював прогноз», а завжди:
// рівень може бути аварійним і при повільному зростанні, і саме
// тоді, коли прогноз осліп після прибирання.
if o.DiskTotalBytes > 0 {
used := o.DiskTotalBytes - free
pct := float64(used) / float64(o.DiskTotalBytes) * 100
if pct >= float64(o.WarnPct) {
st.Level, st.Reason = "warning", "level"
st.Message = fmt.Sprintf(
"Зайнято %.1f %% тому на %s, вільного %s — це більше за поріг %d %%.",
pct, humanBytes(o.DiskTotalBytes), humanBytes(free), o.WarnPct)
// Рівень уже щось сказав, тож сліпота прогнозу перестає бути
// причиною мовчання — але лишається поясненням, чому в тексті
// немає дати.
return st
}
}
return st
}
// storageSeverity переводить рівень вироку в серйозність алерту.
//
// warning і disaster, без проміжних. Проміжні рівні тут не описували б
// нічого нового: між «встигнеш замовити диск» і «встигнеш лише
// скоротити строк» немає третього стану, а зайва градація коштує
// маршруту доставки, який ніхто не налаштує.
func storageSeverity(level string) string {
if level == "disaster" {
return "disaster"
}
return "warning"
}
// ---------------------------------------------------------------------
// Читання й збереження налаштувань
// ---------------------------------------------------------------------
// StorageAlertSettings читає налаштування попередження.
func (s *Store) StorageAlertSettings(ctx context.Context) (StorageAlertConfig, error) {
var c StorageAlertConfig
err := s.bg.QueryRow(ctx, `
SELECT alert_enabled, alert_days_warn, alert_days_crit
FROM core.storage_config
`).Scan(&c.Enabled, &c.DaysWarn, &c.DaysCrit)
return c, err
}
// SetStorageAlert зберігає налаштування попередження.
//
// Перевірки тут, а не лише в CHECK бази: суперечливі пороги — помилка
// людини у формі, і вона має отримати текст, а не 500 від порушеного
// обмеження. CHECK при цьому лишається — він захищає від запису повз
// цю функцію, а не від людини.
// dataPath — вказівник: nil означає «не чіпати шлях», порожній рядок —
// «прибрати». Дві різні дії, і злити їх в одну не можна: шлях
// прибирають рідко, а зберігають форму часто.
func (s *Store) SetStorageAlert(ctx context.Context, c StorageAlertConfig, dataPath *string, userID string) error {
if c.DaysWarn < 2 || c.DaysWarn > 365 {
return ErrInvalid
}
if c.DaysCrit < 1 || c.DaysCrit > 90 {
return ErrInvalid
}
if c.DaysCrit > c.DaysWarn {
return ErrInvalid
}
setPath := dataPath != nil
path := ""
if setPath {
path = *dataPath
}
_, err := s.bg.Exec(ctx, `
UPDATE core.storage_config
SET alert_enabled = $1,
alert_days_warn = $2,
alert_days_crit = $3,
data_path = CASE WHEN $4::boolean THEN $5::text ELSE data_path END,
updated_at = now(),
updated_by = $6
WHERE id
`, c.Enabled, c.DaysWarn, c.DaysCrit, setPath, nullString(path), nullUUID(userID))
return err
}
// ---------------------------------------------------------------------
// Підняття й закриття
// ---------------------------------------------------------------------
// CheckStorageAlert обчислює вирок і приводить алерти у відповідність
// до нього.
//
// Повертає сам вирок — щоб такт міг написати в журнал те саме, що
// побачить людина, а не свій переказ.
func (s *Store) CheckStorageAlert(ctx context.Context) (StorageAlertState, error) {
o, err := s.StorageUsage(ctx)
if err != nil {
return StorageAlertState{}, err
}
st := o.Alert
if !st.Enabled {
// Вимкнене попередження має ще й прибрати за собою: інакше
// алерт, піднятий до вимкнення, лишився б на дошці назавжди —
// закрити його не буде кому.
if _, err := s.resolveStorageAlerts(ctx); err != nil {
return st, err
}
return st, nil
}
if st.Level == "" {
if _, err := s.resolveStorageAlerts(ctx); err != nil {
return st, err
}
return st, nil
}
return st, s.raiseStorageAlert(ctx, o, st)
}
// raiseStorageAlert піднімає алерт у кожному чинному кабінеті.
//
// У КОЖНОМУ — це рішення, а не недогляд. Том один на інсталяцію, а
// алерти живуть у кабінетах; коли база стане, зупиниться моніторинг
// усіх, тож дізнатись про це має кожен, у кого він є. У коробковій
// поставці кабінет один і питання не виникає зовсім; на спільному
// хостингу ціна — по одному сповіщенню на клієнта, і вона менша за
// ціну клієнта, який дізнався про зупинку моніторингу від своєї
// мережі.
//
// Вікна обслуговування свідомо не питаються. Вони існують, щоб не
// будити людину через те, що вона сама вимкнула, — а том від
// оголошеного вікна наповнюватись не перестає. До того ж алерт тут один
// і дедуплікований: він потурбує рівно раз, а не щогодини.
func (s *Store) raiseStorageAlert(ctx context.Context, o *StorageOverview, st StorageAlertState) error {
tenants, err := s.activeTenantIDs(ctx)
if err != nil {
return err
}
if len(tenants) == 0 {
return nil
}
sev := storageSeverity(st.Level)
title := "Сховище: місце на томі бази закінчується"
if st.Level == "disaster" {
title = "Сховище: місце на томі бази ось-ось закінчиться"
}
meta := map[string]any{
"reason": st.Reason,
"free_bytes": o.FreeBytes,
"total_bytes": o.DiskTotalBytes,
"per_day_bytes": o.PerDayBytes,
"observed_days": o.ObservedDays,
"source": o.FreeSource,
}
if o.DaysLeft != nil {
meta["days_left"] = *o.DaysLeft
}
ctxJSON, err := json.Marshal(meta)
if err != nil {
return err
}
var value *float64
if o.DaysLeft != nil {
value = o.DaysLeft
}
for _, tenantID := range tenants {
// notify_pending виставляється лише коли є про що сповіщати
// заново: перша поява або зростання серйозності. Інакше
// щогодинний такт слав би те саме повідомлення двадцять чотири
// рази на добу — і його вимкнули б на другу.
//
// Порівняння серйозностей робить сам Postgres: alr.severity —
// перелік, і порядок у ньому той самий, що й у голові
// (info < warning < average < high < disaster).
if _, err := s.bg.Exec(ctx, `
INSERT INTO alr.alerts
(tenant_id, rule_id, device_id, severity, state, title, message,
dedup_key, value, context, notify_pending, started_at, last_seen_at)
VALUES ($1, NULL, NULL, $2::alr.severity, 'firing', $3, $4,
$5, $6, $7::jsonb, true, now(), now())
ON CONFLICT (tenant_id, dedup_key)
WHERE state IN ('firing','acknowledged','suppressed')
DO UPDATE SET
last_seen_at = now(),
value = EXCLUDED.value,
message = EXCLUDED.message,
title = EXCLUDED.title,
context = EXCLUDED.context,
severity = EXCLUDED.severity,
-- Підтверджений людиною алерт не повертається у firing від
-- того, що проблема триває: ack означає «я знаю».
state = CASE WHEN alr.alerts.state = 'acknowledged'
THEN 'acknowledged' ELSE 'firing' END::alr.alert_state,
notify_pending = alr.alerts.notify_pending
OR EXCLUDED.severity > alr.alerts.severity
`, tenantID, sev, title, st.Message, storageDedupKey, value, string(ctxJSON)); err != nil {
return fmt.Errorf("алерт про сховище (кабінет %s): %w", tenantID, err)
}
}
return nil
}
// resolveStorageAlerts закриває алерт, коли причини більше немає.
//
// Своїм запитом, а не через ResolveMissing: та працює за rule_id, а в
// цього алерту правила немає за побудовою. Тобто без цієї функції він
// не закрився б ніколи — і саме такий алерт навчає людей не вірити
// дошці.
func (s *Store) resolveStorageAlerts(ctx context.Context) (int64, error) {
tag, err := s.bg.Exec(ctx, `
UPDATE alr.alerts
SET state = 'resolved', resolved_at = now(), notify_pending = false
WHERE dedup_key = $1
AND state IN ('firing','acknowledged','suppressed')
`, storageDedupKey)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// activeTenantIDs — кабінети, яким узагалі є сенс щось повідомляти.
//
// Той самий предикат, що й у вибірці правил движка: призупинений або
// скасований кабінет не отримує алертів ні про що інше, і сховище тут
// не виняток.
func (s *Store) activeTenantIDs(ctx context.Context) ([]string, error) {
rows, err := s.bg.Query(ctx, `
SELECT id::text FROM core.tenants
WHERE deleted_at IS NULL AND status NOT IN ('suspended','cancelled')
ORDER BY created_at
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// storageAlertSince — коли алерт про том піднято; nil, якщо не піднято.
//
// По всій інсталяції, а не по кабінету: сторінка сховища теж показує
// інсталяцію цілком, і «у вас тихо, а в сусіда горить» було б для неї
// неможливим станом.
func (s *Store) storageAlertSince(ctx context.Context) *time.Time {
var t *time.Time
if err := s.bg.QueryRow(ctx, `
SELECT min(started_at) FROM alr.alerts
WHERE dedup_key = $1 AND state IN ('firing','acknowledged','suppressed')
`, storageDedupKey).Scan(&t); err != nil {
return nil
}
return t
}
// ---------------------------------------------------------------------
// Числа словами
// ---------------------------------------------------------------------
// humanBytes — розмір для тексту сповіщення.
//
// Своя, а не спільна з клієнтом: цей рядок їде в Telegram і в пошту,
// тобто туди, куди форматування браузера не доїжджає взагалі.
func humanBytes(b int64) string {
neg := ""
if b < 0 {
neg, b = "", -b
}
const unit = 1024
if b < unit {
return fmt.Sprintf("%s%d Б", neg, b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit && exp < 3; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%s%.1f %s", neg, float64(b)/float64(div),
[]string{"КБ", "МБ", "ГБ", "ТБ"}[exp])
}
// humanDays — запас у добах словами.
//
// Менше доби окремим випадком: «0 діб» читається як «даних немає», а
// це рівно протилежне до того, що відбувається.
func humanDays(d float64) string {
if d < 1 {
return "менш ніж добу"
}
n := int(d)
switch {
case n%100 >= 11 && n%100 <= 14:
return fmt.Sprintf("%d діб", n)
case n%10 == 1:
return fmt.Sprintf("%d добу", n)
case n%10 >= 2 && n%10 <= 4:
return fmt.Sprintf("%d доби", n)
default:
return fmt.Sprintf("%d діб", n)
}
}