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

404 lines
19 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 (
"math"
"testing"
)
// Перевіряються МЕЖІ прогнозу, а не його середина.
//
// Середина працює сама: вільного 100 ГБ, росте на 10 ГБ за добу — десять
// діб, і помилитись тут ніде. Коштує ж не середина, а краї, і кожен із
// них настає рівно в найгіршу мить:
//
// нуль спостережень — перша година життя інсталяції, коли ряд ще
// порожній, а диск уже може бути майже повним (розгорнули з дампа);
// одне спостереження — друга година, коли ряд уже не порожній, але
// різниця країв ділиться на нуль;
// від'ємний приріст — після прибирання або видалення хоста; прогноз
// сліпне, і саме тоді хочеться, щоб він мовчав, а не показував
// дату переповнення в минулому;
// нульовий приріст — найтихіший стан, у якому ділення дає +Inf, а
// +Inf ламає не прогноз, а серіалізацію алерту в JSON, тобто
// ВЕСЬ список алертів.
//
// Живої бази ці перевірки не потребують — і не тому, що так зручніше.
// Жоден із цих станів на живій базі не відтворити на замовлення: щоб
// побачити нуль спостережень, треба зловити першу годину інсталяції.
// Якби вирок жив у SQL, перевірити його можна було б лише сподіванням.
// overview — мінімальний набір чисел, на які дивиться вирок.
func overview(free *int64, total int64, perDay float64, observed float64) *StorageOverview {
return &StorageOverview{
DiskTotalBytes: total,
FreeBytes: free,
PerDayBytes: perDay,
ObservedDays: observed,
WarnPct: 80,
}
}
func bytes(n int64) *int64 { return &n }
const (
gb = int64(1) << 30
tb = int64(1) << 40
)
var defaultAlert = StorageAlertConfig{Enabled: true, DaysWarn: 21, DaysCrit: 4}
// ---------------------------------------------------------------------
// Межі прогнозу
// ---------------------------------------------------------------------
func TestStorageVerdictForecastEdges(t *testing.T) {
cases := []struct {
name string
o *StorageOverview
level string
blind string
}{
{
// Ряд порожній: жодного знімка ще не знято. storageGrowth
// поверне нуль і в приріст, і в спостереження — тобто рівно
// те саме, що «база не росте». Розрізняє їх лише
// ObservedDays, і якщо не розрізнити, свіжа інсталяція
// доповідає «усе гаразд» замість «нічим міряти».
name: "нуль спостережень — прогнозу немає, але й тиші немає",
o: overview(bytes(90*gb), 100*gb, 0, 0),
level: "",
blind: "no_observations",
},
{
// Один знімок: ряд не порожній, різниця країв ділиться на
// нульовий проміжок. 0064 відсікає це умовою span >= 0.5 у
// SQL; тут перевіряється, що вирок не вважає нуль
// приростом, який просто «дуже малий».
name: "одне спостереження — проміжок нульовий",
o: overview(bytes(90*gb), 100*gb, 0, 0),
level: "",
blind: "no_observations",
},
{
// Пів доби вистачає СТОРІНЦІ (0064 показує приріст із 0.5),
// але не вистачає, щоб будити людину: нічний прохід згорток
// на такому вікні читається як стала швидкість.
name: "спостережень менше за поріг довіри",
o: overview(bytes(90*gb), 100*gb, 40*float64(gb), 0.7),
level: "",
blind: "no_observations",
},
{
name: "спостережень рівно поріг — прогноз уже рахується",
o: overview(bytes(90*gb), 100*gb, 40*float64(gb), storageForecastMinDays),
level: "disaster",
blind: "",
},
{
// Базу почистили: приріст за вікном від'ємний. Ділення дало
// б від'ємний запас — «місце закінчилось позавчора».
name: "від'ємний приріст — база зменшилась",
o: overview(bytes(90*gb), 100*gb, -3*float64(gb), 30),
level: "",
blind: "not_growing",
},
{
// Рівно нуль. Найтихіший стан і найнебезпечніший для коду:
// ділення дає +Inf, і ламається не прогноз, а JSON усього
// списку алертів.
name: "нульовий приріст — ділення на нуль",
o: overview(bytes(90*gb), 100*gb, 0, 30),
level: "",
blind: "not_growing",
},
{
// Ємності не знає ніхто: ані шляху до тому, ані числа від
// людини. Тут немає чисельника, і жодна з трьох умов не має
// сенсу — включно з відсотком.
name: "ємності тому не знає ніхто",
o: overview(nil, 0, 40*float64(gb), 30),
level: "",
blind: "no_capacity",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := storageVerdict(c.o, defaultAlert)
if got.Level != c.level {
t.Fatalf("рівень: очікували %q, отримали %q (%s)", c.level, got.Level, got.Message)
}
if got.Blind != c.blind {
t.Fatalf("сліпота: очікували %q, отримали %q", c.blind, got.Blind)
}
})
}
}
// Без вільного місця вирок не має права сказати нічого — жодним шляхом.
//
// Окремою перевіркою, а не рядком у таблиці вище: це не випадок, а
// властивість. Умов три, і кожна з них ділить або порівнює з вільним
// місцем; варто комусь додати четверту, яка «і так зрозуміла без
// ємності», — і сторінка почне будити людей числом, узятим зі стелі.
func TestStorageVerdictNeverSpeaksWithoutCapacity(t *testing.T) {
for _, perDay := range []float64{-1e12, 0, 1, 1e15} {
for _, observed := range []float64{0, 0.5, 30, 400} {
o := overview(nil, 0, perDay, observed)
o.MaxWalBytes = gb
got := storageVerdict(o, defaultAlert)
if got.Level != "" {
t.Fatalf("приріст %v, спостережень %v: вирок %q без відомої ємності",
perDay, observed, got.Level)
}
if got.Blind != "no_capacity" {
t.Fatalf("приріст %v, спостережень %v: сліпота %q замість no_capacity",
perDay, observed, got.Blind)
}
}
}
}
// ---------------------------------------------------------------------
// Пороги
// ---------------------------------------------------------------------
func TestStorageVerdictThresholds(t *testing.T) {
// Вільного 21 ГБ, росте на 1 ГБ за добу — тобто рівно 21 доба, рівно
// поріг попередження. Межа перевіряється з обох боків, бо «менше»
// й «не більше» тут різняться цілим станом дошки алертів.
cases := []struct {
name string
free int64
day float64
level string
}{
{"запас рівно поріг попередження — ще тихо", 21 * gb, float64(gb), ""},
{"запас на волосину менший — попередження", 21*gb - 1, float64(gb), "warning"},
{"запас рівно аварійний поріг — ще попередження", 4 * gb, float64(gb), "warning"},
{"запас на волосину менший за аварійний — аварія", 4*gb - 1, float64(gb), "disaster"},
{"вільного нуль — аварія, а не ділення на нуль", 0, float64(gb), "disaster"},
{"запас на роки — тихо", 4000 * gb, float64(gb), ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Том навмисно лише трохи більший за вільне місце: тоді
// зайнято мало, поріг за РІВНЕМ мовчить і не підміняє
// собою те, що тут перевіряється.
o := overview(bytes(c.free), c.free+5*gb, c.day, 30)
got := storageVerdict(o, defaultAlert)
if got.Level != c.level {
t.Fatalf("очікували %q, отримали %q (%s)", c.level, got.Level, got.Message)
}
if c.level != "" && got.Reason != "days" {
t.Fatalf("причина: очікували days, отримали %q", got.Reason)
}
})
}
}
// ---------------------------------------------------------------------
// Рівень зайнятого як покриття сліпої зони
// ---------------------------------------------------------------------
// Стрибок після видалення хоста — найдорожчий зі станів.
//
// Видалили дільницю на сорок комутаторів, drop_chunks повернув місце,
// і різниця країв за вікном спостережень стала від'ємною. Прогноз
// мовчить — і мовчатиме, поки видалення не вийде за вікно, тобто до
// тридцяти діб. База при цьому росте як росла.
//
// Саме заради цієї доби й існує поріг за рівнем: він не потребує
// історії взагалі.
func TestStorageVerdictLevelCoversBlindForecast(t *testing.T) {
// Том 100 ГБ, вільного 8 — зайнято 92 %, поріг 80.
o := overview(bytes(8*gb), 100*gb, -12*float64(gb), 30)
got := storageVerdict(o, defaultAlert)
if got.Level != "warning" {
t.Fatalf("очікували warning від порога за рівнем, отримали %q", got.Level)
}
if got.Reason != "level" {
t.Fatalf("причина: очікували level, отримали %q", got.Reason)
}
// Сліпота прогнозу має лишитись видимою: інакше людина прочитає
// «зайнято 92 %» і не дізнається, що дати переповнення їй не
// скажуть.
if got.Blind != "not_growing" {
t.Fatalf("сліпота прогнозу загубилась: %q", got.Blind)
}
}
func TestStorageVerdictLevelStaysQuietBelowThreshold(t *testing.T) {
// Зайнято рівно 80 % при порозі 80 — межа має спрацьовувати, бо
// саме її вводить людина як «уже пора».
o := overview(bytes(20*gb), 100*gb, 0, 30)
if got := storageVerdict(o, defaultAlert); got.Level != "warning" {
t.Fatalf("рівно поріг: очікували warning, отримали %q", got.Level)
}
// На волосину менше — тихо.
o = overview(bytes(20*gb+1), 100*gb, 0, 30)
if got := storageVerdict(o, defaultAlert); got.Level != "" {
t.Fatalf("нижче порога: очікували тишу, отримали %q (%s)", got.Level, got.Message)
}
}
// Прогноз головніший за рівень: коли обидва спрацювали, у тексті має
// бути дата, а не відсоток.
//
// Це не смак. «Зайнято 92 %» не каже, що робити; «вистачить на 2 доби»
// каже, що робити це треба сьогодні.
func TestStorageVerdictForecastWinsOverLevel(t *testing.T) {
o := overview(bytes(2*gb), 100*gb, float64(gb), 30) // 2 доби, зайнято 98 %
got := storageVerdict(o, defaultAlert)
if got.Reason != "days" {
t.Fatalf("причина: очікували days, отримали %q", got.Reason)
}
if got.Level != "disaster" {
t.Fatalf("рівень: очікували disaster, отримали %q", got.Level)
}
}
// ---------------------------------------------------------------------
// Підлога WAL
// ---------------------------------------------------------------------
func TestStorageVerdictWalFloor(t *testing.T) {
// Том на 1 ГБ, зайнято половину, росте на мегабайт за добу — за
// прогнозом лишилось понад рік, за рівнем зайнято 50 %. Але вільного
// менше, ніж Postgres має право написати в журнал між контрольними
// точками, тобто записи можуть стати вже сьогодні.
o := overview(bytes(512*1024*1024), gb, float64(1<<20), 30)
o.MaxWalBytes = gb
got := storageVerdict(o, defaultAlert)
if got.Level != "disaster" || got.Reason != "wal" {
t.Fatalf("очікували disaster/wal, отримали %q/%q (%s)", got.Level, got.Reason, got.Message)
}
}
func TestStorageVerdictWalFloorSilentWhenUnknown(t *testing.T) {
// max_wal_size прочитати не дали (0 — «не знаємо»). Вигадувати
// підлогу не можна: вигадана підлога зробила б аварію з нормального
// стану на кожній інсталяції з маленьким томом.
o := overview(bytes(512*1024*1024), gb, float64(1<<20), 30)
o.MaxWalBytes = 0
if got := storageVerdict(o, defaultAlert); got.Level != "" {
t.Fatalf("без max_wal_size очікували тишу, отримали %q (%s)", got.Level, got.Message)
}
}
// ---------------------------------------------------------------------
// Ділення, яке не має статись
// ---------------------------------------------------------------------
// Прогноз не має права повернути нескінченність або NaN.
//
// Ламається від цього не прогноз: DaysLeft їде в JSON списку алертів і
// сторінки сховища, а encoding/json відмовляється серіалізувати +Inf
// узагалі. Тобто одна нескінченність тут кладе не одну цифру, а всю
// відповідь API — і саме в тій інсталяції, де база перестала рости.
func TestStorageDaysLeftNeverInfinite(t *testing.T) {
cases := []struct {
name string
free *int64
day float64
want bool // чи має бути число
}{
{"нема вільного місця — нема числа", nil, float64(gb), false},
{"нульовий приріст — нема числа, а не +Inf", bytes(10 * gb), 0, false},
{"від'ємний приріст — нема числа, а не мінус", bytes(10 * gb), -float64(gb), false},
{"NaN у прирості — нема числа", bytes(10 * gb), math.NaN(), false},
{"вільного нуль, приріст є — нуль діб, це число", bytes(0), float64(gb), true},
{"звичайний випадок", bytes(10 * gb), float64(gb), true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := storageDaysLeft(c.free, c.day)
if (got != nil) != c.want {
t.Fatalf("очікували наявність числа=%v, отримали %v", c.want, got)
}
if got != nil && (math.IsInf(*got, 0) || math.IsNaN(*got) || *got < 0) {
t.Fatalf("непридатне число: %v", *got)
}
})
}
}
// ---------------------------------------------------------------------
// Вимикач
// ---------------------------------------------------------------------
// Вимкнене попередження не заважає сторінці рахувати.
//
// Різниця тонка й важлива: вимикач керує тим, чи піднімати алерт, а не
// тим, чи знати відповідь. Людина, яка вимкнула сповіщення, все одно
// має бачити на сторінці, що місця лишилось на два дні.
func TestStorageVerdictDisabledStillComputes(t *testing.T) {
off := StorageAlertConfig{Enabled: false, DaysWarn: 21, DaysCrit: 4}
o := overview(bytes(90*gb), 100*gb, 40*float64(gb), 30)
got := storageVerdict(o, off)
if got.Level != "disaster" {
t.Fatalf("вимкнене попередження змінило вирок: %q", got.Level)
}
if got.Enabled {
t.Fatal("вимикач загубився у вироку")
}
}
// ---------------------------------------------------------------------
// Числа словами
// ---------------------------------------------------------------------
// Текст їде в Telegram і в пошту, тобто туди, де його читає людина, яку
// щойно розбудили. «21 діб» у такому повідомленні коштує довіри до
// всього повідомлення.
func TestHumanDays(t *testing.T) {
cases := map[float64]string{
0: "менш ніж добу",
0.4: "менш ніж добу",
1: "1 добу",
2: "2 доби",
4: "4 доби",
5: "5 діб",
11: "11 діб",
12: "12 діб",
14: "14 діб",
21: "21 добу",
22: "22 доби",
25: "25 діб",
101: "101 добу",
111: "111 діб",
3.99: "3 доби",
}
for in, want := range cases {
if got := humanDays(in); got != want {
t.Errorf("humanDays(%v) = %q, очікували %q", in, got, want)
}
}
}
func TestHumanBytes(t *testing.T) {
cases := map[int64]string{
0: "0 Б",
512: "512 Б",
1024: "1.0 КБ",
1536: "1.5 КБ",
gb: "1.0 ГБ",
tb: "1.0 ТБ",
1024 * tb: "1024.0 ТБ", // вище ТБ не піднімаємось свідомо
-3 * 1024: "3.0 КБ",
100*gb + gb/2: "100.5 ГБ",
int64(1) << 62: "4194304.0 ТБ",
}
for in, want := range cases {
if got := humanBytes(in); got != want {
t.Errorf("humanBytes(%d) = %q, очікували %q", in, got, want)
}
}
}