Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
263 lines
18 KiB
SQL
263 lines
18 KiB
SQL
-- =====================================================================
|
||
-- NetPulse :: 0066_escalations.sql
|
||
-- Ескалація сповіщень: «не підтвердили за 15 хвилин — буди наступного».
|
||
--
|
||
-- ЧОМУ ЦЕ З'ЯВЛЯЄТЬСЯ ЗАРАЗ
|
||
--
|
||
-- Таблиця alr.escalation_policies стоїть у схемі з 0007. Порожня. Коду
|
||
-- під нею немає жодного рядка — ані читання, ані запису. Це та сама
|
||
-- порожня обіцянка, що й тригери на трапи до 0058, тільки гірша: там
|
||
-- людина хоча б бачила правило в переліку й могла помітити нуль
|
||
-- спрацювань. Тут не видно нічого — сутність існує лише в схемі.
|
||
--
|
||
-- Ціна відсутності рахується однією ситуацією. О 02:40 падає ядро,
|
||
-- сповіщення йде в Telegram черговому, черговий спить. Система вважає,
|
||
-- що повідомила: notify_count = 1, у журналі доставки status = 'sent',
|
||
-- на дошці алерт червоний. Формально все спрацювало. Фактично про
|
||
-- аварію дізнаються о 09:00 з дзвінка клієнта. Моніторинг, який
|
||
-- повідомив рівно один раз і замовк, відрізняється від відсутнього
|
||
-- моніторингу лише тим, що в нього є алібі.
|
||
--
|
||
-- Ескалація — це відповідь на питання «а якщо перше повідомлення ніхто
|
||
-- не прочитав». Не «надіслати ще раз тому самому» (це спам, і його
|
||
-- вимикають на другий тиждень), а «через N хвилин мовчання підняти
|
||
-- наступного за списком».
|
||
--
|
||
-- ЩО САМЕ БРАКУЄ В НАЯВНІЙ СХЕМІ
|
||
--
|
||
-- 0007 дала опис драбини: steps, repeat_after_min, max_repeats. Це
|
||
-- НАЛАШТУВАННЯ. Бракує двох інших речей:
|
||
--
|
||
-- 1) СТАНУ — на якій сходинці зараз кожен конкретний алерт і коли в
|
||
-- нього наступна. Тримати це в пам'яті процесу не можна: рестарт
|
||
-- під час викочування нової версії стер би драбину рівно посеред
|
||
-- нічної аварії, і «наступного» не розбудив би ніхто. Стан живе в
|
||
-- alr.alert_escalations.
|
||
--
|
||
-- 2) ЖУРНАЛУ — що і чому було зроблено. alr.notifications фіксує
|
||
-- факт доставки, але не фіксує НЕдоставку за рішенням: «сходинка
|
||
-- не пішла, бо алерт підтвердили о 02:47». Без цього запису на
|
||
-- питання «чому мене розбудили» (і на дзеркальне «чому мене НЕ
|
||
-- розбудили») відповіді немає взагалі. Журнал — alr.escalation_steps.
|
||
--
|
||
-- ДО ЧОГО ПРИВ'ЯЗУЄТЬСЯ ПОЛІТИКА
|
||
--
|
||
-- До ПРАВИЛА (alr.rules.escalation_policy_id), і це свідомий вибір із
|
||
-- трьох можливих.
|
||
--
|
||
-- * До серйозності. Тоді одна драбина накриває всі 'high' у кабінеті.
|
||
-- Але 'high' на тестовому комутаторі й 'high' на ядрі — це різні
|
||
-- люди й різна година ночі, а серйозність у них однакова, бо її
|
||
-- ставить той самий тригер. Прив'язка до серйозності означала б, що
|
||
-- єдиний спосіб розвести їх — завести окремий рівень серйозності,
|
||
-- тобто збрехати про гостроту заради маршрутизації.
|
||
--
|
||
-- * До групи хостів. Тоді той самий комутатор ескалює однаково і
|
||
-- «завантаження порту 91%», і «пристрій не відповідає». Перше може
|
||
-- чекати до ранку, друге — ні.
|
||
--
|
||
-- * До правила. Правило — єдине місце, де «що сталося» і «на яких
|
||
-- хостах» уже вирішені разом. Саме там у 0018 оселилось «куди
|
||
-- слати» (alr.rules.channel_ids) з тим самим міркуванням: Zabbix
|
||
-- розводить тригер і дію над тригером на дві сутності, і в
|
||
-- переважній більшості випадків це одна думка, розірвана надвоє.
|
||
-- «Кого будити, якщо не відповіли» — та сама думка, на поле далі.
|
||
--
|
||
-- Серйозність і група при цьому нікуди не діваються: вони вже є в
|
||
-- самому правилі (severity + selector), тож драбина «для аварій на
|
||
-- магістралі» описується правилом, а не третім механізмом.
|
||
--
|
||
-- Типове значення — NULL, тобто БЕЗ ескалації. Це головна вимога до
|
||
-- цієї міграції: після оновлення жоден кабінет не має отримати ані
|
||
-- одного зайвого повідомлення. Ескалація вмикається руками, свідомо, на
|
||
-- конкретному правилі.
|
||
-- =====================================================================
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- 1. Політики: доводимо наявну таблицю до придатного стану
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- Формат сходинки фіксуємо тут, бо в 0007 він був намічений коментарем
|
||
-- («channels») і жодного разу не використаний. Оскільки таблиця
|
||
-- порожня, це не міграція даних, а перше визначення.
|
||
--
|
||
-- after_min рахується від ПОЧАТКУ алерту, а не від попередньої
|
||
-- сходинки. Людина проектує чергування абсолютними числами: «через 15
|
||
-- хвилин — другий інженер, через 45 — керівник зміни». Відносні
|
||
-- проміжки змушують рахувати в голові, і перша ж вставлена посередині
|
||
-- сходинка мовчки зсуває всі наступні.
|
||
COMMENT ON COLUMN alr.escalation_policies.steps IS
|
||
'Драбина: [{"after_min":15,"channel_ids":["uuid",…]},…]. '
|
||
'after_min — хвилини від початку алерту, строго зростають, перша > 0.';
|
||
|
||
-- Нуль першої сходинки заборонено окремо й навмисно: сходинка «через 0
|
||
-- хвилин» продублювала б звичайне сповіщення, яке вже пішло в ту саму
|
||
-- мить. Людина побачила б два однакові повідомлення й вирішила, що
|
||
-- система заїкається. Перевірку робить сервер (там є текст пояснення),
|
||
-- а тут — лише те, що можна перевірити структурно.
|
||
ALTER TABLE alr.escalation_policies
|
||
ADD CONSTRAINT escalation_steps_is_array CHECK (jsonb_typeof(steps) = 'array'),
|
||
-- Стеля на кількість сходинок. Драбина на сорок сходинок — це не
|
||
-- ескалація, а розсилка по всій компанії з інтервалом; обмеження
|
||
-- тут, щоб її не можна було завести навіть в обхід форми.
|
||
ADD CONSTRAINT escalation_steps_count CHECK (jsonb_array_length(steps) <= 10),
|
||
ADD CONSTRAINT escalation_max_repeats CHECK (max_repeats BETWEEN 0 AND 10),
|
||
ADD CONSTRAINT escalation_repeat_after CHECK (repeat_after_min IS NULL OR repeat_after_min BETWEEN 1 AND 1440);
|
||
|
||
ALTER TABLE alr.escalation_policies
|
||
ADD COLUMN description text,
|
||
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||
|
||
COMMENT ON COLUMN alr.escalation_policies.repeat_after_min IS
|
||
'Через скільки хвилин після вичерпання драбини почати її спочатку; NULL — не повторювати';
|
||
COMMENT ON COLUMN alr.escalation_policies.max_repeats IS
|
||
'Скільки разів дозволено повторити драбину цілком';
|
||
|
||
-- alr.routes.policy_id лишається невикористаним, і це рішення, а не
|
||
-- недогляд. Дві точки, де задається та сама драбина, означають, що на
|
||
-- питання «чому мене розбудили» треба читати обидві — а весь сенс
|
||
-- журналу нижче в тому, щоб відповідь була одна й коротка.
|
||
COMMENT ON COLUMN alr.routes.policy_id IS
|
||
'НЕ ЧИТАЄТЬСЯ. Ескалація прив''язана до правила (alr.rules.escalation_policy_id).';
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- 2. Прив'язка до правила
|
||
-- ---------------------------------------------------------------------
|
||
|
||
ALTER TABLE alr.rules
|
||
ADD COLUMN escalation_policy_id uuid
|
||
REFERENCES alr.escalation_policies(id) ON DELETE SET NULL;
|
||
|
||
COMMENT ON COLUMN alr.rules.escalation_policy_id IS
|
||
'Драбина ескалації для алертів цього правила; NULL — без ескалації (типово)';
|
||
|
||
-- ON DELETE SET NULL, а не RESTRICT: видалена політика має означати
|
||
-- «ескалації більше немає», а не «правило не видаляється». Живі
|
||
-- ескалації, що лишились без політики, зупиняє движок із причиною
|
||
-- «політику видалено» — і це видно в журналі, а не тихо.
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- 3. Стан ескалації: рівно один рядок на алерт
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- Первинний ключ — сам alert_id. Це не економія, а вимога: два рядки
|
||
-- на один алерт означали б дві паралельні драбини, тобто рівно те
|
||
-- подвоєння, від якого ескалація має рятувати. Взведення робиться через
|
||
-- ON CONFLICT DO NOTHING, тому повторна доставка того самого алерту
|
||
-- (ретрай, другий інстанс, перезапуск) драбини не подвоює.
|
||
CREATE TABLE alr.alert_escalations (
|
||
alert_id uuid PRIMARY KEY REFERENCES alr.alerts(id) ON DELETE CASCADE,
|
||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||
policy_id uuid REFERENCES alr.escalation_policies(id) ON DELETE SET NULL,
|
||
|
||
-- Подієвий алерт (0058) ескалюється інакше — див. пояснення нижче.
|
||
is_event boolean NOT NULL DEFAULT false,
|
||
|
||
-- Індекс сходинки, яку треба доставити НАСТУПНОЮ (0 — ще жодної не
|
||
-- доставлено). Тобто це водночас і «скільки вже пройдено» в цьому
|
||
-- проході, і саме це число показує картка алерту.
|
||
step_idx int NOT NULL DEFAULT 0,
|
||
-- Скільки разів драбину вже пройшли цілком.
|
||
repeat_idx int NOT NULL DEFAULT 0,
|
||
|
||
-- Момент, від якого відлічуються after_min поточного проходу. Для
|
||
-- першого проходу це початок алерту; для повтору — зсунутий момент,
|
||
-- щоб «повторити через 60 хв» не перетворилось на «через 60 + 15».
|
||
pass_start timestamptz NOT NULL,
|
||
|
||
-- Коли настане наступна сходинка. NULL — драбина зупинена або
|
||
-- вичерпана; саме за цим полем движок і шукає роботу.
|
||
next_at timestamptz,
|
||
|
||
-- Жорстка стеля життя драбини. Потрібна через одну конкретну річ:
|
||
-- заглушений алерт сходинку не витрачає, а відкладає (пояснення в
|
||
-- alerts_escalation.go). Без стелі відкладання ходило б по колу
|
||
-- місяцями на алерті, який ніхто не закриє.
|
||
deadline timestamptz NOT NULL,
|
||
|
||
-- Оренда: момент, до якого рядок вважається взятим у роботу. Движок
|
||
-- бере сходинку, ставить оренду, ухвалює рішення й лише потім
|
||
-- надсилає. Падіння між взяттям і рішенням не втрачає сходинку —
|
||
-- оренда спливає, і її беруть знову. Падіння між рішенням і
|
||
-- надсиланням коштує однієї сходинки, і це той самий свідомий вибір,
|
||
-- що вже зроблено для notify_pending у 0058: краще не надіслати, ніж
|
||
-- надіслати вдруге.
|
||
leased_until timestamptz,
|
||
|
||
armed_at timestamptz NOT NULL DEFAULT now(),
|
||
last_step_at timestamptz,
|
||
stopped_at timestamptz,
|
||
-- 'acked' | 'closed' | 'done' | 'deadline' | 'no_policy'
|
||
stop_reason text
|
||
);
|
||
|
||
-- Вибірка «що настало» робиться кожен тік і майже завжди порожня —
|
||
-- тому частковий індекс рівно по живих драбинах.
|
||
CREATE INDEX alert_escalations_due_idx ON alr.alert_escalations (next_at)
|
||
WHERE stopped_at IS NULL AND next_at IS NOT NULL;
|
||
|
||
CREATE INDEX alert_escalations_tenant_idx ON alr.alert_escalations (tenant_id);
|
||
|
||
ALTER TABLE alr.alert_escalations ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE alr.alert_escalations FORCE ROW LEVEL SECURITY;
|
||
CREATE POLICY tenant_isolation ON alr.alert_escalations
|
||
USING (tenant_id = core.current_tenant())
|
||
WITH CHECK (tenant_id = core.current_tenant());
|
||
|
||
GRANT SELECT, INSERT, UPDATE, DELETE ON alr.alert_escalations
|
||
TO netpulse_app, netpulse_worker;
|
||
|
||
COMMENT ON TABLE alr.alert_escalations IS
|
||
'Стан драбини ескалації одного алерту. Живе в базі, а не в пам''яті: '
|
||
'перезапуск процесу посеред нічної аварії не має стирати чергу підйому.';
|
||
|
||
COMMENT ON COLUMN alr.alert_escalations.is_event IS
|
||
'Алерт піднято подієвим джерелом (0058): драбина проходить один раз, без повторів';
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- 4. Журнал сходинок
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- Чому окремо від alr.notifications. Той журнал відповідає на питання
|
||
-- «чи пішло повідомлення в канал» і не має слова для рішення «не пішло,
|
||
-- бо алерт уже підтвердили». А саме це рішення й треба показати: у
|
||
-- ескалації половина роботи — не будити. Запис «сходинку 2 пропущено:
|
||
-- підтверджено о 02:47» — це і є доказ, що система не мовчала випадково.
|
||
CREATE TABLE alr.escalation_steps (
|
||
ts timestamptz NOT NULL DEFAULT now(),
|
||
id uuid NOT NULL DEFAULT core.new_id(),
|
||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||
-- Без зовнішнього ключа на alr.alerts: алерти переїжджають в історію
|
||
-- (ArchiveResolved), а журнал причин має пережити переїзд. Каскад
|
||
-- звідти забрав би рівно ті записи, які найчастіше й потрібні —
|
||
-- про давню нічну аварію.
|
||
alert_id uuid NOT NULL,
|
||
policy_id uuid,
|
||
step_idx int NOT NULL,
|
||
repeat_idx int NOT NULL DEFAULT 0,
|
||
-- 'sent' | 'acked' | 'closed' | 'suppressed' | 'deadline' | 'no_policy' | 'done'
|
||
outcome text NOT NULL,
|
||
detail text,
|
||
PRIMARY KEY (ts, id)
|
||
);
|
||
|
||
CREATE INDEX escalation_steps_alert_idx ON alr.escalation_steps (alert_id, ts DESC);
|
||
|
||
ALTER TABLE alr.escalation_steps ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE alr.escalation_steps FORCE ROW LEVEL SECURITY;
|
||
CREATE POLICY tenant_isolation ON alr.escalation_steps
|
||
USING (tenant_id = core.current_tenant())
|
||
WITH CHECK (tenant_id = core.current_tenant());
|
||
|
||
GRANT SELECT, INSERT, UPDATE, DELETE ON alr.escalation_steps
|
||
TO netpulse_app, netpulse_worker;
|
||
|
||
COMMENT ON TABLE alr.escalation_steps IS
|
||
'Що зробила ескалація і ЧОМУ — зокрема й «нічого, бо вже підтверджено»';
|
||
|
||
-- Строк зберігання — той самий, що й у журналу доставки в 0007 (90
|
||
-- діб). Таблиця не гіпертаблиця: рядок на сходинку живого алерту — це
|
||
-- одиниці на добу навіть у великому кабінеті, і заводити під це
|
||
-- партиціонування було б платою без причини. Прибирання робить
|
||
-- прибиральник алертів разом з архівацією.
|