Схема PostgreSQL 16+/TimescaleDB: 11 міграцій, 7 схем, топологія (neighbors -> links -> maps -> nodes/edges), time-series з CAGG, NCM, alerting, білінг з entitlements, RLS. Контракт agent<->server: 6 proto-файлів, gRPC, інтернування серій, at-least-once з ack, чанкування конфігів. Перевірено на стенді Debian 13 / PG 17.11 / TimescaleDB 2.29.1: міграції + 8 функціональних перевірок схеми, buf lint + 5 наскрізних gRPC-тестів контракту. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
10 KiB
Markdown
141 lines
10 KiB
Markdown
# NetPulse — схема бази даних (Етап 1)
|
||
|
||
PostgreSQL 16+ / TimescaleDB 2.14+. Міграції в `migrations/`, накочуються по порядку номерів.
|
||
|
||
```bash
|
||
docker compose up -d db
|
||
```
|
||
|
||
```bash
|
||
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: ядро топології
|
||
|
||
```mermaid
|
||
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 виставляє на кожній транзакції:
|
||
|
||
```sql
|
||
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` перевіряє не лише синтаксис, а й поведінку моделі:
|
||
|
||
```bash
|
||
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 |
|