netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного запуску — це половина дистрибутива на порожньому місці. Міграції вшиті через embed і переїхали в server/migrations: embed не бачить нічого за межами кореня свого модуля, а міграції поруч із бінарником, який їх накочує, не можуть розійтися версіями. Накочування під advisory-блокуванням: два інстанси при rolling update інакше застосували б ту саму міграцію двічі. Кожен файл в одній транзакції разом із записом у schema_migrations; виняток — continuous aggregates, які TimescaleDB забороняє в транзакції. Змінена вже застосована міграція зупиняє запуск: у різних інсталяціях інакше опиниться різна схема під одним номером. Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск каже «схема актуальна». Веб віддає сам API через embed: на self-hosted це прибирає з інструкції встановлення цілий компонент. Три політики кешування — назавжди для assets із хешем у імені, ніколи для index.html, коротко для решти. Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з index.html, і клієнт падав на розборі HTML як JSON замість чесного 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| profiles | ||
| tests | ||
| README-migrations.md | ||
| README.md | ||
NetPulse — схема бази даних (Етап 1)
PostgreSQL 16+ / TimescaleDB 2.14+. Міграції в migrations/, накочуються по порядку номерів.
docker compose up -d db
pwsh ./db/migrate.ps1
Схеми (namespaces)
| Схема | Призначення |
|---|---|
core |
Ядро: тенанти, users, RBAC, секрети, агенти, плагіни, чеки, дашборди, аудит |
inv |
Інвентар: сайти, групи, пристрої, інтерфейси, IP, підмережі, креденшели |
topo |
Топологія: сирі сусіди (LLDP/CDP/ARP), зведені лінки, мапи, вузли, ребра, підкладки |
ts |
Time-series: hypertables + continuous aggregates |
ncm |
Config management: репо, профілі, задачі, версії конфігів, diff, compliance |
alr |
Alerting: правила, алерти, канали, ескалації, вікна обслуговування |
bill |
Тарифи, підписки, entitlements, usage, інвойси, ліцензійні ключі |
Порядок міграцій
| # | Файл | Що містить |
|---|---|---|
| 0001 | 0001_core.sql |
Розширення, домени, tenants, users, RBAC, секрети (AES-GCM), audit_log |
| 0002 | 0002_inventory.sql |
Sites, groups, devices, interfaces, addresses, subnets, credentials |
| 0003 | 0003_agents_plugins.sql |
Реєстр плагінів, агенти, типи чеків, checks, event outbox |
| 0004 | 0004_topology.sql |
Neighbors → links → maps → nodes/edges → backgrounds → revisions |
| 0005 | 0005_telemetry_timescale.sql |
Hypertables, CAGG, compression, retention, view'и для мапи |
| 0006 | 0006_ncm.sql |
Git-репо, профілі збору, jobs, configs, diffs, compliance, rollback |
| 0007 | 0007_alerting.sql |
Rules, alerts, channels, push, routes, maintenance, mutes |
| 0008 | 0008_dashboards.sql |
Дашборди, віджети (в т.ч. map), saved views, SLA |
| 0009 | 0009_billing_licensing.sql |
Plans, subscriptions, entitlements, usage, invoices, license keys |
| 0010 | 0010_seed.sql |
Довідники: права, ролі, плагіни, чеки, тарифи, NCM-профілі |
| 0011 | 0011_rls.sql |
Row Level Security + ролі БД |
0010 навмисно йде перед 0011: після
FORCE ROW LEVEL SECURITYнавіть власник схеми не зможе вставити довідникові рядки зtenant_id IS NULL.
ERD: ядро топології
erDiagram
TENANTS ||--o{ DEVICES : ""
TENANTS ||--o{ MAPS : ""
SITES ||--o{ DEVICES : ""
DEVICES ||--o{ INTERFACES : ""
DEVICES ||--o{ NEIGHBORS : "виявлено з"
NEIGHBORS }o--|| LINKS : "резолвиться в"
INTERFACES ||--o{ LINKS : "a_if / b_if"
MAPS ||--o{ MAP_NODES : ""
MAPS ||--o{ MAP_EDGES : ""
MAPS ||--o{ MAP_BACKGROUNDS : "floor plan / OSM / rack"
MAPS ||--o{ MAP_REVISIONS : "undo/redo"
DEVICES ||--o{ MAP_NODES : "kind='device'"
MAP_NODES ||--o{ MAP_EDGES : "source/target"
LINKS ||--o{ MAP_EDGES : "статус і трафік"
INTERFACES ||--o{ IF_COUNTERS : "трафік → анімація"
DEVICES ||--o{ ICMP_SAMPLES : "колір вузла"
Ключові рішення
1. Фізична топологія відокремлена від візуальної.
topo.links — те, що існує в мережі (виявлено LLDP/CDP або створено вручну).
topo.map_edges — як це намальовано на конкретній мапі. Один лінк може бути показаний на N мапах з різними стилями; видалення мапи не чіпає топологію. map_edges.link_id — джерело статусу й трафіку для лінії.
2. Порт-у-порт зв'язки.
І topo.links, і topo.map_edges посилаються на inv.interfaces, тому Switch1:Port1 → Router1:eth0 — це FK, а не текст. Унікальний індекс links_pair_uniq нормалізує пару через LEAST/GREATEST, щоб A→B і B→A не дублювались.
3. Автовиявлення не затирає ручну роботу.
topo.neighbors зберігає сире, як його віддав агент, плюс resolved_* і confidence. Резолвер зводить це в topo.links. Прапорець is_pinned захищає підтверджені людиною лінки від перезапису.
4. Дві моделі метрик.
ts.series+ts.samples— узагальнена (Prometheus-подібна): будь-який плагін реєструє свійmetric_keyбез зміни DDL.ts.icmp_samplesіts.if_counters— широкі таблиці для гарячих шляхів. Це те, що читає мапа на кожному тику WebSocket, і денормалізація тут окупається.
5. Анімація трафіку має конкретне джерело.
if_counters.util_out_pct (% від interfaces.speed_bps) → topo.link_live (гірша з двох сторін) → map_edges.animation.speed_source='utilization'. Кольори порогів — у map_edges.thresholds.
6. Продуктивність мапи на 1000+ вузлів.
map_nodes має GiST-індекс по point(x,y) для viewport-culling, map_edges — індекси по source/target. Повний стан полотна тягнеться одним запитом (nodes + edges + backgrounds + останні статуси через ts.device_last_icmp і topo.link_live), далі дельти йдуть по WebSocket.
7. Ліміти тарифу перевіряються двічі.
bill.entitlements — матеріалізовані права тенанта (читає API на кожному запиті, кешується в Redis). Плюс тригери bill.assert_device_limit() і bill.assert_map_node_limit() як друга лінія оборони на рівні БД.
8. Секрети ніколи не лежать відкрито.
core.secrets зберігає лише ciphertext + nonce + auth_tag + key_id (AES-GCM-256, шифрування на боці застосунку, DEK у KMS/Vault). Паролі SSH/SNMPv3, приватні ключі, токени каналів і тіла конфігів — усе через цю таблицю.
9. Тіло конфігів — у Git, метадані — у БД.
ncm.configs тримає commit_sha/blob_sha/path та content_hash для швидкої відповіді «змінилось?». Diff-и кешуються в ncm.diffs, щоб Telegram Mini App не рахував їх щоразу.
Ізоляція тенантів
API виставляє на кожній транзакції:
SET LOCAL app.tenant_id = '<uuid>';
Політика tenant_isolation (USING + WITH CHECK) вмикається автоматично на кожній таблиці з колонкою tenant_id. Якщо змінна не виставлена — core.current_tenant() повертає NULL і запит дає порожній результат: це навмисно безпечніше за «усі тенанти».
Свідомі винятки, які треба тримати в голові:
- Усі 12 hypertables — RLS вимкнено. TimescaleDB відмовляє:
operation not supported on hypertables that have columnstore enabled, тобто RLS несумісний зі стисненням, а стиснення увімкнене на всіх гарячих time-series таблицях. Виключено гіпертаблі цілком, а не вибірково — інакше набір захищених таблиць мовчки залежав би від того, на які з них уже накотили compression policy. Ізоляція для них: доступ лише через JOIN зinv.devices/inv.interfaces/ts.series(усі під RLS) плюс обов'язковий предикатtenant_idу репозиторному шарі API. - Зв'язкові таблиці без власного
tenant_id(core.role_permissions,inv.device_group_members,inv.device_tags,inv.device_credentials,bill.invoice_lines,topo.map_shares) покладаються на FK-каскад від захищеного батька. netpulse_workerмаєBYPASSRLS— воркери агрегації, білінгу й retention працюють поверх тенантів.
Стан перевірки
Схема прогнана на живому стенді: Debian 13 / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій накотились на чисту БД без помилок.
| Об'єкт | Кількість |
|---|---|
| Таблиць | 81 |
| Hypertables | 12 |
| Continuous aggregates | 6 |
| Фонових job-ів (compression/retention/refresh) | 25 |
| RLS-політик | 63 |
| Індексів | 220 |
tests/smoke.sql перевіряє не лише синтаксис, а й поведінку моделі:
psql -d netpulse -f db/tests/smoke.sql
| Перевірка | Результат |
|---|---|
Дзеркальний лінк B→A відхилено (links_pair_uniq) |
PASS |
kind='device' без device_id відхилено (map_nodes_kind_ref_chk) |
PASS |
Дубль активного алерту відхилено (alerts_active_dedup_uniq) |
PASS |
| 16-й пристрій на тарифі Free відхилено тригером | PASS |
| Стан полотна мапи одним запитом (вузли + статуси + RTT) | PASS |
Ребро з портами Gi0/1 → ether1 і живим util_pct = 78% |
PASS |
CAGG if_counters_5m рахує роллапи |
PASS |
RLS: ACME бачить 15 пристроїв, Globex — 0, без app.tenant_id — 0 |
PASS |