Етап 1-2: схема БД та protobuf-контракт агент-сервер
Схема 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>
This commit is contained in:
commit
15d22a8d8a
38 changed files with 12997 additions and 0 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Збірки
|
||||
/bin/
|
||||
/dist/
|
||||
*.exe
|
||||
*.test
|
||||
netpulse-agent
|
||||
netpulse-server
|
||||
|
||||
# Секрети й локальні налаштування
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
!testdata/**/*.key
|
||||
|
||||
# Згенерований TypeScript — регенерується з proto, на відміну від gen/go,
|
||||
# який комітиться, щоб збірка агента не залежала від наявності buf.
|
||||
/gen/ts/
|
||||
|
||||
# Інструменти
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
|
||||
# Дані локального стенду
|
||||
/data/
|
||||
/var/
|
||||
135
HISTORY.md
Normal file
135
HISTORY.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# NetPulse — журнал розробки
|
||||
|
||||
Стислий лог: що зроблено, які рішення прийняті, що далі.
|
||||
Мета — щоб наступна сесія не перечитувала весь код.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-14 — Етап 1: схема БД
|
||||
|
||||
### Створено
|
||||
|
||||
```
|
||||
netpulse/
|
||||
├── HISTORY.md ← цей файл
|
||||
├── docker-compose.yml TimescaleDB 2.17/pg16 + DragonflyDB
|
||||
└── db/
|
||||
├── README.md ERD, ключові рішення, ізоляція тенантів
|
||||
├── migrate.ps1 накат міграцій + schema_migrations
|
||||
└── migrations/
|
||||
├── 0001_core.sql tenants, users, RBAC, secrets, audit
|
||||
├── 0002_inventory.sql sites, devices, interfaces, credentials
|
||||
├── 0003_agents_plugins.sql plugins, agents, check_types, checks, outbox
|
||||
├── 0004_topology.sql neighbors, links, maps, nodes, edges, backgrounds
|
||||
├── 0005_telemetry_timescale.sql hypertables, CAGG, compression, retention
|
||||
├── 0006_ncm.sql repos, profiles, jobs, configs, diffs, rollback
|
||||
├── 0007_alerting.sql rules, alerts, channels, routes, maintenance
|
||||
├── 0008_dashboards.sql dashboards, widgets, SLA
|
||||
├── 0009_billing_licensing.sql plans, subscriptions, entitlements, invoices, licenses
|
||||
├── 0010_seed.sql довідники
|
||||
└── 0011_rls.sql Row Level Security
|
||||
```
|
||||
|
||||
### Прийняті архітектурні рішення
|
||||
|
||||
1. **Фізична топологія ≠ візуальна.** `topo.links` (що є в мережі) окремо від `topo.map_edges` (як намальовано). Один лінк — на багатьох мапах.
|
||||
2. **Зв'язки port→port через FK** на `inv.interfaces`, не текстом. Пара нормалізована через `LEAST/GREATEST`, щоб A→B і B→A не дублювались.
|
||||
3. **Автовиявлення в два кроки:** сирі `topo.neighbors` (LLDP/CDP/ARP/FDB + `confidence`) → резолвер → `topo.links`. Прапорець `is_pinned` захищає ручні лінки.
|
||||
4. **Дві моделі метрик:** узагальнена `ts.series`+`ts.samples` (плагіни реєструють `metric_key` без DDL) і широкі `ts.icmp_samples`/`ts.if_counters` для гарячих шляхів мапи.
|
||||
5. **Анімація трафіку має ланцюг даних:** `if_counters.util_out_pct` → view `topo.link_live` → `map_edges.animation`.
|
||||
6. **Ліміти тарифу перевіряються двічі:** `bill.entitlements` в API + тригери в БД.
|
||||
7. **Секрети лише шифровані** (`core.secrets`: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS).
|
||||
8. **Тіло конфігів у Git, метадані в БД** (`ncm.configs.commit_sha` + `content_hash`).
|
||||
9. **RLS за замовчуванням** на кожній таблиці з `tenant_id`; порожній `app.tenant_id` → порожній результат.
|
||||
|
||||
### Проблеми, на які натрапив (щоб не повторювати)
|
||||
|
||||
- **Порядок seed ↔ RLS.** Спершу RLS був 0010, seed 0011 — це ламається: після `FORCE ROW LEVEL SECURITY` навіть власник схеми не вставить довідники з `tenant_id IS NULL`. Файли переставлені місцями.
|
||||
- **CAGG не працюють у транзакції.** `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)` падає всередині транзакційного блоку. `migrate.ps1` детектить це по вмісту файлу й вимикає `--single-transaction` для 0005.
|
||||
|
||||
### Помилки, знайдені прогоном на живому Postgres
|
||||
|
||||
1. `window` — зарезервоване слово. `core.sla_targets.window` → `period_kind`.
|
||||
2. `core.check_types.key` був доменом `core.slug`, але ключі мають вигляд `icmp.ping` — крапка не проходить. Замінено на `text` з власним CHECK `^[a-z0-9]+(\.[a-z0-9_]+)+$`; так само `core.checks.check_type`.
|
||||
3. Домен `core.slug` не пропускав підкреслення, а ключі фіч — `http_checks`, `auto_discovery`. Регекс розширено до `[a-z0-9_-]`.
|
||||
4. **RLS блокує не CAGG, а стиснення.** Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29: `operation not supported on hypertables that have columnstore enabled` — тобто конфлікт саме з compression. Тому в 0011 виключено **всі** hypertables (через `timescaledb_information.hypertables`), а не три захардкоджені.
|
||||
|
||||
### Перевірено на живому стенді
|
||||
|
||||
Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1.
|
||||
Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables,
|
||||
6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів.
|
||||
|
||||
`db/tests/smoke.sql` — 8 функціональних перевірок, усі PASS:
|
||||
нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів,
|
||||
ліміт пристроїв тарифу Free, стан полотна мапи одним запитом,
|
||||
ребро port→port з живим `util_pct`, роллап CAGG, ізоляція тенантів RLS.
|
||||
|
||||
Бази на сервері: `netpulse` (схема + smoke-дані). Перестворити:
|
||||
`sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse`, далі міграції.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер
|
||||
|
||||
### Створено
|
||||
|
||||
```
|
||||
netpulse/
|
||||
├── buf.yaml, buf.gen.yaml
|
||||
├── proto/
|
||||
│ ├── README.md контракт: сервіси, потоки, семантика, безпека
|
||||
│ └── netpulse/v1/
|
||||
│ ├── common.proto Status, Transport, Error, DeviceTarget, Credential, AgentHealth
|
||||
│ ├── agent.proto EnrollmentService, AgentService, ControlUp/ControlDown
|
||||
│ ├── telemetry.proto SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters
|
||||
│ ├── discovery.proto NeighborRecord, InterfaceRecord, DiscoveredDevice
|
||||
│ ├── ncm.proto ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob
|
||||
│ └── logs.proto SyslogEntry, SnmpTrap, LogBatch
|
||||
├── gen/go/ згенерований код (комітиться)
|
||||
└── test/contract/ наскрізні gRPC-тести на bufconn
|
||||
```
|
||||
|
||||
### Прийняті рішення
|
||||
|
||||
1. **Форма контракту випливає з одного обмеження:** усі з'єднання ініціює агент.
|
||||
«Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого
|
||||
агентом bidi-стріму `Control`, а не RPC у бік агента.
|
||||
2. **Чотири окремі стріми** (Control / Telemetry / Logs / Config), а не один:
|
||||
пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії
|
||||
не має топити телеметрію.
|
||||
3. **Інтернування серій.** Агент реєструє серію раз під `series_ref`, далі шле
|
||||
лише номер. Заміряно: 66 → 25 байт на семпл. `series_ref` живе в межах сесії.
|
||||
4. **Швидкості рахує агент** (лише він знає точний інтервал опитування), але шле
|
||||
й сирі лічильники — щоб сервер міг перерахувати заднім числом.
|
||||
5. **Scrub/redact конфігів — на сервері.** Агент віддає сирий текст; правила
|
||||
живуть у `ncm.profiles` і змінюються без оновлення агентів у полі.
|
||||
6. **Топологію зводить сервер.** Агент доповідає лише «на порту X бачу chassis Y».
|
||||
7. **`Task.params_json` — непрозорі байти.** Новий плагін не потребує зміни .proto.
|
||||
Модуль-виконавець виводиться з префікса `check_type` до крапки.
|
||||
8. **At-least-once + upsert.** Дедуплікацію дають PK схеми БД `(ts, device_id)` тощо.
|
||||
9. **Зворотний тиск диктує сервер** у `Welcome` і кожному `TelemetryAck`.
|
||||
10. **Самооновлення підписане Ed25519** — інакше компрометація CDN = RCE в мережі
|
||||
кожного клієнта.
|
||||
|
||||
### Перевірено на стенді
|
||||
|
||||
Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0.
|
||||
|
||||
- `protoc` — усі 6 файлів валідні;
|
||||
- `buf lint` (STANDARD) — без зауважень;
|
||||
- генерація Go+gRPC, `go build`, `go vet` — чисто;
|
||||
- `go test ./test/contract/...` — **5/5 PASS**: рукостискання й push плану задач,
|
||||
інтернування серій, реакція на невідомий `series_ref`, чанкування конфігу зі
|
||||
звіркою sha256, відмова при пошкодженій контрольній сумі.
|
||||
|
||||
Дрібниця для наступного разу: `buf.yaml` довелось звільнити від
|
||||
`RPC_REQUEST_STANDARD_NAME` та сусідніх правил — вони припускають пари
|
||||
запит-відповідь, а `ControlUp`/`ControlDown` це незалежні потоки подій.
|
||||
|
||||
### Далі (Етап 2, частина 2)
|
||||
|
||||
Сам Go-агент: скелет із gRPC-клієнтом і реконектом, планувальник задач
|
||||
зі `schedule_offset`, модулі ICMP і SNMP, збір сусідів LLDP/CDP/ARP,
|
||||
буфер телеметрії з обмеженням пам'яті (бюджет RSS < 30 МБ).
|
||||
Паралельно — серверна сторона `AgentService` поверх схеми з Етапу 1.
|
||||
25
buf.gen.yaml
Normal file
25
buf.gen.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
version: v2
|
||||
|
||||
managed:
|
||||
enabled: true
|
||||
override:
|
||||
- file_option: go_package_prefix
|
||||
value: github.com/netpulse/netpulse/gen/go
|
||||
|
||||
plugins:
|
||||
# Go-структури
|
||||
- remote: buf.build/protocolbuffers/go
|
||||
out: gen/go
|
||||
opt: paths=source_relative
|
||||
|
||||
# gRPC-клієнт (агент) і сервер
|
||||
- remote: buf.build/grpc/go
|
||||
out: gen/go
|
||||
opt:
|
||||
- paths=source_relative
|
||||
- require_unimplemented_servers=false
|
||||
|
||||
# TypeScript для адмін-інструментів і e2e-тестів фронтенду
|
||||
- remote: buf.build/bufbuild/es
|
||||
out: gen/ts
|
||||
opt: target=ts
|
||||
19
buf.yaml
Normal file
19
buf.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
version: v2
|
||||
|
||||
modules:
|
||||
- path: proto
|
||||
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
except:
|
||||
# Ми свідомо не додаємо суфікс Request/Response до повідомлень
|
||||
# bidi-стріму: ControlUp/ControlDown — не пари запит-відповідь,
|
||||
# а незалежні потоки подій у двох напрямках.
|
||||
- RPC_REQUEST_STANDARD_NAME
|
||||
- RPC_RESPONSE_STANDARD_NAME
|
||||
- RPC_REQUEST_RESPONSE_UNIQUE
|
||||
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
141
db/README.md
Normal file
141
db/README.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# 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 |
|
||||
63
db/migrate.ps1
Normal file
63
db/migrate.ps1
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<#
|
||||
Накочує міграції по порядку номерів у транзакції (кожен файл окремо).
|
||||
Використання:
|
||||
.\migrate.ps1 # локальний стенд з docker-compose
|
||||
.\migrate.ps1 -DbUrl "postgres://user:pw@host:5432/db"
|
||||
.\migrate.ps1 -DryRun # лише показати порядок
|
||||
#>
|
||||
param(
|
||||
[string]$DbUrl = "postgres://netpulse:netpulse@localhost:5432/netpulse",
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$dir = Join-Path $PSScriptRoot "migrations"
|
||||
$files = Get-ChildItem -Path $dir -Filter "*.sql" | Sort-Object Name
|
||||
|
||||
if ($DryRun) {
|
||||
$files | ForEach-Object { $_.Name }
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Get-Command psql -ErrorAction SilentlyContinue)) {
|
||||
throw "psql не знайдено в PATH. Встанови PostgreSQL client tools або запусти через контейнер: docker compose exec -T db psql ..."
|
||||
}
|
||||
|
||||
# Таблиця обліку застосованих міграцій
|
||||
$bootstrap = @'
|
||||
CREATE TABLE IF NOT EXISTS public.schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
'@
|
||||
$bootstrap | psql $DbUrl -v ON_ERROR_STOP=1 -q
|
||||
|
||||
foreach ($f in $files) {
|
||||
$version = $f.BaseName
|
||||
$applied = (psql $DbUrl -tA -c "SELECT 1 FROM public.schema_migrations WHERE version = '$version'").Trim()
|
||||
if ($applied -eq "1") {
|
||||
Write-Host "skip $version" -ForegroundColor DarkGray
|
||||
continue
|
||||
}
|
||||
|
||||
$sum = (Get-FileHash $f.FullName -Algorithm SHA256).Hash
|
||||
Write-Host "apply $version" -ForegroundColor Cyan
|
||||
|
||||
# Зазвичай — одна транзакція на файл, щоб не було часткових міграцій.
|
||||
# Виняток: TimescaleDB забороняє CREATE MATERIALIZED VIEW WITH
|
||||
# (timescaledb.continuous) всередині транзакційного блоку.
|
||||
$needsAutocommit = (Select-String -Path $f.FullName -Pattern "timescaledb\.continuous" -Quiet)
|
||||
if ($needsAutocommit) {
|
||||
Write-Host " (autocommit: continuous aggregates)" -ForegroundColor DarkYellow
|
||||
psql $DbUrl -v ON_ERROR_STOP=1 -q -f $f.FullName
|
||||
} else {
|
||||
psql $DbUrl -v ON_ERROR_STOP=1 --single-transaction -q -f $f.FullName
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "Міграція $version впала" }
|
||||
|
||||
psql $DbUrl -v ON_ERROR_STOP=1 -q -c `
|
||||
"INSERT INTO public.schema_migrations (version, checksum) VALUES ('$version','$sum')"
|
||||
}
|
||||
|
||||
Write-Host "OK: усі міграції застосовано" -ForegroundColor Green
|
||||
229
db/migrations/0001_core.sql
Normal file
229
db/migrations/0001_core.sql
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0001_core.sql
|
||||
-- Розширення, домени, мультитенантність, автентифікація, RBAC, аудит.
|
||||
-- PostgreSQL 16+ / TimescaleDB 2.14+
|
||||
-- =====================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest(), gen_random_bytes()
|
||||
CREATE EXTENSION IF NOT EXISTS citext; -- регістронезалежні email/hostname
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- пошук по інвентарю
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist; -- EXCLUDE-обмеження (maintenance windows)
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS core;
|
||||
CREATE SCHEMA IF NOT EXISTS inv; -- inventory
|
||||
CREATE SCHEMA IF NOT EXISTS topo; -- топологія / мапи
|
||||
CREATE SCHEMA IF NOT EXISTS ts; -- time-series (hypertables)
|
||||
CREATE SCHEMA IF NOT EXISTS ncm; -- network config management
|
||||
CREATE SCHEMA IF NOT EXISTS alr; -- alerting
|
||||
CREATE SCHEMA IF NOT EXISTS bill; -- billing / licensing
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Спільні домени й хелпери
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Slug: [a-z0-9_-], 1..64, починається й закінчується літерою/цифрою.
|
||||
-- Підкреслення дозволене — ключі фіч (http_checks, auto_discovery) його використовують.
|
||||
CREATE DOMAIN core.slug AS text
|
||||
CHECK (VALUE ~ '^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$');
|
||||
|
||||
-- Обгортка для UUIDv7-подібних ключів. Поки що v4 (gen_random_uuid),
|
||||
-- заміна на pg_uuidv7 не потребує зміни DDL.
|
||||
CREATE OR REPLACE FUNCTION core.new_id() RETURNS uuid
|
||||
LANGUAGE sql VOLATILE AS $$ SELECT gen_random_uuid() $$;
|
||||
|
||||
-- Поточний tenant із сесійної змінної (використовується в RLS).
|
||||
CREATE OR REPLACE FUNCTION core.current_tenant() RETURNS uuid
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core.touch_updated_at() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Tenants (організації)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE core.tenant_status AS ENUM ('trial','active','past_due','suspended','cancelled');
|
||||
|
||||
CREATE TABLE core.tenants (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
slug core.slug NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
status core.tenant_status NOT NULL DEFAULT 'trial',
|
||||
-- White-label (Enterprise): logo_url, primary_color, custom_domain, favicon
|
||||
branding jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
timezone text NOT NULL DEFAULT 'UTC',
|
||||
-- Ідентифікатор інсталяції для self-hosted (прив'язка ліцензійного ключа)
|
||||
install_id uuid NOT NULL DEFAULT core.new_id(),
|
||||
trial_ends_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
CREATE TRIGGER trg_tenants_touch BEFORE UPDATE ON core.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Users / Membership / RBAC
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE core.users (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
email citext NOT NULL UNIQUE,
|
||||
email_verified_at timestamptz,
|
||||
password_hash text, -- argon2id; NULL для SSO-only
|
||||
full_name text,
|
||||
avatar_url text,
|
||||
locale text NOT NULL DEFAULT 'uk',
|
||||
timezone text NOT NULL DEFAULT 'UTC',
|
||||
totp_secret_enc bytea, -- AES-GCM-256, див. core.secrets
|
||||
mfa_enabled boolean NOT NULL DEFAULT false,
|
||||
-- Прив'язка Telegram для Mini App / сповіщень
|
||||
telegram_user_id bigint UNIQUE,
|
||||
last_login_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
CREATE TRIGGER trg_users_touch BEFORE UPDATE ON core.users
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
-- Ролі: системні (tenant_id IS NULL) + кастомні на рівні тенанта (Enterprise)
|
||||
CREATE TABLE core.roles (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
key core.slug NOT NULL, -- owner, admin, engineer, viewer, ...
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
is_system boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Один і той самий key унікальний в межах тенанта; системні — глобально.
|
||||
CREATE UNIQUE INDEX roles_tenant_key_uniq
|
||||
ON core.roles (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), key);
|
||||
|
||||
-- Права у вигляді "resource:action" (devices:read, maps:write, ncm:diff, billing:manage)
|
||||
CREATE TABLE core.permissions (
|
||||
key text PRIMARY KEY CHECK (key ~ '^[a-z_]+:[a-z_]+$'),
|
||||
description text NOT NULL,
|
||||
-- Плагін, який зареєстрував право (NULL = ядро)
|
||||
plugin_key core.slug
|
||||
);
|
||||
|
||||
CREATE TABLE core.role_permissions (
|
||||
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE CASCADE,
|
||||
permission_key text NOT NULL REFERENCES core.permissions(key) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_key)
|
||||
);
|
||||
|
||||
CREATE TABLE core.memberships (
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
||||
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE RESTRICT,
|
||||
-- Обмеження видимості: NULL = усі пристрої тенанта, інакше — перелік груп
|
||||
scope_group_ids uuid[],
|
||||
invited_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
accepted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, user_id)
|
||||
);
|
||||
CREATE INDEX memberships_user_idx ON core.memberships (user_id);
|
||||
|
||||
CREATE TABLE core.invitations (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE CASCADE,
|
||||
token_hash bytea NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, email)
|
||||
);
|
||||
|
||||
-- Сесії (refresh-токени) + API-ключі
|
||||
CREATE TABLE core.sessions (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
token_hash bytea NOT NULL UNIQUE, -- sha256(refresh_token)
|
||||
user_agent text,
|
||||
ip inet,
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX sessions_user_idx ON core.sessions (user_id) WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE core.api_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
prefix text NOT NULL, -- перші 8 символів для показу в UI
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
scopes text[] NOT NULL DEFAULT '{}',
|
||||
last_used_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX api_tokens_tenant_idx ON core.api_tokens (tenant_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Сховище секретів (AES-GCM-256; шифрування на боці застосунку)
|
||||
-- Жоден секрет не потрапляє в БД у відкритому вигляді.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE core.secret_kind AS ENUM (
|
||||
'ssh_password','ssh_key','snmp_v3','api_token','telnet','enable_password',
|
||||
'webhook_secret','totp','generic'
|
||||
);
|
||||
|
||||
CREATE TABLE core.secrets (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
kind core.secret_kind NOT NULL,
|
||||
alg text NOT NULL DEFAULT 'AES-256-GCM',
|
||||
key_id text NOT NULL, -- ідентифікатор DEK у KMS/Vault (ротація)
|
||||
nonce bytea NOT NULL, -- 96-bit IV
|
||||
ciphertext bytea NOT NULL,
|
||||
auth_tag bytea NOT NULL, -- 128-bit GCM tag
|
||||
aad text, -- зазвичай tenant_id||kind||object_id
|
||||
version int NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
rotated_at timestamptz
|
||||
);
|
||||
CREATE INDEX secrets_tenant_idx ON core.secrets (tenant_id, kind);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Аудит (hypertable — журнал росте лінійно)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE core.audit_log (
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
id uuid NOT NULL DEFAULT core.new_id(),
|
||||
tenant_id uuid,
|
||||
actor_user_id uuid,
|
||||
actor_token_id uuid,
|
||||
actor_ip inet,
|
||||
action text NOT NULL, -- device.create, map.update, ncm.rollback
|
||||
object_type text,
|
||||
object_id uuid,
|
||||
before jsonb,
|
||||
after jsonb,
|
||||
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (ts, id)
|
||||
);
|
||||
SELECT create_hypertable('core.audit_log', 'ts',
|
||||
chunk_time_interval => INTERVAL '7 days',
|
||||
if_not_exists => TRUE);
|
||||
CREATE INDEX audit_tenant_ts_idx ON core.audit_log (tenant_id, ts DESC);
|
||||
CREATE INDEX audit_object_idx ON core.audit_log (object_type, object_id, ts DESC);
|
||||
226
db/migrations/0002_inventory.sql
Normal file
226
db/migrations/0002_inventory.sql
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0002_inventory.sql
|
||||
-- Локації, групи, пристрої, інтерфейси, облікові дані, теги.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Локації (сайти) та ієрархічні групи
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE inv.sites (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
code text, -- KYV-DC1
|
||||
address text,
|
||||
-- Геокоординати для OSM-підкладки мапи
|
||||
lat double precision CHECK (lat BETWEEN -90 AND 90),
|
||||
lon double precision CHECK (lon BETWEEN -180 AND 180),
|
||||
timezone text,
|
||||
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX sites_tenant_idx ON inv.sites (tenant_id);
|
||||
CREATE TRIGGER trg_sites_touch BEFORE UPDATE ON inv.sites
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
-- Групи для RBAC-скоупів, масових операцій та авто-групування на мапі
|
||||
CREATE TYPE inv.group_kind AS ENUM ('static','dynamic');
|
||||
|
||||
CREATE TABLE inv.device_groups (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES inv.device_groups(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
kind inv.group_kind NOT NULL DEFAULT 'static',
|
||||
-- Для dynamic: правило відбору {"all":[{"field":"vendor","op":"eq","value":"mikrotik"}]}
|
||||
filter jsonb,
|
||||
color text,
|
||||
icon text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, parent_id, name)
|
||||
);
|
||||
CREATE INDEX device_groups_tenant_idx ON inv.device_groups (tenant_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Пристрої
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE inv.device_kind AS ENUM (
|
||||
'router','switch','firewall','ap','server','vm','printer','ups','pdu',
|
||||
'inverter','bms','sensor','camera','olt','onu','cloud','website','other'
|
||||
);
|
||||
|
||||
CREATE TYPE inv.device_status AS ENUM ('up','down','warning','unknown','maintenance','disabled');
|
||||
|
||||
CREATE TABLE inv.devices (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
|
||||
agent_id uuid, -- FK додається в 0003 (зонд-опитувач)
|
||||
name text NOT NULL,
|
||||
hostname citext,
|
||||
-- Основна адреса опитування
|
||||
address inet,
|
||||
fqdn text,
|
||||
kind inv.device_kind NOT NULL DEFAULT 'other',
|
||||
vendor text,
|
||||
model text,
|
||||
os_version text,
|
||||
serial_number text,
|
||||
-- Ідентифікатори для кореляції LLDP/CDP/ARP-виявлення
|
||||
chassis_id text, -- LLDP chassis ID
|
||||
system_name text, -- sysName / CDP device-id
|
||||
base_mac macaddr,
|
||||
mgmt_vlan int CHECK (mgmt_vlan BETWEEN 1 AND 4094),
|
||||
|
||||
status inv.device_status NOT NULL DEFAULT 'unknown',
|
||||
status_changed_at timestamptz,
|
||||
last_seen_at timestamptz,
|
||||
|
||||
-- Профілі опитування (які плагіни активні для цього пристрою)
|
||||
monitoring jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Джерело появи: manual | discovery | import | api
|
||||
source text NOT NULL DEFAULT 'manual',
|
||||
-- Чи тарифікується (Free-план рахує лише enabled)
|
||||
is_billable boolean NOT NULL DEFAULT true,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
|
||||
notes text,
|
||||
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
CREATE UNIQUE INDEX devices_tenant_name_uniq ON inv.devices (tenant_id, lower(name))
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX devices_tenant_status_idx ON inv.devices (tenant_id, status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX devices_site_idx ON inv.devices (site_id);
|
||||
CREATE INDEX devices_agent_idx ON inv.devices (agent_id);
|
||||
CREATE INDEX devices_address_idx ON inv.devices (tenant_id, address);
|
||||
CREATE INDEX devices_chassis_idx ON inv.devices (tenant_id, chassis_id) WHERE chassis_id IS NOT NULL;
|
||||
CREATE INDEX devices_basemac_idx ON inv.devices (tenant_id, base_mac) WHERE base_mac IS NOT NULL;
|
||||
CREATE INDEX devices_name_trgm ON inv.devices USING gin (name gin_trgm_ops);
|
||||
CREATE TRIGGER trg_devices_touch BEFORE UPDATE ON inv.devices
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
CREATE TABLE inv.device_group_members (
|
||||
group_id uuid NOT NULL REFERENCES inv.device_groups(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, device_id)
|
||||
);
|
||||
CREATE INDEX dgm_device_idx ON inv.device_group_members (device_id);
|
||||
|
||||
CREATE TABLE inv.tags (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
key text NOT NULL,
|
||||
value text,
|
||||
color text,
|
||||
UNIQUE (tenant_id, key, value)
|
||||
);
|
||||
|
||||
CREATE TABLE inv.device_tags (
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
tag_id uuid NOT NULL REFERENCES inv.tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (device_id, tag_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Інтерфейси (потрібні для зв'язків на мапі "port -> port")
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE inv.if_admin_status AS ENUM ('up','down','testing','unknown');
|
||||
CREATE TYPE inv.if_oper_status AS ENUM ('up','down','testing','unknown','dormant','notPresent','lowerLayerDown');
|
||||
|
||||
CREATE TABLE inv.interfaces (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
if_index bigint, -- SNMP ifIndex
|
||||
name text NOT NULL, -- GigabitEthernet0/1, ether1, eth0
|
||||
alias text, -- ifAlias / description
|
||||
mac macaddr,
|
||||
mtu int,
|
||||
type text, -- ethernetCsmacd, ieee8023adLag, l3ipvlan...
|
||||
speed_bps bigint, -- номінальна швидкість (для % завантаження)
|
||||
duplex text,
|
||||
admin_status inv.if_admin_status NOT NULL DEFAULT 'unknown',
|
||||
oper_status inv.if_oper_status NOT NULL DEFAULT 'unknown',
|
||||
last_change_at timestamptz,
|
||||
-- LAG/стек: посилання на батьківський агрегат
|
||||
parent_if_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
|
||||
is_uplink boolean NOT NULL DEFAULT false,
|
||||
monitored boolean NOT NULL DEFAULT true,
|
||||
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX interfaces_device_ifindex_uniq
|
||||
ON inv.interfaces (device_id, if_index) WHERE if_index IS NOT NULL;
|
||||
CREATE UNIQUE INDEX interfaces_device_name_uniq ON inv.interfaces (device_id, lower(name));
|
||||
CREATE INDEX interfaces_tenant_idx ON inv.interfaces (tenant_id);
|
||||
CREATE INDEX interfaces_mac_idx ON inv.interfaces (tenant_id, mac) WHERE mac IS NOT NULL;
|
||||
CREATE TRIGGER trg_interfaces_touch BEFORE UPDATE ON inv.interfaces
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
-- IP-адреси інтерфейсів (для ARP-кореляції та L3-топології)
|
||||
CREATE TABLE inv.interface_addresses (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
interface_id uuid NOT NULL REFERENCES inv.interfaces(id) ON DELETE CASCADE,
|
||||
address inet NOT NULL,
|
||||
is_primary boolean NOT NULL DEFAULT false,
|
||||
vrf text,
|
||||
UNIQUE (interface_id, address)
|
||||
);
|
||||
CREATE INDEX if_addr_lookup_idx ON inv.interface_addresses (tenant_id, address);
|
||||
|
||||
-- Підмережі — для авто-групування пристроїв на мапі
|
||||
CREATE TABLE inv.subnets (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
|
||||
cidr cidr NOT NULL,
|
||||
vlan_id int CHECK (vlan_id BETWEEN 1 AND 4094),
|
||||
name text,
|
||||
description text,
|
||||
UNIQUE (tenant_id, cidr)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Облікові дані для опитування / NCM (посилання на core.secrets)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE inv.credential_proto AS ENUM ('ssh','telnet','snmp_v2c','snmp_v3','http','https','api','modbus');
|
||||
|
||||
CREATE TABLE inv.credentials (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
proto inv.credential_proto NOT NULL,
|
||||
username text,
|
||||
port int CHECK (port BETWEEN 1 AND 65535),
|
||||
-- Секрети (пароль, приватний ключ, snmp v3 auth/priv) — лише шифровані
|
||||
secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
|
||||
enable_secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
|
||||
-- SNMPv3: {"sec_level":"authPriv","auth_proto":"SHA256","priv_proto":"AES128","context":""}
|
||||
options jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX credentials_tenant_idx ON inv.credentials (tenant_id, proto);
|
||||
|
||||
-- Прив'язка кредів до пристроїв (пристрій може мати SSH + SNMP одночасно)
|
||||
CREATE TABLE inv.device_credentials (
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
credential_id uuid NOT NULL REFERENCES inv.credentials(id) ON DELETE CASCADE,
|
||||
priority int NOT NULL DEFAULT 100,
|
||||
PRIMARY KEY (device_id, credential_id)
|
||||
);
|
||||
129
db/migrations/0003_agents_plugins.sql
Normal file
129
db/migrations/0003_agents_plugins.sql
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0003_agents_plugins.sql
|
||||
-- Зонди (Go-агенти), реєстр плагінів, завдання опитування, черги.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Реєстр плагінів (Plugin-First Architecture)
|
||||
-- Ядро знає лише про Auth/RBAC/EventBus/TSDB/API — решта тут.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE core.plugin_scope AS ENUM ('agent','server','ui','both');
|
||||
|
||||
CREATE TABLE core.plugins (
|
||||
key core.slug PRIMARY KEY, -- icmp, snmp, ncm, topology, netflow, modbus
|
||||
name text NOT NULL,
|
||||
version text NOT NULL,
|
||||
scope core.plugin_scope NOT NULL,
|
||||
publisher text NOT NULL DEFAULT 'netpulse',
|
||||
description text,
|
||||
-- Оголошення можливостей: метрики, типи чеків, UI-панелі, права доступу
|
||||
manifest jsonb NOT NULL,
|
||||
-- Мінімальний тариф, з якого плагін доступний (NULL = усі)
|
||||
min_plan_key core.slug,
|
||||
checksum bytea, -- sha256 бінарника/бандла
|
||||
is_core boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE core.plugin_installs (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
plugin_key core.slug NOT NULL REFERENCES core.plugins(key) ON DELETE CASCADE,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, plugin_key)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Агенти / зонди
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE core.agent_status AS ENUM ('pending','online','degraded','offline','disabled');
|
||||
|
||||
CREATE TABLE core.agents (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
-- Автентифікація агента: sha256(enrollment/agent token). Тільки вихідний gRPC/TLS.
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
fingerprint text, -- TLS client cert SPKI pin
|
||||
version text,
|
||||
os text,
|
||||
arch text,
|
||||
hostname text,
|
||||
public_ip inet,
|
||||
status core.agent_status NOT NULL DEFAULT 'pending',
|
||||
last_heartbeat_at timestamptz,
|
||||
-- Які модулі активовані сервером на цьому агенті
|
||||
enabled_modules core.slug[] NOT NULL DEFAULT '{icmp}',
|
||||
-- Ліміти опитування: {"max_concurrency":256,"icmp_rate_pps":500}
|
||||
limits jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Останні самометрики агента (RAM/CPU/черга) для швидкого показу в UI
|
||||
health jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX agents_tenant_status_idx ON core.agents (tenant_id, status);
|
||||
CREATE TRIGGER trg_agents_touch BEFORE UPDATE ON core.agents
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
-- Відкладений FK з 0002 (devices.agent_id)
|
||||
ALTER TABLE inv.devices
|
||||
ADD CONSTRAINT devices_agent_fk
|
||||
FOREIGN KEY (agent_id) REFERENCES core.agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Завдання опитування (те, що агент реально виконує)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE core.check_types (
|
||||
-- Ключ у формі "<plugin>.<check>" — крапка не проходить core.slug, тому власний CHECK
|
||||
key text PRIMARY KEY CHECK (key ~ '^[a-z0-9]+(\.[a-z0-9_]+)+$'),
|
||||
-- icmp.ping, snmp.get, snmp.walk, http.status,
|
||||
plugin_key core.slug NOT NULL REFERENCES core.plugins(key) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
-- JSON Schema параметрів + перелік метрик, які повертає чек
|
||||
params_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metrics jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE core.checks (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
|
||||
check_type text NOT NULL REFERENCES core.check_types(key) ON DELETE RESTRICT,
|
||||
params jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
interval_sec int NOT NULL DEFAULT 60 CHECK (interval_sec BETWEEN 5 AND 86400),
|
||||
timeout_ms int NOT NULL DEFAULT 3000,
|
||||
retries int NOT NULL DEFAULT 2,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
-- Стан планувальника
|
||||
last_run_at timestamptz,
|
||||
next_run_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX checks_device_idx ON core.checks (device_id);
|
||||
CREATE INDEX checks_schedule_idx ON core.checks (next_run_at) WHERE enabled;
|
||||
CREATE UNIQUE INDEX checks_uniq
|
||||
ON core.checks (device_id, check_type, COALESCE(interface_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(params::text));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Event Bus (outbox): усе, що йде у Redis/WebSocket, спершу лягає сюди
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE core.event_outbox (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
topic text NOT NULL, -- device.status, link.status, alert.fired, ncm.diff
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz
|
||||
);
|
||||
CREATE INDEX event_outbox_pending_idx ON core.event_outbox (id) WHERE published_at IS NULL;
|
||||
348
db/migrations/0004_topology.sql
Normal file
348
db/migrations/0004_topology.sql
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0004_topology.sql
|
||||
-- Мапи, вузли з координатами, зв'язки port->port, підкладки (floor plans),
|
||||
-- сире автовиявлення (LLDP/CDP/ARP/FDB) та зведені фізичні лінки.
|
||||
-- =====================================================================
|
||||
|
||||
-- =====================================================================
|
||||
-- ЧАСТИНА A. ФІЗИЧНА ТОПОЛОГІЯ (те, що існує в мережі)
|
||||
-- Не залежить від мап: один лінк може бути показаний на N мапах.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TYPE topo.discovery_proto AS ENUM ('lldp','cdp','arp','fdb','stp','routing','manual','snmp_topo');
|
||||
|
||||
-- Сирі сусіди, як їх повідомив агент. Історія перезаписується per (interface, proto).
|
||||
CREATE TABLE topo.neighbors (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
|
||||
proto topo.discovery_proto NOT NULL,
|
||||
|
||||
-- Те, що бачимо "з іншого боку" (може ще не бути в інвентарі)
|
||||
remote_chassis_id text,
|
||||
remote_system_name text,
|
||||
remote_port_id text,
|
||||
remote_port_descr text,
|
||||
remote_mgmt_ip inet,
|
||||
remote_mac macaddr,
|
||||
remote_platform text,
|
||||
remote_capabilities text[], -- ['bridge','router','wlan-ap']
|
||||
|
||||
-- Резолвлений збіг з інвентарем (заповнює сервер-резолвер)
|
||||
resolved_device_id uuid REFERENCES inv.devices(id) ON DELETE SET NULL,
|
||||
resolved_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
|
||||
confidence smallint NOT NULL DEFAULT 0 CHECK (confidence BETWEEN 0 AND 100),
|
||||
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
raw jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE UNIQUE INDEX neighbors_uniq
|
||||
ON topo.neighbors (device_id, COALESCE(interface_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
proto, COALESCE(remote_chassis_id,''), COALESCE(remote_port_id,''));
|
||||
CREATE INDEX neighbors_tenant_idx ON topo.neighbors (tenant_id, last_seen_at DESC);
|
||||
CREATE INDEX neighbors_resolved_idx ON topo.neighbors (resolved_device_id);
|
||||
|
||||
-- Зведений фізичний/логічний лінк між двома портами.
|
||||
CREATE TYPE topo.link_kind AS ENUM ('physical','lag','wireless','vpn','stack','logical','virtual','power','serial');
|
||||
|
||||
CREATE TABLE topo.links (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
|
||||
a_device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
a_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
|
||||
b_device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
b_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
|
||||
|
||||
kind topo.link_kind NOT NULL DEFAULT 'physical',
|
||||
-- Пропускна здатність каналу (для % завантаження та швидкості анімації)
|
||||
capacity_bps bigint,
|
||||
discovered_by topo.discovery_proto NOT NULL DEFAULT 'manual',
|
||||
confidence smallint NOT NULL DEFAULT 100 CHECK (confidence BETWEEN 0 AND 100),
|
||||
-- Підтверджений людиною лінк не перезаписується автовиявленням
|
||||
is_pinned boolean NOT NULL DEFAULT false,
|
||||
|
||||
status inv.device_status NOT NULL DEFAULT 'unknown',
|
||||
status_changed_at timestamptz,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
CHECK (a_device_id <> b_device_id OR a_interface_id IS DISTINCT FROM b_interface_id)
|
||||
);
|
||||
-- Нормалізований ключ, щоб A->B і B->A не дублювались.
|
||||
CREATE UNIQUE INDEX links_pair_uniq ON topo.links (
|
||||
tenant_id,
|
||||
LEAST(a_device_id, b_device_id),
|
||||
GREATEST(a_device_id, b_device_id),
|
||||
LEAST(COALESCE(a_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(b_interface_id,'00000000-0000-0000-0000-000000000000'::uuid)),
|
||||
GREATEST(COALESCE(a_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(b_interface_id,'00000000-0000-0000-0000-000000000000'::uuid))
|
||||
);
|
||||
CREATE INDEX links_a_idx ON topo.links (a_device_id);
|
||||
CREATE INDEX links_b_idx ON topo.links (b_device_id);
|
||||
CREATE INDEX links_tenant_status_idx ON topo.links (tenant_id, status);
|
||||
|
||||
-- Запуски автовиявлення (для UI "Discovery in progress / знайдено N лінків")
|
||||
CREATE TYPE topo.discovery_status AS ENUM ('queued','running','completed','failed','cancelled');
|
||||
|
||||
CREATE TABLE topo.discovery_runs (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
agent_id uuid REFERENCES core.agents(id) ON DELETE SET NULL,
|
||||
started_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
scope jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"subnets":["10.0.0.0/24"],"protos":["lldp","cdp"]}
|
||||
status topo.discovery_status NOT NULL DEFAULT 'queued',
|
||||
devices_found int NOT NULL DEFAULT 0,
|
||||
links_found int NOT NULL DEFAULT 0,
|
||||
error text,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX discovery_runs_tenant_idx ON topo.discovery_runs (tenant_id, created_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- ЧАСТИНА B. ВІЗУАЛЬНІ МАПИ (те, що бачить користувач)
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TYPE topo.map_kind AS ENUM ('logical','floor_plan','rack','geo','auto');
|
||||
CREATE TYPE topo.layout_algo AS ENUM ('manual','grid','tree','circular','force','hierarchical','dagre');
|
||||
|
||||
CREATE TABLE topo.maps (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
|
||||
parent_map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL, -- drill-down підмапи
|
||||
name text NOT NULL,
|
||||
slug core.slug NOT NULL,
|
||||
kind topo.map_kind NOT NULL DEFAULT 'logical',
|
||||
description text,
|
||||
|
||||
-- Стан полотна
|
||||
layout_algo topo.layout_algo NOT NULL DEFAULT 'manual',
|
||||
viewport jsonb NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'::jsonb,
|
||||
grid jsonb NOT NULL DEFAULT '{"enabled":true,"size":16,"snap":true}'::jsonb,
|
||||
-- Кластеризація при віддаленні: {"enabled":true,"zoom_threshold":0.4,"by":"site"}
|
||||
clustering jsonb NOT NULL DEFAULT '{"enabled":true,"zoom_threshold":0.4}'::jsonb,
|
||||
-- Правила автододавання пристроїв (kind='auto'): фільтр як у dynamic-групах
|
||||
auto_rule jsonb,
|
||||
theme jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
-- Публічний read-only лінк (status page)
|
||||
public_token text UNIQUE,
|
||||
-- Оптимістичне блокування при спільному редагуванні
|
||||
revision bigint NOT NULL DEFAULT 1,
|
||||
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
CREATE INDEX maps_tenant_idx ON topo.maps (tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE TRIGGER trg_maps_touch BEFORE UPDATE ON topo.maps
|
||||
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Підкладки: план офісу / серверної (PNG/SVG), схема стійки, OSM
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE topo.background_kind AS ENUM ('image','svg','rack','osm','tile','none');
|
||||
|
||||
CREATE TABLE topo.map_backgrounds (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
|
||||
kind topo.background_kind NOT NULL DEFAULT 'image',
|
||||
z_index int NOT NULL DEFAULT 0,
|
||||
|
||||
-- Для image/svg: об'єкт у S3/MinIO
|
||||
storage_key text,
|
||||
mime_type text,
|
||||
file_size bigint,
|
||||
natural_width int,
|
||||
natural_height int,
|
||||
|
||||
-- Розміщення підкладки на полотні
|
||||
x double precision NOT NULL DEFAULT 0,
|
||||
y double precision NOT NULL DEFAULT 0,
|
||||
width double precision,
|
||||
height double precision,
|
||||
rotation double precision NOT NULL DEFAULT 0,
|
||||
opacity double precision NOT NULL DEFAULT 1 CHECK (opacity BETWEEN 0 AND 1),
|
||||
locked boolean NOT NULL DEFAULT true,
|
||||
|
||||
-- Для kind='osm'/'tile': географічна прив'язка полотна
|
||||
-- {"center":[50.45,30.52],"zoom":12,"bounds":[[..],[..]],"tile_url":"..."}
|
||||
geo jsonb,
|
||||
-- Для kind='rack': {"units":42,"orientation":"front"}
|
||||
rack jsonb,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX map_backgrounds_map_idx ON topo.map_backgrounds (map_id, z_index);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вузли мапи
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE topo.node_kind AS ENUM (
|
||||
'device', -- прив'язаний до inv.devices
|
||||
'group', -- згорнутий кластер / контейнер
|
||||
'cloud', -- Internet / провайдер
|
||||
'text', -- анотація
|
||||
'shape', -- прямокутник/еліпс/лінія-роздільник
|
||||
'image', -- іконка/логотип
|
||||
'link_map', -- перехід на іншу мапу (drill-down)
|
||||
'metric' -- міні-віджет з графіком/значенням
|
||||
);
|
||||
|
||||
CREATE TABLE topo.map_nodes (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
|
||||
kind topo.node_kind NOT NULL DEFAULT 'device',
|
||||
|
||||
-- Прив'язки (за kind)
|
||||
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
group_id uuid REFERENCES inv.device_groups(id) ON DELETE SET NULL,
|
||||
target_map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL,
|
||||
-- Вкладеність у контейнер/кластер на цій же мапі
|
||||
parent_node_id uuid REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
|
||||
|
||||
label text,
|
||||
-- Геометрія полотна (React Flow / Cytoscape): координати у світових одиницях
|
||||
x double precision NOT NULL DEFAULT 0,
|
||||
y double precision NOT NULL DEFAULT 0,
|
||||
width double precision,
|
||||
height double precision,
|
||||
rotation double precision NOT NULL DEFAULT 0,
|
||||
z_index int NOT NULL DEFAULT 0,
|
||||
|
||||
-- Гео-координати для kind='geo' мап (мають пріоритет над x/y при рендері OSM)
|
||||
lat double precision CHECK (lat BETWEEN -90 AND 90),
|
||||
lon double precision CHECK (lon BETWEEN -180 AND 180),
|
||||
-- Позиція в стійці для kind='rack'
|
||||
rack_unit int,
|
||||
|
||||
-- Вигляд: {"icon":"switch","color":"#22c55e","shape":"rounded","showMetrics":["cpu"]}
|
||||
style jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Довільні дані ноди (текст анотації, конфіг міні-віджета)
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
collapsed boolean NOT NULL DEFAULT false,
|
||||
locked boolean NOT NULL DEFAULT false,
|
||||
hidden boolean NOT NULL DEFAULT false,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Цілісність: device-нода зобов'язана мати device_id, link_map — target_map_id
|
||||
CONSTRAINT map_nodes_kind_ref_chk CHECK (
|
||||
(kind = 'device' AND device_id IS NOT NULL) OR
|
||||
(kind = 'link_map' AND target_map_id IS NOT NULL) OR
|
||||
(kind NOT IN ('device','link_map'))
|
||||
)
|
||||
);
|
||||
-- Один пристрій — одна нода на мапі
|
||||
CREATE UNIQUE INDEX map_nodes_device_uniq ON topo.map_nodes (map_id, device_id)
|
||||
WHERE device_id IS NOT NULL;
|
||||
CREATE INDEX map_nodes_map_idx ON topo.map_nodes (map_id);
|
||||
CREATE INDEX map_nodes_device_idx ON topo.map_nodes (device_id);
|
||||
CREATE INDEX map_nodes_parent_idx ON topo.map_nodes (parent_node_id);
|
||||
-- Просторовий індекс для viewport-culling при 1000+ вузлах
|
||||
CREATE INDEX map_nodes_bbox_idx ON topo.map_nodes USING gist (point(x, y));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Ребра мапи (візуальне представлення лінка)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE topo.edge_style AS ENUM ('straight','bezier','smoothstep','step','orthogonal','arc');
|
||||
CREATE TYPE topo.edge_dash AS ENUM ('solid','dashed','dotted','dashdot');
|
||||
|
||||
CREATE TABLE topo.map_edges (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
|
||||
|
||||
source_node_id uuid NOT NULL REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
|
||||
target_node_id uuid NOT NULL REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
|
||||
-- Конкретні порти: Switch1:Port1 -> Router1:eth0
|
||||
source_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
|
||||
target_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
|
||||
-- Прив'язка до фізичного лінка (звідки беруться статус і трафік)
|
||||
link_id uuid REFERENCES topo.links(id) ON DELETE SET NULL,
|
||||
|
||||
label text,
|
||||
-- Точки прив'язки на ноді: top|right|bottom|left|auto або якір "port:<if_id>"
|
||||
source_handle text,
|
||||
target_handle text,
|
||||
|
||||
style topo.edge_style NOT NULL DEFAULT 'smoothstep',
|
||||
dash topo.edge_dash NOT NULL DEFAULT 'solid',
|
||||
color text,
|
||||
width_px double precision NOT NULL DEFAULT 2,
|
||||
-- Проміжні точки для ручного розведення ліній: [{"x":10,"y":20},...]
|
||||
waypoints jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
-- Анімація трафіку. speed_source: 'utilization' | 'fixed' | 'off'
|
||||
-- {"enabled":true,"speed_source":"utilization","direction":"a_to_b","max_speed":3}
|
||||
animation jsonb NOT NULL DEFAULT '{"enabled":true,"speed_source":"utilization"}'::jsonb,
|
||||
-- Пороги фарбування: {"warn_pct":70,"crit_pct":90,"loss_warn":1,"loss_crit":5}
|
||||
thresholds jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Показувати підпис зі швидкістю/втратами на лінії
|
||||
show_metrics boolean NOT NULL DEFAULT true,
|
||||
|
||||
z_index int NOT NULL DEFAULT 0,
|
||||
locked boolean NOT NULL DEFAULT false,
|
||||
hidden boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (source_node_id <> target_node_id)
|
||||
);
|
||||
CREATE INDEX map_edges_map_idx ON topo.map_edges (map_id);
|
||||
CREATE INDEX map_edges_source_idx ON topo.map_edges (source_node_id);
|
||||
CREATE INDEX map_edges_target_idx ON topo.map_edges (target_node_id);
|
||||
CREATE INDEX map_edges_link_idx ON topo.map_edges (link_id);
|
||||
-- Одне ребро на пару портів у межах мапи
|
||||
CREATE UNIQUE INDEX map_edges_ports_uniq ON topo.map_edges (
|
||||
map_id, source_node_id, target_node_id,
|
||||
COALESCE(source_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(target_interface_id,'00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Версії мап (undo/redo, відкат після невдалого автолейауту)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE topo.map_revisions (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
|
||||
revision bigint NOT NULL,
|
||||
author_id uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
comment text,
|
||||
-- Повний знімок {nodes:[...], edges:[...], backgrounds:[...]} (стиснутий на боці API)
|
||||
snapshot jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (map_id, revision)
|
||||
);
|
||||
CREATE INDEX map_revisions_map_idx ON topo.map_revisions (map_id, revision DESC);
|
||||
|
||||
-- Доступ до мапи окремим користувачам/ролям (понад загальний RBAC)
|
||||
CREATE TABLE topo.map_shares (
|
||||
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES core.users(id) ON DELETE CASCADE,
|
||||
role_id uuid REFERENCES core.roles(id) ON DELETE CASCADE,
|
||||
can_edit boolean NOT NULL DEFAULT false,
|
||||
CHECK (user_id IS NOT NULL OR role_id IS NOT NULL)
|
||||
);
|
||||
CREATE UNIQUE INDEX map_shares_uniq ON topo.map_shares (
|
||||
map_id,
|
||||
COALESCE(user_id,'00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(role_id,'00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
392
db/migrations/0005_telemetry_timescale.sql
Normal file
392
db/migrations/0005_telemetry_timescale.sql
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0005_telemetry_timescale.sql
|
||||
-- Time-series: узагальнені метрики (series/samples), гарячі шляхи
|
||||
-- (ICMP, лічильники інтерфейсів, статус лінків), CAGG, стиснення, retention.
|
||||
--
|
||||
-- УВАГА: цей файл НЕ можна виконувати в одній транзакції —
|
||||
-- CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) заборонено
|
||||
-- всередині транзакційного блоку. migrate.ps1 визначає це автоматично.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- A. Узагальнена модель метрик (Prometheus-подібна): series + samples.
|
||||
-- Будь-який плагін реєструє власний metric_key без зміни DDL.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE ts.series (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
|
||||
plugin_key core.slug,
|
||||
metric_key text NOT NULL, -- cpu.util, mem.used, ups.battery.pct, sensor.temp
|
||||
unit text, -- pct, bps, C, V, ms
|
||||
-- Додаткові виміри: {"core":"0","oid":"...","phase":"L1"}
|
||||
labels jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
labels_hash uuid GENERATED ALWAYS AS (md5(labels::text)::uuid) STORED,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, device_id, metric_key, labels_hash)
|
||||
);
|
||||
CREATE INDEX series_device_metric_idx ON ts.series (device_id, metric_key);
|
||||
CREATE INDEX series_iface_idx ON ts.series (interface_id) WHERE interface_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE ts.samples (
|
||||
ts timestamptz NOT NULL,
|
||||
series_id bigint NOT NULL REFERENCES ts.series(id) ON DELETE CASCADE,
|
||||
value double precision NOT NULL,
|
||||
PRIMARY KEY (ts, series_id)
|
||||
);
|
||||
SELECT create_hypertable('ts.samples', 'ts',
|
||||
chunk_time_interval => INTERVAL '1 day',
|
||||
if_not_exists => TRUE);
|
||||
CREATE INDEX samples_series_ts_idx ON ts.samples (series_id, ts DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- B. ICMP — гарячий шлях: колір ноди й лінії на мапі
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE ts.icmp_samples (
|
||||
ts timestamptz NOT NULL,
|
||||
device_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
agent_id uuid,
|
||||
rtt_avg_ms real,
|
||||
rtt_min_ms real,
|
||||
rtt_max_ms real,
|
||||
jitter_ms real,
|
||||
loss_pct real NOT NULL DEFAULT 0,
|
||||
packets_sent smallint NOT NULL DEFAULT 0,
|
||||
packets_recv smallint NOT NULL DEFAULT 0,
|
||||
reachable boolean NOT NULL,
|
||||
PRIMARY KEY (ts, device_id)
|
||||
);
|
||||
SELECT create_hypertable('ts.icmp_samples', 'ts',
|
||||
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
|
||||
CREATE INDEX icmp_device_ts_idx ON ts.icmp_samples (device_id, ts DESC);
|
||||
CREATE INDEX icmp_tenant_ts_idx ON ts.icmp_samples (tenant_id, ts DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- C. Лічильники інтерфейсів — джерело швидкості анімації трафіку
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE ts.if_counters (
|
||||
ts timestamptz NOT NULL,
|
||||
interface_id uuid NOT NULL,
|
||||
device_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
-- Сирі 64-бітні лічильники (для точного перерахунку після рестарту пристрою)
|
||||
in_octets bigint,
|
||||
out_octets bigint,
|
||||
in_ucast_pkts bigint,
|
||||
out_ucast_pkts bigint,
|
||||
in_errors bigint,
|
||||
out_errors bigint,
|
||||
in_discards bigint,
|
||||
out_discards bigint,
|
||||
-- Похідні швидкості, пораховані агентом (уже з урахуванням wrap/reset)
|
||||
in_bps double precision,
|
||||
out_bps double precision,
|
||||
in_pps double precision,
|
||||
out_pps double precision,
|
||||
-- % завантаження від speed_bps — саме це керує анімацією та кольором лінії
|
||||
util_in_pct real,
|
||||
util_out_pct real,
|
||||
oper_up boolean,
|
||||
PRIMARY KEY (ts, interface_id)
|
||||
);
|
||||
SELECT create_hypertable('ts.if_counters', 'ts',
|
||||
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
|
||||
CREATE INDEX ifc_iface_ts_idx ON ts.if_counters (interface_id, ts DESC);
|
||||
CREATE INDEX ifc_device_ts_idx ON ts.if_counters (device_id, ts DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- D. Статус лінків — те, що WebSocket транслює на мапу
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE ts.link_status (
|
||||
ts timestamptz NOT NULL,
|
||||
link_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
status inv.device_status NOT NULL,
|
||||
latency_ms real,
|
||||
loss_pct real,
|
||||
util_pct real,
|
||||
PRIMARY KEY (ts, link_id)
|
||||
);
|
||||
SELECT create_hypertable('ts.link_status', 'ts',
|
||||
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||
CREATE INDEX link_status_link_ts_idx ON ts.link_status (link_id, ts DESC);
|
||||
|
||||
-- Історія станів пристроїв (SLA / uptime-звіти)
|
||||
CREATE TABLE ts.device_status_history (
|
||||
ts timestamptz NOT NULL,
|
||||
device_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
status inv.device_status NOT NULL,
|
||||
prev_status inv.device_status,
|
||||
reason text,
|
||||
PRIMARY KEY (ts, device_id)
|
||||
);
|
||||
SELECT create_hypertable('ts.device_status_history', 'ts',
|
||||
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
|
||||
CREATE INDEX dsh_device_ts_idx ON ts.device_status_history (device_id, ts DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- E. Syslog / SNMP-трапи (тригер бекапу конфігу в NCM)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE ts.syslog (
|
||||
ts timestamptz NOT NULL,
|
||||
id uuid NOT NULL DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL,
|
||||
device_id uuid,
|
||||
source_ip inet,
|
||||
facility smallint,
|
||||
severity smallint,
|
||||
hostname text,
|
||||
tag text,
|
||||
message text NOT NULL,
|
||||
parsed jsonb,
|
||||
PRIMARY KEY (ts, id)
|
||||
);
|
||||
SELECT create_hypertable('ts.syslog', 'ts',
|
||||
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
|
||||
CREATE INDEX syslog_device_ts_idx ON ts.syslog (device_id, ts DESC);
|
||||
CREATE INDEX syslog_msg_trgm_idx ON ts.syslog USING gin (message gin_trgm_ops);
|
||||
|
||||
CREATE TABLE ts.snmp_traps (
|
||||
ts timestamptz NOT NULL,
|
||||
id uuid NOT NULL DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL,
|
||||
device_id uuid,
|
||||
source_ip inet,
|
||||
trap_oid text,
|
||||
varbinds jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (ts, id)
|
||||
);
|
||||
SELECT create_hypertable('ts.snmp_traps', 'ts',
|
||||
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||
CREATE INDEX traps_device_ts_idx ON ts.snmp_traps (device_id, ts DESC);
|
||||
|
||||
-- Самометрики агентів (RAM < 30 МБ — контролюємо фактом, не обіцянкою)
|
||||
CREATE TABLE ts.agent_health (
|
||||
ts timestamptz NOT NULL,
|
||||
agent_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
cpu_pct real,
|
||||
rss_bytes bigint,
|
||||
goroutines int,
|
||||
queue_depth int,
|
||||
checks_per_sec real,
|
||||
errors_per_min real,
|
||||
PRIMARY KEY (ts, agent_id)
|
||||
);
|
||||
SELECT create_hypertable('ts.agent_health', 'ts',
|
||||
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||
|
||||
-- =====================================================================
|
||||
-- F. CONTINUOUS AGGREGATES
|
||||
-- Графіки за 30 днів не читають сирі дані — лише роллапи.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE MATERIALIZED VIEW ts.icmp_5m
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
|
||||
device_id,
|
||||
tenant_id,
|
||||
avg(rtt_avg_ms)::real AS rtt_avg_ms,
|
||||
min(rtt_min_ms)::real AS rtt_min_ms,
|
||||
max(rtt_max_ms)::real AS rtt_max_ms,
|
||||
avg(jitter_ms)::real AS jitter_ms,
|
||||
avg(loss_pct)::real AS loss_pct,
|
||||
sum(packets_sent) AS packets_sent,
|
||||
sum(packets_recv) AS packets_recv,
|
||||
count(*) FILTER (WHERE NOT reachable) AS down_samples,
|
||||
count(*) AS samples
|
||||
FROM ts.icmp_samples
|
||||
GROUP BY bucket, device_id, tenant_id
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE MATERIALIZED VIEW ts.icmp_1h
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
|
||||
device_id,
|
||||
tenant_id,
|
||||
avg(rtt_avg_ms)::real AS rtt_avg_ms,
|
||||
min(rtt_min_ms)::real AS rtt_min_ms,
|
||||
max(rtt_max_ms)::real AS rtt_max_ms,
|
||||
avg(loss_pct)::real AS loss_pct,
|
||||
sum(down_samples) AS down_samples,
|
||||
sum(samples) AS samples
|
||||
FROM ts.icmp_5m
|
||||
GROUP BY 1, 2, 3
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE MATERIALIZED VIEW ts.if_counters_5m
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
|
||||
interface_id,
|
||||
device_id,
|
||||
tenant_id,
|
||||
avg(in_bps) AS in_bps_avg,
|
||||
max(in_bps) AS in_bps_max,
|
||||
avg(out_bps) AS out_bps_avg,
|
||||
max(out_bps) AS out_bps_max,
|
||||
avg(util_in_pct)::real AS util_in_avg,
|
||||
max(util_in_pct)::real AS util_in_max,
|
||||
avg(util_out_pct)::real AS util_out_avg,
|
||||
max(util_out_pct)::real AS util_out_max,
|
||||
sum(in_errors) AS in_errors,
|
||||
sum(out_errors) AS out_errors,
|
||||
sum(in_discards) AS in_discards,
|
||||
sum(out_discards) AS out_discards
|
||||
FROM ts.if_counters
|
||||
GROUP BY bucket, interface_id, device_id, tenant_id
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE MATERIALIZED VIEW ts.if_counters_1h
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
|
||||
interface_id, device_id, tenant_id,
|
||||
avg(in_bps_avg) AS in_bps_avg,
|
||||
max(in_bps_max) AS in_bps_max,
|
||||
avg(out_bps_avg) AS out_bps_avg,
|
||||
max(out_bps_max) AS out_bps_max,
|
||||
avg(util_in_avg)::real AS util_in_avg,
|
||||
max(util_in_max)::real AS util_in_max,
|
||||
avg(util_out_avg)::real AS util_out_avg,
|
||||
max(util_out_max)::real AS util_out_max
|
||||
FROM ts.if_counters_5m
|
||||
GROUP BY 1, 2, 3, 4
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE MATERIALIZED VIEW ts.samples_5m
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
|
||||
series_id,
|
||||
avg(value) AS avg_value,
|
||||
min(value) AS min_value,
|
||||
max(value) AS max_value,
|
||||
last(value, ts) AS last_value,
|
||||
count(*) AS samples
|
||||
FROM ts.samples
|
||||
GROUP BY bucket, series_id
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE MATERIALIZED VIEW ts.samples_1h
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
|
||||
series_id,
|
||||
avg(avg_value) AS avg_value,
|
||||
min(min_value) AS min_value,
|
||||
max(max_value) AS max_value,
|
||||
sum(samples) AS samples
|
||||
FROM ts.samples_5m
|
||||
GROUP BY 1, 2
|
||||
WITH NO DATA;
|
||||
|
||||
-- Політики оновлення роллапів
|
||||
SELECT add_continuous_aggregate_policy('ts.icmp_5m',
|
||||
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
|
||||
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
|
||||
SELECT add_continuous_aggregate_policy('ts.icmp_1h',
|
||||
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
|
||||
SELECT add_continuous_aggregate_policy('ts.if_counters_5m',
|
||||
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
|
||||
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
|
||||
SELECT add_continuous_aggregate_policy('ts.if_counters_1h',
|
||||
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
|
||||
SELECT add_continuous_aggregate_policy('ts.samples_5m',
|
||||
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
|
||||
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
|
||||
SELECT add_continuous_aggregate_policy('ts.samples_1h',
|
||||
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
|
||||
|
||||
-- =====================================================================
|
||||
-- G. СТИСНЕННЯ ТА RETENTION
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE ts.samples SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'series_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE ts.icmp_samples SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'device_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE ts.if_counters SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'interface_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE ts.link_status SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'link_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE ts.syslog SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'tenant_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE ts.snmp_traps SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'tenant_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE ts.agent_health SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'agent_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
ALTER TABLE core.audit_log SET (timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'tenant_id',
|
||||
timescaledb.compress_orderby = 'ts DESC');
|
||||
|
||||
SELECT add_compression_policy('ts.samples', INTERVAL '2 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('ts.icmp_samples', INTERVAL '2 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('ts.if_counters', INTERVAL '2 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('ts.link_status', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('ts.syslog', INTERVAL '3 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('ts.snmp_traps', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('ts.agent_health', INTERVAL '3 days', if_not_exists => TRUE);
|
||||
SELECT add_compression_policy('core.audit_log', INTERVAL '30 days', if_not_exists => TRUE);
|
||||
|
||||
-- Retention: значення за замовчуванням для Pro. Для Enterprise
|
||||
-- застосунок перевизначає політику через remove_retention_policy/add_retention_policy.
|
||||
SELECT add_retention_policy('ts.samples', INTERVAL '35 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.icmp_samples', INTERVAL '35 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.if_counters', INTERVAL '35 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.syslog', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.snmp_traps', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.agent_health', INTERVAL '14 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.samples_5m', INTERVAL '400 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.if_counters_5m', INTERVAL '400 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('ts.icmp_5m', INTERVAL '400 days', if_not_exists => TRUE);
|
||||
-- 1h-роллапи не видаляємо: це база для SLA-звітів і білінгових графіків.
|
||||
|
||||
-- =====================================================================
|
||||
-- H. ХЕЛПЕРИ ДЛЯ РЕНДЕРУ МАПИ
|
||||
-- Один запит на весь стан полотна замість N+1.
|
||||
-- =====================================================================
|
||||
|
||||
-- Останній ICMP-семпл кожного пристрою (SkipScan по (device_id, ts DESC))
|
||||
CREATE OR REPLACE VIEW ts.device_last_icmp AS
|
||||
SELECT DISTINCT ON (device_id)
|
||||
device_id, ts, rtt_avg_ms, loss_pct, jitter_ms, reachable
|
||||
FROM ts.icmp_samples
|
||||
WHERE ts > now() - INTERVAL '15 minutes'
|
||||
ORDER BY device_id, ts DESC;
|
||||
|
||||
-- Поточне завантаження лінка: беремо гіршу з двох сторін
|
||||
CREATE OR REPLACE VIEW topo.link_live AS
|
||||
SELECT l.id AS link_id,
|
||||
l.tenant_id,
|
||||
l.status,
|
||||
l.capacity_bps,
|
||||
GREATEST(COALESCE(a.util_out_pct, 0), COALESCE(b.util_out_pct, 0)) AS util_pct,
|
||||
COALESCE(a.out_bps, 0) AS a_out_bps,
|
||||
COALESCE(b.out_bps, 0) AS b_out_bps,
|
||||
GREATEST(a.ts, b.ts) AS ts
|
||||
FROM topo.links l
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM ts.if_counters c
|
||||
WHERE c.interface_id = l.a_interface_id AND c.ts > now() - INTERVAL '15 minutes'
|
||||
ORDER BY c.ts DESC LIMIT 1
|
||||
) a ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM ts.if_counters c
|
||||
WHERE c.interface_id = l.b_interface_id AND c.ts > now() - INTERVAL '15 minutes'
|
||||
ORDER BY c.ts DESC LIMIT 1
|
||||
) b ON true;
|
||||
205
db/migrations/0006_ncm.sql
Normal file
205
db/migrations/0006_ncm.sql
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0006_ncm.sql
|
||||
-- Network Config Management: репозиторії, бекапи конфігів, Git-версії,
|
||||
-- diff, шаблони відповідності (compliance), відкат.
|
||||
-- =====================================================================
|
||||
|
||||
-- Один Git-репозиторій на тенанта (bare repo під керуванням libgit2 на сервері).
|
||||
-- Гілка на пристрій: refs/heads/device/<device_id>; файл: <site>/<device>.cfg
|
||||
CREATE TABLE ncm.repos (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
storage_path text NOT NULL, -- /var/lib/netpulse/git/<tenant>.git
|
||||
default_branch text NOT NULL DEFAULT 'main',
|
||||
-- Опційне дзеркалювання на зовнішній Git (GitHub/GitLab/Gitea)
|
||||
remote_url text,
|
||||
remote_secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
|
||||
mirror_enabled boolean NOT NULL DEFAULT false,
|
||||
size_bytes bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
-- Профіль збору для конкретної моделі/вендора:
|
||||
-- які команди виконати, як розпізнати prompt, що вирізати перед diff.
|
||||
CREATE TABLE ncm.profiles (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE, -- NULL = вбудований
|
||||
key core.slug NOT NULL,
|
||||
name text NOT NULL,
|
||||
vendor text,
|
||||
transport inv.credential_proto NOT NULL DEFAULT 'ssh',
|
||||
-- ["terminal length 0","show running-config"]
|
||||
commands jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
prompt_regex text,
|
||||
enable_required boolean NOT NULL DEFAULT false,
|
||||
-- Рядки, що змінюються щоразу (uptime, timestamps, хеші паролів) — не шумимо в diff
|
||||
scrub_patterns jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- Маскування секретів перед збереженням у Git
|
||||
redact_patterns jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_builtin boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX ncm_profiles_key_uniq
|
||||
ON ncm.profiles (COALESCE(tenant_id,'00000000-0000-0000-0000-000000000000'::uuid), key);
|
||||
|
||||
-- Політика бекапу для пристрою (розклад + тригери)
|
||||
CREATE TABLE ncm.device_policies (
|
||||
device_id uuid PRIMARY KEY REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
profile_id uuid REFERENCES ncm.profiles(id) ON DELETE SET NULL,
|
||||
credential_id uuid REFERENCES inv.credentials(id) ON DELETE SET NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
cron text NOT NULL DEFAULT '0 3 * * *',
|
||||
-- Бекап за Syslog-подією зміни конфігу (%SYS-5-CONFIG_I)
|
||||
on_syslog boolean NOT NULL DEFAULT true,
|
||||
syslog_match text,
|
||||
on_trap boolean NOT NULL DEFAULT false,
|
||||
retention_versions int NOT NULL DEFAULT 100,
|
||||
last_backup_at timestamptz,
|
||||
next_backup_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ncm_policies_schedule_idx ON ncm.device_policies (next_backup_at) WHERE enabled;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Задачі збору та їх результати
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE ncm.job_trigger AS ENUM ('schedule','manual','syslog','trap','api','discovery');
|
||||
CREATE TYPE ncm.job_status AS ENUM ('queued','running','success','failed','unchanged','timeout');
|
||||
|
||||
CREATE TABLE ncm.jobs (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
agent_id uuid REFERENCES core.agents(id) ON DELETE SET NULL,
|
||||
trigger ncm.job_trigger NOT NULL,
|
||||
status ncm.job_status NOT NULL DEFAULT 'queued',
|
||||
requested_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
duration_ms int,
|
||||
error text,
|
||||
log text, -- транскрипт сесії (для діагностики)
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ncm_jobs_device_idx ON ncm.jobs (device_id, created_at DESC);
|
||||
CREATE INDEX ncm_jobs_pending_idx ON ncm.jobs (status, created_at) WHERE status IN ('queued','running');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Версії конфігів. Тіло живе в Git; тут — метадані та індекс для пошуку.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE ncm.configs (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
repo_id uuid NOT NULL REFERENCES ncm.repos(id) ON DELETE CASCADE,
|
||||
job_id uuid REFERENCES ncm.jobs(id) ON DELETE SET NULL,
|
||||
|
||||
-- Git-координати
|
||||
commit_sha text NOT NULL, -- 40 hex
|
||||
blob_sha text NOT NULL,
|
||||
branch text NOT NULL,
|
||||
path text NOT NULL, -- kyiv-dc1/core-sw-01/running.cfg
|
||||
-- Тип зрізу: running | startup | vlan | license | inventory
|
||||
config_type text NOT NULL DEFAULT 'running',
|
||||
|
||||
size_bytes int NOT NULL,
|
||||
line_count int,
|
||||
-- sha256 нормалізованого (після scrub) тексту — швидка перевірка "змінилось?"
|
||||
content_hash bytea NOT NULL,
|
||||
-- Зашифрований AES-GCM-256 кеш тіла для миттєвого diff без читання Git
|
||||
body_secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
|
||||
|
||||
-- Порівняння з попередньою версією
|
||||
prev_config_id uuid REFERENCES ncm.configs(id) ON DELETE SET NULL,
|
||||
lines_added int NOT NULL DEFAULT 0,
|
||||
lines_removed int NOT NULL DEFAULT 0,
|
||||
is_change boolean NOT NULL DEFAULT false,
|
||||
|
||||
collected_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (device_id, config_type, commit_sha)
|
||||
);
|
||||
CREATE INDEX ncm_configs_device_time_idx ON ncm.configs (device_id, config_type, collected_at DESC);
|
||||
CREATE INDEX ncm_configs_changes_idx ON ncm.configs (tenant_id, collected_at DESC) WHERE is_change;
|
||||
CREATE INDEX ncm_configs_hash_idx ON ncm.configs (device_id, content_hash);
|
||||
|
||||
-- Кешовані diff-и для UI (щоб не рахувати щоразу при відкритті Telegram Mini App)
|
||||
CREATE TABLE ncm.diffs (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
from_config_id uuid NOT NULL REFERENCES ncm.configs(id) ON DELETE CASCADE,
|
||||
to_config_id uuid NOT NULL REFERENCES ncm.configs(id) ON DELETE CASCADE,
|
||||
format text NOT NULL DEFAULT 'unified', -- unified | side_by_side | json_hunks
|
||||
-- [{"old_start":12,"old_lines":3,"new_start":12,"new_lines":4,"lines":[...]}]
|
||||
hunks jsonb NOT NULL,
|
||||
lines_added int NOT NULL DEFAULT 0,
|
||||
lines_removed int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (from_config_id, to_config_id, format)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Compliance: правила відповідності конфігів (Enterprise)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE ncm.rule_kind AS ENUM ('must_contain','must_not_contain','regex_match','regex_absent','jsonpath');
|
||||
CREATE TYPE ncm.rule_severity AS ENUM ('info','low','medium','high','critical');
|
||||
|
||||
CREATE TABLE ncm.compliance_rules (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
kind ncm.rule_kind NOT NULL,
|
||||
pattern text NOT NULL,
|
||||
severity ncm.rule_severity NOT NULL DEFAULT 'medium',
|
||||
-- До яких пристроїв застосовувати (фільтр як у dynamic-групах)
|
||||
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
remediation text, -- підказка "як виправити"
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE ncm.compliance_results (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
rule_id uuid NOT NULL REFERENCES ncm.compliance_rules(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
config_id uuid REFERENCES ncm.configs(id) ON DELETE SET NULL,
|
||||
passed boolean NOT NULL,
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
checked_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (rule_id, device_id)
|
||||
);
|
||||
CREATE INDEX ncm_compliance_failed_idx ON ncm.compliance_results (tenant_id, checked_at DESC)
|
||||
WHERE NOT passed;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Відкат конфігурації (push назад на пристрій) — небезпечна операція,
|
||||
-- тому окрема сутність із двоетапним підтвердженням.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE ncm.rollback_status AS ENUM ('draft','awaiting_approval','approved','applying','applied','failed','rejected');
|
||||
|
||||
CREATE TABLE ncm.rollbacks (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
target_config_id uuid NOT NULL REFERENCES ncm.configs(id) ON DELETE RESTRICT,
|
||||
status ncm.rollback_status NOT NULL DEFAULT 'draft',
|
||||
-- Команди, які реально підуть на пристрій (мінімальний набір змін)
|
||||
commands jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
requested_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
approved_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
approved_at timestamptz,
|
||||
applied_at timestamptz,
|
||||
result_log text,
|
||||
error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ncm_rollbacks_device_idx ON ncm.rollbacks (device_id, created_at DESC);
|
||||
233
db/migrations/0007_alerting.sql
Normal file
233
db/migrations/0007_alerting.sql
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0007_alerting.sql
|
||||
-- Правила тригерів, алерти, ескалації, канали доставки (Telegram/PWA Push),
|
||||
-- вікна обслуговування, придушення шуму.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TYPE alr.severity AS ENUM ('info','warning','average','high','disaster');
|
||||
CREATE TYPE alr.alert_state AS ENUM ('firing','acknowledged','suppressed','resolved','expired');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Правила
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE alr.rule_source AS ENUM ('metric','icmp','interface','link','syslog','trap','ncm','agent','compliance');
|
||||
|
||||
CREATE TABLE alr.rules (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
source alr.rule_source NOT NULL,
|
||||
severity alr.severity NOT NULL DEFAULT 'warning',
|
||||
|
||||
-- До чого застосовується: пристрої/групи/теги (той самий формат фільтра)
|
||||
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Умова. Приклади:
|
||||
-- metric: {"metric_key":"cpu.util","agg":"avg","window":"5m","op":">","value":85}
|
||||
-- icmp: {"op":"loss_pct >","value":20,"for":"3m"}
|
||||
-- interface: {"metric":"util_out_pct","op":">","value":90,"for":"10m"}
|
||||
-- syslog: {"regex":"%LINK-3-UPDOWN.*down"}
|
||||
condition jsonb NOT NULL,
|
||||
-- Гістерезис: умова зняття алерту (якщо NULL — інверсія condition)
|
||||
recovery jsonb,
|
||||
-- Скільки триматися умові перед firing (антифлап)
|
||||
for_seconds int NOT NULL DEFAULT 60,
|
||||
-- Не піднімати алерт, якщо батьківський вузол на мапі вже down
|
||||
depends_on_topology boolean NOT NULL DEFAULT true,
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX alr_rules_tenant_idx ON alr.rules (tenant_id) WHERE enabled;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Алерти
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE alr.alerts (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
rule_id uuid REFERENCES alr.rules(id) ON DELETE SET NULL,
|
||||
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
|
||||
link_id uuid REFERENCES topo.links(id) ON DELETE CASCADE,
|
||||
|
||||
severity alr.severity NOT NULL,
|
||||
state alr.alert_state NOT NULL DEFAULT 'firing',
|
||||
title text NOT NULL,
|
||||
message text,
|
||||
-- Ключ дедуплікації: rule_id + object → один активний алерт
|
||||
dedup_key text NOT NULL,
|
||||
value double precision,
|
||||
threshold double precision,
|
||||
context jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Кореляція: кореневий алерт (маршрутизатор впав → 40 пристроїв за ним)
|
||||
root_alert_id uuid REFERENCES alr.alerts(id) ON DELETE SET NULL,
|
||||
suppressed_by text, -- 'maintenance' | 'topology' | 'dependency'
|
||||
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
acked_at timestamptz,
|
||||
acked_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
ack_comment text,
|
||||
resolved_at timestamptz,
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
notify_count int NOT NULL DEFAULT 0
|
||||
);
|
||||
-- Один активний алерт на dedup_key
|
||||
CREATE UNIQUE INDEX alerts_active_dedup_uniq ON alr.alerts (tenant_id, dedup_key)
|
||||
WHERE state IN ('firing','acknowledged','suppressed');
|
||||
CREATE INDEX alerts_tenant_state_idx ON alr.alerts (tenant_id, state, severity, started_at DESC);
|
||||
CREATE INDEX alerts_device_idx ON alr.alerts (device_id, started_at DESC);
|
||||
CREATE INDEX alerts_link_idx ON alr.alerts (link_id) WHERE state = 'firing';
|
||||
CREATE INDEX alerts_root_idx ON alr.alerts (root_alert_id);
|
||||
|
||||
-- Архів (після resolve алерти переносяться сюди фоном; hypertable)
|
||||
CREATE TABLE alr.alerts_history (
|
||||
ts timestamptz NOT NULL,
|
||||
id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
rule_id uuid,
|
||||
device_id uuid,
|
||||
severity alr.severity NOT NULL,
|
||||
title text NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
resolved_at timestamptz,
|
||||
duration_sec int,
|
||||
acked_by uuid,
|
||||
context jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (ts, id)
|
||||
);
|
||||
SELECT create_hypertable('alr.alerts_history', 'ts',
|
||||
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
|
||||
CREATE INDEX alerts_hist_tenant_idx ON alr.alerts_history (tenant_id, ts DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Канали доставки
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE alr.channel_kind AS ENUM (
|
||||
'email','telegram','webhook','slack','discord','sms','web_push','pagerduty','mattermost','viber'
|
||||
);
|
||||
|
||||
CREATE TABLE alr.channels (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
kind alr.channel_kind NOT NULL,
|
||||
name text NOT NULL,
|
||||
-- Несекретна частина: {"chat_id":-100123,"thread_id":5} / {"url":"https://..."}
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
|
||||
-- Кастомний шаблон повідомлення (Go template / Handlebars)
|
||||
template text,
|
||||
min_severity alr.severity NOT NULL DEFAULT 'warning',
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
-- Підписки PWA Web Push (на користувача, не на тенант)
|
||||
CREATE TABLE alr.push_subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
||||
endpoint text NOT NULL,
|
||||
p256dh text NOT NULL,
|
||||
auth text NOT NULL,
|
||||
user_agent text,
|
||||
last_success_at timestamptz,
|
||||
failure_count int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, endpoint)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Маршрутизація та ескалація
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE alr.escalation_policies (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
-- Кому і коли: [{"after_min":0,"channels":[...]},{"after_min":15,"channels":[...]}]
|
||||
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
repeat_after_min int,
|
||||
max_repeats int NOT NULL DEFAULT 3,
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE alr.routes (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
priority int NOT NULL DEFAULT 100,
|
||||
-- Фільтр: {"severity_gte":"high","tags":{"env":"prod"},"site_ids":[...]}
|
||||
matcher jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
policy_id uuid REFERENCES alr.escalation_policies(id) ON DELETE SET NULL,
|
||||
channel_ids uuid[] NOT NULL DEFAULT '{}',
|
||||
-- Тихі години: {"tz":"Europe/Kyiv","quiet":[{"days":[6,0],"from":"22:00","to":"08:00"}]}
|
||||
schedule jsonb,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
-- Журнал доставки
|
||||
CREATE TYPE alr.delivery_status AS ENUM ('queued','sent','failed','throttled','skipped');
|
||||
|
||||
CREATE TABLE alr.notifications (
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
id uuid NOT NULL DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL,
|
||||
alert_id uuid,
|
||||
channel_id uuid,
|
||||
user_id uuid,
|
||||
status alr.delivery_status NOT NULL DEFAULT 'queued',
|
||||
attempt smallint NOT NULL DEFAULT 1,
|
||||
error text,
|
||||
-- Для Telegram: message_id, щоб потім редагувати кнопки Ack/Mute
|
||||
external_id text,
|
||||
payload jsonb,
|
||||
PRIMARY KEY (ts, id)
|
||||
);
|
||||
SELECT create_hypertable('alr.notifications', 'ts',
|
||||
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||
CREATE INDEX notif_alert_idx ON alr.notifications (alert_id, ts DESC);
|
||||
SELECT add_retention_policy('alr.notifications', INTERVAL '90 days', if_not_exists => TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вікна обслуговування (алерти придушуються, SLA не псується)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE alr.maintenance_windows (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
period tstzrange NOT NULL,
|
||||
-- Повторюваність (RRULE, RFC 5545) для регулярних вікон
|
||||
rrule text,
|
||||
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
suppress_notifications boolean NOT NULL DEFAULT true,
|
||||
exclude_from_sla boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX mw_active_idx ON alr.maintenance_windows USING gist (tenant_id, period);
|
||||
|
||||
-- Ручне придушення конкретного об'єкта ("Mute" з Telegram Mini App)
|
||||
CREATE TABLE alr.mutes (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
link_id uuid REFERENCES topo.links(id) ON DELETE CASCADE,
|
||||
rule_id uuid REFERENCES alr.rules(id) ON DELETE CASCADE,
|
||||
until timestamptz NOT NULL,
|
||||
reason text,
|
||||
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (device_id IS NOT NULL OR link_id IS NOT NULL OR rule_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX mutes_active_idx ON alr.mutes (tenant_id, until);
|
||||
102
db/migrations/0008_dashboards.sql
Normal file
102
db/migrations/0008_dashboards.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0008_dashboards.sql
|
||||
-- Дашборди (в т.ч. NOC/TV-режим), віджети, збережені фільтри, SLA-звіти.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TYPE core.dashboard_kind AS ENUM ('standard','noc_tv','mobile','embed');
|
||||
|
||||
CREATE TABLE core.dashboards (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
slug core.slug NOT NULL,
|
||||
kind core.dashboard_kind NOT NULL DEFAULT 'standard',
|
||||
-- Сітка: {"cols":24,"rowHeight":40,"compact":"vertical"}
|
||||
layout jsonb NOT NULL DEFAULT '{"cols":24,"rowHeight":40}'::jsonb,
|
||||
-- TV-режим: автопрокрутка сторінок, темна тема, приховані меню
|
||||
tv_options jsonb NOT NULL DEFAULT '{"rotate_sec":0,"hide_chrome":true,"theme":"dark"}'::jsonb,
|
||||
refresh_sec int NOT NULL DEFAULT 30,
|
||||
time_range jsonb NOT NULL DEFAULT '{"from":"now-6h","to":"now"}'::jsonb,
|
||||
variables jsonb NOT NULL DEFAULT '[]'::jsonb, -- $site, $group для фільтрів
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
public_token text UNIQUE, -- read-only лінк для телевізора в NOC
|
||||
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE TYPE core.widget_kind AS ENUM (
|
||||
'map', -- повноекранна мапа топології
|
||||
'timeseries','gauge','stat','table','heatmap','topn','pie',
|
||||
'alert_list','device_grid','status_matrix','uptime_bar',
|
||||
'syslog_stream','ncm_changes','text','iframe','weather_map'
|
||||
);
|
||||
|
||||
CREATE TABLE core.dashboard_widgets (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
dashboard_id uuid NOT NULL REFERENCES core.dashboards(id) ON DELETE CASCADE,
|
||||
kind core.widget_kind NOT NULL,
|
||||
title text,
|
||||
-- Позиція в сітці
|
||||
grid_x int NOT NULL DEFAULT 0,
|
||||
grid_y int NOT NULL DEFAULT 0,
|
||||
grid_w int NOT NULL DEFAULT 6,
|
||||
grid_h int NOT NULL DEFAULT 4,
|
||||
-- Для kind='map'
|
||||
map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL,
|
||||
-- Джерела даних: [{"metric_key":"cpu.util","selector":{...},"agg":"avg"}]
|
||||
queries jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
options jsonb NOT NULL DEFAULT '{}'::jsonb, -- осі, кольори, пороги, легенда
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX widgets_dashboard_idx ON core.dashboard_widgets (dashboard_id);
|
||||
|
||||
-- Збережені фільтри / представлення інвентарю
|
||||
CREATE TABLE core.saved_views (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES core.users(id) ON DELETE CASCADE, -- NULL = спільний
|
||||
scope text NOT NULL, -- devices | alerts | maps | ncm
|
||||
name text NOT NULL,
|
||||
filter jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
columns jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
sort jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- SLA / uptime-звіти
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE core.sla_targets (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
target_pct numeric(5,3) NOT NULL DEFAULT 99.900,
|
||||
-- 'window' — зарезервоване слово в PostgreSQL, тому period_kind
|
||||
period_kind text NOT NULL DEFAULT 'monthly', -- daily | weekly | monthly | quarterly
|
||||
-- Робочі години, поза якими простій не рахується
|
||||
business_hours jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE core.sla_periods (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
sla_target_id uuid NOT NULL REFERENCES core.sla_targets(id) ON DELETE CASCADE,
|
||||
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
period daterange NOT NULL,
|
||||
uptime_pct numeric(6,3) NOT NULL,
|
||||
downtime_sec bigint NOT NULL DEFAULT 0,
|
||||
maintenance_sec bigint NOT NULL DEFAULT 0,
|
||||
incidents int NOT NULL DEFAULT 0,
|
||||
breached boolean NOT NULL DEFAULT false,
|
||||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (sla_target_id, device_id, period)
|
||||
);
|
||||
CREATE INDEX sla_periods_tenant_idx ON core.sla_periods (tenant_id, period);
|
||||
290
db/migrations/0009_billing_licensing.sql
Normal file
290
db/migrations/0009_billing_licensing.sql
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0009_billing_licensing.sql
|
||||
-- Тарифи, entitlements, підписки (Stripe/Paddle), per-device usage,
|
||||
-- інвойси, ліцензійні ключі RSA-4096 для self-hosted.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Тарифні плани та ліміти
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE bill.plans (
|
||||
key core.slug PRIMARY KEY, -- free, pro, enterprise
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
-- Базова абонплата (може бути 0 при чистому per-device)
|
||||
base_price_cents int NOT NULL DEFAULT 0,
|
||||
-- Ціна за пристрій/місяць у центах: Pro 50..150
|
||||
per_device_cents int NOT NULL DEFAULT 0,
|
||||
currency char(3) NOT NULL DEFAULT 'USD',
|
||||
billing_period text NOT NULL DEFAULT 'monthly', -- monthly | yearly
|
||||
-- Обмеження. NULL = без обмежень.
|
||||
max_devices int,
|
||||
max_maps int,
|
||||
max_map_nodes int,
|
||||
max_agents int,
|
||||
max_users int,
|
||||
metric_retention_days int NOT NULL DEFAULT 35,
|
||||
-- Доступні плагіни/можливості
|
||||
features text[] NOT NULL DEFAULT '{}',
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_public boolean NOT NULL DEFAULT true,
|
||||
stripe_price_id text,
|
||||
paddle_price_id text
|
||||
);
|
||||
|
||||
-- Каталог фіч, на які посилаються plans.features та перевірки в API
|
||||
CREATE TABLE bill.features (
|
||||
key core.slug PRIMARY KEY, -- snmp, lldp_discovery, ncm_git, floor_plans,
|
||||
name text NOT NULL, -- white_label, telegram, custom_roles, netflow
|
||||
description text,
|
||||
plugin_key core.slug REFERENCES core.plugins(key) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Підписки
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE bill.provider AS ENUM ('stripe','paddle','manual','license_key');
|
||||
CREATE TYPE bill.sub_status AS ENUM ('trialing','active','past_due','canceled','unpaid','paused','incomplete');
|
||||
|
||||
CREATE TABLE bill.subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
|
||||
provider bill.provider NOT NULL DEFAULT 'stripe',
|
||||
status bill.sub_status NOT NULL DEFAULT 'trialing',
|
||||
|
||||
external_customer_id text, -- cus_...
|
||||
external_subscription_id text, -- sub_...
|
||||
external_item_id text, -- si_... (для usage-based reporting)
|
||||
|
||||
quantity int NOT NULL DEFAULT 0, -- поточна кількість оплачених пристроїв
|
||||
currency char(3) NOT NULL DEFAULT 'USD',
|
||||
current_period_start timestamptz,
|
||||
current_period_end timestamptz,
|
||||
trial_end timestamptz,
|
||||
cancel_at_period_end boolean NOT NULL DEFAULT false,
|
||||
canceled_at timestamptz,
|
||||
-- Персональні перевизначення лімітів (Enterprise-домовленості)
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX subs_tenant_active_uniq ON bill.subscriptions (tenant_id)
|
||||
WHERE status IN ('trialing','active','past_due','paused');
|
||||
CREATE INDEX subs_external_idx ON bill.subscriptions (external_subscription_id);
|
||||
|
||||
-- Матеріалізовані права доступу тенанта — те, що читає API на кожному запиті.
|
||||
-- Перераховується при зміні підписки/ліцензії. Кеш у Redis, істина тут.
|
||||
CREATE TABLE bill.entitlements (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
|
||||
max_devices int,
|
||||
max_maps int,
|
||||
max_map_nodes int,
|
||||
max_agents int,
|
||||
max_users int,
|
||||
metric_retention_days int NOT NULL DEFAULT 35,
|
||||
features text[] NOT NULL DEFAULT '{}',
|
||||
source bill.provider NOT NULL DEFAULT 'stripe',
|
||||
valid_until timestamptz,
|
||||
-- Grace-період після несплати: доступ read-only, опитування зупинено
|
||||
grace_until timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Облік використання (per-device billing)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Щоденний зріз: скільки пристроїв реально було увімкнено.
|
||||
-- Тарифікація за піком або середнім — рішення застосунку.
|
||||
CREATE TABLE bill.usage_daily (
|
||||
day date NOT NULL,
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
devices_total int NOT NULL DEFAULT 0,
|
||||
devices_billable int NOT NULL DEFAULT 0,
|
||||
devices_peak int NOT NULL DEFAULT 0,
|
||||
agents_active int NOT NULL DEFAULT 0,
|
||||
map_nodes int NOT NULL DEFAULT 0,
|
||||
maps_total int NOT NULL DEFAULT 0,
|
||||
ncm_configs int NOT NULL DEFAULT 0,
|
||||
samples_ingested bigint NOT NULL DEFAULT 0,
|
||||
storage_bytes bigint NOT NULL DEFAULT 0,
|
||||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (day, tenant_id)
|
||||
);
|
||||
CREATE INDEX usage_daily_tenant_idx ON bill.usage_daily (tenant_id, day DESC);
|
||||
|
||||
-- Позиції, відправлені провайдеру як usage records (ідемпотентність)
|
||||
CREATE TABLE bill.usage_reports (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
subscription_id uuid NOT NULL REFERENCES bill.subscriptions(id) ON DELETE CASCADE,
|
||||
period daterange NOT NULL,
|
||||
quantity int NOT NULL,
|
||||
idempotency_key text NOT NULL UNIQUE,
|
||||
reported_at timestamptz,
|
||||
external_id text,
|
||||
error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (subscription_id, period)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Інвойси (B2B PDF)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE bill.invoice_status AS ENUM ('draft','open','paid','void','uncollectible','refunded');
|
||||
|
||||
CREATE TABLE bill.invoices (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
subscription_id uuid REFERENCES bill.subscriptions(id) ON DELETE SET NULL,
|
||||
number text NOT NULL, -- NP-2026-000123
|
||||
status bill.invoice_status NOT NULL DEFAULT 'draft',
|
||||
currency char(3) NOT NULL DEFAULT 'USD',
|
||||
subtotal_cents int NOT NULL DEFAULT 0,
|
||||
tax_cents int NOT NULL DEFAULT 0,
|
||||
total_cents int NOT NULL DEFAULT 0,
|
||||
amount_paid_cents int NOT NULL DEFAULT 0,
|
||||
period daterange,
|
||||
-- Реквізити покупця на момент виписки (юр. вимога — не FK, а знімок)
|
||||
bill_to jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
vat_number text,
|
||||
pdf_storage_key text,
|
||||
external_id text, -- in_... / Paddle txn id
|
||||
issued_at timestamptz,
|
||||
due_at timestamptz,
|
||||
paid_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, number)
|
||||
);
|
||||
CREATE INDEX invoices_tenant_idx ON bill.invoices (tenant_id, created_at DESC);
|
||||
|
||||
CREATE TABLE bill.invoice_lines (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
invoice_id uuid NOT NULL REFERENCES bill.invoices(id) ON DELETE CASCADE,
|
||||
description text NOT NULL,
|
||||
quantity numeric(12,3) NOT NULL DEFAULT 1,
|
||||
unit_price_cents int NOT NULL DEFAULT 0,
|
||||
amount_cents int NOT NULL DEFAULT 0,
|
||||
period daterange,
|
||||
meta jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- Вебхуки платіжних провайдерів: спершу сирий запис, потім обробка.
|
||||
CREATE TABLE bill.payment_events (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
provider bill.provider NOT NULL,
|
||||
external_id text NOT NULL, -- evt_...
|
||||
type text NOT NULL,
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE SET NULL,
|
||||
payload jsonb NOT NULL,
|
||||
signature_ok boolean NOT NULL DEFAULT false,
|
||||
processed_at timestamptz,
|
||||
error text,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, external_id)
|
||||
);
|
||||
CREATE INDEX payment_events_pending_idx ON bill.payment_events (received_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Ліцензійні ключі для self-hosted (підпис RSA-4096)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE bill.license_status AS ENUM ('issued','active','expired','revoked','suspended');
|
||||
|
||||
CREATE TABLE bill.license_keys (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE SET NULL,
|
||||
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
|
||||
-- Ключ, який бачить клієнт (NP-XXXX-...), у БД — лише хеш
|
||||
key_hash bytea NOT NULL UNIQUE,
|
||||
key_prefix text NOT NULL,
|
||||
-- Підписаний payload: {install_id, plan, max_devices, max_map_nodes, features, exp}
|
||||
payload jsonb NOT NULL,
|
||||
signature bytea NOT NULL, -- RSA-4096 PSS SHA-256 над canonical(payload)
|
||||
signing_key_id text NOT NULL, -- ротація ключів підпису
|
||||
status bill.license_status NOT NULL DEFAULT 'issued',
|
||||
|
||||
-- Обмеження, продубльовані для швидких SQL-перевірок
|
||||
max_devices int,
|
||||
max_map_nodes int,
|
||||
features text[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Прив'язка до інсталяції (перший активований install_id фіксується)
|
||||
bound_install_id uuid,
|
||||
issued_to text,
|
||||
issued_at timestamptz NOT NULL DEFAULT now(),
|
||||
activated_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
revoke_reason text
|
||||
);
|
||||
CREATE INDEX license_keys_tenant_idx ON bill.license_keys (tenant_id);
|
||||
CREATE INDEX license_keys_install_idx ON bill.license_keys (bound_install_id);
|
||||
|
||||
-- Пінги self-hosted інсталяцій (online-перевірка ліцензії, опційна)
|
||||
CREATE TABLE bill.license_checkins (
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
license_id uuid NOT NULL,
|
||||
install_id uuid NOT NULL,
|
||||
version text,
|
||||
device_count int,
|
||||
map_node_count int,
|
||||
ip inet,
|
||||
PRIMARY KEY (ts, license_id)
|
||||
);
|
||||
SELECT create_hypertable('bill.license_checkins', 'ts',
|
||||
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('bill.license_checkins', INTERVAL '400 days', if_not_exists => TRUE);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Перевірка лімітів на рівні БД (друга лінія оборони після API)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION bill.assert_device_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
lim int;
|
||||
cnt int;
|
||||
BEGIN
|
||||
SELECT max_devices INTO lim FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
|
||||
IF lim IS NULL THEN
|
||||
RETURN NEW; -- без ліміту або entitlements ще не створені
|
||||
END IF;
|
||||
SELECT count(*) INTO cnt FROM inv.devices
|
||||
WHERE tenant_id = NEW.tenant_id AND deleted_at IS NULL AND enabled;
|
||||
IF cnt >= lim THEN
|
||||
RAISE EXCEPTION 'device limit reached for tenant % (limit %)', NEW.tenant_id, lim
|
||||
USING ERRCODE = 'check_violation', HINT = 'upgrade_plan';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_devices_limit BEFORE INSERT ON inv.devices
|
||||
FOR EACH ROW EXECUTE FUNCTION bill.assert_device_limit();
|
||||
|
||||
CREATE OR REPLACE FUNCTION bill.assert_map_node_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
lim int;
|
||||
cnt int;
|
||||
BEGIN
|
||||
SELECT max_map_nodes INTO lim FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
|
||||
IF lim IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
SELECT count(*) INTO cnt FROM topo.map_nodes WHERE map_id = NEW.map_id;
|
||||
IF cnt >= lim THEN
|
||||
RAISE EXCEPTION 'map node limit reached (limit %)', lim
|
||||
USING ERRCODE = 'check_violation', HINT = 'upgrade_plan';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_map_nodes_limit BEFORE INSERT ON topo.map_nodes
|
||||
FOR EACH ROW EXECUTE FUNCTION bill.assert_map_node_limit();
|
||||
205
db/migrations/0010_seed.sql
Normal file
205
db/migrations/0010_seed.sql
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0010_seed.sql
|
||||
-- Довідники: права, системні ролі, core-плагіни, типи чеків, тарифи, фічі.
|
||||
-- Ідемпотентно (ON CONFLICT DO NOTHING/UPDATE).
|
||||
-- ВАЖЛИВО: виконується ДО 0011_rls.sql — після вмикання FORCE RLS
|
||||
-- навіть власник схеми не зможе вставити рядки з tenant_id IS NULL.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Права доступу
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO core.permissions (key, description) VALUES
|
||||
('devices:read', 'Перегляд пристроїв та метрик'),
|
||||
('devices:write', 'Створення/редагування/видалення пристроїв'),
|
||||
('devices:control', 'Керування опитуванням, ручний запуск чеків'),
|
||||
('maps:read', 'Перегляд мап топології'),
|
||||
('maps:write', 'Редагування мап, вузлів та зв''язків'),
|
||||
('maps:publish', 'Публічні посилання на мапи'),
|
||||
('alerts:read', 'Перегляд алертів'),
|
||||
('alerts:ack', 'Підтвердження та mute алертів'),
|
||||
('alerts:write', 'Редагування правил, каналів, ескалацій'),
|
||||
('ncm:read', 'Перегляд конфігів та diff'),
|
||||
('ncm:write', 'Редагування політик бекапу'),
|
||||
('ncm:rollback', 'Відкат конфігурації на пристрій'),
|
||||
('agents:read', 'Перегляд зондів'),
|
||||
('agents:write', 'Реєстрація та налаштування зондів'),
|
||||
('users:read', 'Перегляд користувачів'),
|
||||
('users:write', 'Запрошення та керування ролями'),
|
||||
('billing:read', 'Перегляд тарифу та інвойсів'),
|
||||
('billing:manage', 'Зміна тарифу, платіжні дані'),
|
||||
('audit:read', 'Перегляд журналу аудиту'),
|
||||
('settings:write', 'Налаштування тенанта, білий лейбл')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Системні ролі (tenant_id IS NULL)
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO core.roles (id, tenant_id, key, name, description, is_system) VALUES
|
||||
('00000000-0000-0000-0000-0000000000a1', NULL, 'owner', 'Власник', 'Повний доступ, включно з білінгом', true),
|
||||
('00000000-0000-0000-0000-0000000000a2', NULL, 'admin', 'Адмін', 'Усе, крім білінгу', true),
|
||||
('00000000-0000-0000-0000-0000000000a3', NULL, 'engineer', 'Інженер', 'Пристрої, мапи, алерти, NCM без rollback', true),
|
||||
('00000000-0000-0000-0000-0000000000a4', NULL, 'operator', 'Оператор', 'Перегляд + ack алертів', true),
|
||||
('00000000-0000-0000-0000-0000000000a5', NULL, 'viewer', 'Глядач', 'Тільки перегляд', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- owner: усі права
|
||||
INSERT INTO core.role_permissions (role_id, permission_key)
|
||||
SELECT '00000000-0000-0000-0000-0000000000a1', key FROM core.permissions
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- admin: усе, крім billing:manage
|
||||
INSERT INTO core.role_permissions (role_id, permission_key)
|
||||
SELECT '00000000-0000-0000-0000-0000000000a2', key FROM core.permissions
|
||||
WHERE key <> 'billing:manage'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO core.role_permissions (role_id, permission_key) VALUES
|
||||
('00000000-0000-0000-0000-0000000000a3','devices:read'),
|
||||
('00000000-0000-0000-0000-0000000000a3','devices:write'),
|
||||
('00000000-0000-0000-0000-0000000000a3','devices:control'),
|
||||
('00000000-0000-0000-0000-0000000000a3','maps:read'),
|
||||
('00000000-0000-0000-0000-0000000000a3','maps:write'),
|
||||
('00000000-0000-0000-0000-0000000000a3','alerts:read'),
|
||||
('00000000-0000-0000-0000-0000000000a3','alerts:ack'),
|
||||
('00000000-0000-0000-0000-0000000000a3','alerts:write'),
|
||||
('00000000-0000-0000-0000-0000000000a3','ncm:read'),
|
||||
('00000000-0000-0000-0000-0000000000a3','ncm:write'),
|
||||
('00000000-0000-0000-0000-0000000000a3','agents:read'),
|
||||
('00000000-0000-0000-0000-0000000000a4','devices:read'),
|
||||
('00000000-0000-0000-0000-0000000000a4','maps:read'),
|
||||
('00000000-0000-0000-0000-0000000000a4','alerts:read'),
|
||||
('00000000-0000-0000-0000-0000000000a4','alerts:ack'),
|
||||
('00000000-0000-0000-0000-0000000000a4','ncm:read'),
|
||||
('00000000-0000-0000-0000-0000000000a5','devices:read'),
|
||||
('00000000-0000-0000-0000-0000000000a5','maps:read'),
|
||||
('00000000-0000-0000-0000-0000000000a5','alerts:read')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Плагіни ядра
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO core.plugins (key, name, version, scope, description, manifest, min_plan_key, is_core) VALUES
|
||||
('icmp', 'ICMP / Ping', '1.0.0', 'agent', 'Доступність, RTT, jitter, втрати',
|
||||
'{"metrics":["icmp.rtt","icmp.loss","icmp.jitter"],"checks":["icmp.ping"]}', 'free', true),
|
||||
('snmp', 'SNMP v2c/v3', '1.0.0', 'agent', 'Опитування OID, інтерфейси, CPU/RAM/сенсори',
|
||||
'{"metrics":["if.*","cpu.util","mem.used","sensor.*"],"checks":["snmp.get","snmp.walk","snmp.if"]}', 'pro', true),
|
||||
('http', 'HTTP/HTTPS/SSL', '1.0.0', 'agent', 'Код відповіді, час, строк дії сертифіката',
|
||||
'{"metrics":["http.status","http.latency","ssl.days_left"],"checks":["http.status","ssl.expiry"]}', 'free', true),
|
||||
('topology', 'Topology Discovery', '1.0.0', 'both', 'LLDP/CDP/ARP/FDB автовиявлення зв''язків',
|
||||
'{"protocols":["lldp","cdp","arp","fdb"],"checks":["topo.discover"]}', 'pro', true),
|
||||
('ncm', 'Config Backup (NCM)', '1.0.0', 'both', 'SSH/Telnet бекап конфігів + Git diff',
|
||||
'{"transports":["ssh","telnet","api"],"checks":["ncm.backup"]}', 'enterprise', true),
|
||||
('modbus', 'Modbus-TCP', '1.0.0', 'agent', 'Інвертори, UPS, BMS',
|
||||
'{"metrics":["modbus.*"],"checks":["modbus.read"]}', 'pro', false),
|
||||
('netflow', 'NetFlow / sFlow', '0.9.0', 'server', 'Аналіз потоків трафіку',
|
||||
'{"metrics":["flow.*"],"collectors":["netflow5","netflow9","ipfix","sflow"]}', 'enterprise', false),
|
||||
('syslog', 'Syslog Collector', '1.0.0', 'server', 'Прийом та парсинг syslog, тригер NCM',
|
||||
'{"ports":[514],"triggers":["ncm.backup"]}', 'pro', true)
|
||||
ON CONFLICT (key) DO UPDATE SET version = EXCLUDED.version, manifest = EXCLUDED.manifest;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Типи чеків
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO core.check_types (key, plugin_key, name, params_schema, metrics) VALUES
|
||||
('icmp.ping', 'icmp', 'ICMP Ping',
|
||||
'{"type":"object","properties":{"count":{"type":"integer","default":3},"packet_size":{"type":"integer","default":56}}}',
|
||||
'["icmp.rtt_avg","icmp.rtt_min","icmp.rtt_max","icmp.loss_pct","icmp.jitter"]'),
|
||||
('snmp.get', 'snmp', 'SNMP Get',
|
||||
'{"type":"object","required":["oids"],"properties":{"oids":{"type":"array","items":{"type":"string"}}}}',
|
||||
'[]'),
|
||||
('snmp.walk', 'snmp', 'SNMP Walk',
|
||||
'{"type":"object","required":["oid"],"properties":{"oid":{"type":"string"},"max_rows":{"type":"integer","default":500}}}',
|
||||
'[]'),
|
||||
('snmp.if', 'snmp', 'SNMP Interfaces',
|
||||
'{"type":"object","properties":{"use_hc_counters":{"type":"boolean","default":true}}}',
|
||||
'["if.in_bps","if.out_bps","if.util_in_pct","if.util_out_pct","if.errors"]'),
|
||||
('http.status', 'http', 'HTTP Status',
|
||||
'{"type":"object","required":["url"],"properties":{"url":{"type":"string"},"expect_status":{"type":"integer","default":200},"keyword":{"type":"string"}}}',
|
||||
'["http.status","http.latency_ms"]'),
|
||||
('ssl.expiry', 'http', 'SSL Certificate Expiry',
|
||||
'{"type":"object","required":["host"],"properties":{"host":{"type":"string"},"port":{"type":"integer","default":443}}}',
|
||||
'["ssl.days_left"]'),
|
||||
('topo.discover','topology','Neighbor Discovery',
|
||||
'{"type":"object","properties":{"protos":{"type":"array","default":["lldp","cdp","arp"]}}}',
|
||||
'[]'),
|
||||
('ncm.backup', 'ncm', 'Config Backup',
|
||||
'{"type":"object","properties":{"config_type":{"type":"string","default":"running"}}}',
|
||||
'[]'),
|
||||
('modbus.read', 'modbus','Modbus Read Registers',
|
||||
'{"type":"object","required":["registers"],"properties":{"unit_id":{"type":"integer","default":1},"registers":{"type":"array"}}}',
|
||||
'[]')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Фічі та тарифи
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO bill.features (key, name, description, plugin_key) VALUES
|
||||
('icmp', 'ICMP-моніторинг', 'Ping, RTT, втрати', 'icmp'),
|
||||
('snmp', 'SNMP v2c/v3', 'Опитування метрик пристроїв', 'snmp'),
|
||||
('http_checks', 'HTTP/SSL-чеки', 'Веб-сервіси та сертифікати', 'http'),
|
||||
('maps_basic', 'Базова мапа', 'Одна мапа, до 15 вузлів', NULL),
|
||||
('maps_unlimited', 'Мапи без обмежень', 'Необмежена кількість мап і вузлів', NULL),
|
||||
('auto_discovery', 'Автовиявлення LLDP/CDP', 'Автопобудова топології', 'topology'),
|
||||
('floor_plans', 'Плани приміщень', 'Підкладки PNG/SVG/OSM, схеми стійок', NULL),
|
||||
('traffic_animation','Анімація трафіку', 'Анімовані лінії за завантаженням каналу', NULL),
|
||||
('telegram', 'Telegram-сповіщення', 'Бот + Mini App', NULL),
|
||||
('web_push', 'PWA Push', 'Web Push-сповіщення', NULL),
|
||||
('ncm_git', 'NCM + Git diff', 'Бекап конфігів і контроль версій', 'ncm'),
|
||||
('ncm_rollback', 'Відкат конфігурації', 'Push конфігу назад на пристрій', 'ncm'),
|
||||
('compliance', 'Compliance-правила', 'Перевірка конфігів на відповідність', 'ncm'),
|
||||
('netflow', 'NetFlow/sFlow', 'Аналіз потоків', 'netflow'),
|
||||
('white_label', 'White-label', 'Власний бренд і домен', NULL),
|
||||
('custom_roles', 'Кастомні ролі', 'Власна матриця прав', NULL),
|
||||
('api_access', 'Публічний API', 'REST/gRPC + вебхуки', NULL),
|
||||
('sla_reports', 'SLA-звіти', 'Uptime-звіти та PDF-експорт', NULL),
|
||||
('sso', 'SSO / SAML', 'Корпоративна автентифікація', NULL)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO bill.plans
|
||||
(key, name, description, base_price_cents, per_device_cents, max_devices, max_maps,
|
||||
max_map_nodes, max_agents, max_users, metric_retention_days, features, sort_order)
|
||||
VALUES
|
||||
('free', 'Free / Community', 'До 15 пристроїв, 1 мапа, базовий ICMP-моніторинг',
|
||||
0, 0, 15, 1, 15, 1, 3, 7,
|
||||
ARRAY['icmp','http_checks','maps_basic','web_push'], 10),
|
||||
|
||||
('pro', 'Pro', 'Per-device білінг: SNMP, мапи без обмежень, автовиявлення, Telegram',
|
||||
0, 100, NULL, NULL, NULL, 10, 25, 90,
|
||||
ARRAY['icmp','snmp','http_checks','maps_basic','maps_unlimited','auto_discovery',
|
||||
'traffic_animation','telegram','web_push','api_access','sla_reports'], 20),
|
||||
|
||||
('enterprise', 'Enterprise / NCM Suite', 'Pro + конфіги з Git diff, плани приміщень, white-label',
|
||||
0, 150, NULL, NULL, NULL, NULL, NULL, 400,
|
||||
ARRAY['icmp','snmp','http_checks','maps_basic','maps_unlimited','auto_discovery',
|
||||
'traffic_animation','telegram','web_push','api_access','sla_reports',
|
||||
'floor_plans','ncm_git','ncm_rollback','compliance','netflow',
|
||||
'white_label','custom_roles','sso'], 30)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
features = EXCLUDED.features,
|
||||
max_devices = EXCLUDED.max_devices,
|
||||
max_map_nodes = EXCLUDED.max_map_nodes,
|
||||
metric_retention_days = EXCLUDED.metric_retention_days;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вбудовані NCM-профілі (tenant_id IS NULL)
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO ncm.profiles (key, name, vendor, transport, commands, prompt_regex, enable_required,
|
||||
scrub_patterns, redact_patterns, is_builtin) VALUES
|
||||
('cisco-ios', 'Cisco IOS', 'cisco', 'ssh',
|
||||
'["terminal length 0","show running-config"]', '[>#]\s*$', true,
|
||||
'["^Building configuration","^Current configuration : \\d+ bytes","^ntp clock-period"]',
|
||||
'["(password|secret) \\d? ?\\S+","snmp-server community \\S+"]', true),
|
||||
('mikrotik-routeros', 'MikroTik RouterOS', 'mikrotik', 'ssh',
|
||||
'["/export terse"]', '\] > $', false,
|
||||
'["^# \\w{3}/\\d{2}/\\d{4}"]', '["password=\\S+"]', true),
|
||||
('juniper-junos', 'Juniper JunOS', 'juniper', 'ssh',
|
||||
'["set cli screen-length 0","show configuration | display set"]', '[%>#]\s*$', false,
|
||||
'["^## Last commit:"]', '["encrypted-password \\S+"]', true),
|
||||
('huawei-vrp', 'Huawei VRP', 'huawei', 'ssh',
|
||||
'["screen-length 0 temporary","display current-configuration"]', '[<\\[].*[>\\]]', false,
|
||||
'[]', '["cipher \\S+"]', true),
|
||||
('bdcom-olt', 'BDCOM OLT', 'bdcom', 'telnet',
|
||||
'["terminal length 0","show running-config"]', '[>#]\s*$', true,
|
||||
'[]', '["(password|secret) \\S+"]', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
112
db/migrations/0011_rls.sql
Normal file
112
db/migrations/0011_rls.sql
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0011_rls.sql
|
||||
-- Ізоляція тенантів через Row Level Security.
|
||||
-- API виставляє SET LOCAL app.tenant_id = '<uuid>' на кожній транзакції.
|
||||
-- =====================================================================
|
||||
|
||||
-- Ролі: власник схеми (міграції) та робоча роль застосунку.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_app') THEN
|
||||
CREATE ROLE netpulse_app NOLOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_worker') THEN
|
||||
-- Воркери (агрегація, білінг, retention) працюють поверх усіх тенантів
|
||||
CREATE ROLE netpulse_worker NOLOGIN BYPASSRLS;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
GRANT USAGE ON SCHEMA core, inv, topo, ts, ncm, alr, bill TO netpulse_app, netpulse_worker;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
|
||||
TO netpulse_app, netpulse_worker;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
|
||||
TO netpulse_app, netpulse_worker;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO netpulse_app, netpulse_worker;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вмикаємо RLS на всіх звичайних таблицях, що мають колонку tenant_id.
|
||||
--
|
||||
-- ВИНЯТОК: усі hypertables. TimescaleDB (перевірено на 2.29.1) відмовляє:
|
||||
-- "operation not supported on hypertables that have columnstore enabled"
|
||||
-- Тобто RLS несумісний зі стисненням (columnstore/hypercore), а стиснення
|
||||
-- увімкнене на всіх гарячих time-series таблицях. Виключаємо гіпертаблі
|
||||
-- цілком — інакше набір захищених таблиць мовчки залежав би від того,
|
||||
-- на які з них уже накотили compression policy.
|
||||
--
|
||||
-- Ізоляція для time-series забезпечується на рівні запитів:
|
||||
-- * доступ лише через JOIN з inv.devices / inv.interfaces / ts.series,
|
||||
-- які під RLS;
|
||||
-- * обов'язковий предикат tenant_id у репозиторному шарі API.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT c.oid::regclass AS tbl, n.nspname, c.relname
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND a.attnum > 0
|
||||
WHERE c.relkind = 'r'
|
||||
AND n.nspname IN ('core','inv','topo','ts','ncm','alr','bill')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM timescaledb_information.hypertables h
|
||||
WHERE h.hypertable_schema = n.nspname
|
||||
AND h.hypertable_name = c.relname
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', r.tbl);
|
||||
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', r.tbl);
|
||||
EXECUTE format($p$
|
||||
CREATE POLICY tenant_isolation ON %s
|
||||
USING (tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant())
|
||||
$p$, r.tbl);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- core.tenants: тенант бачить лише сам себе
|
||||
ALTER TABLE core.tenants ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_self ON core.tenants
|
||||
USING (id = core.current_tenant());
|
||||
|
||||
-- core.users глобальні (один користувач може бути в кількох тенантах):
|
||||
-- видимі лише ті, хто має membership у поточному тенанті.
|
||||
ALTER TABLE core.users ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY users_in_tenant ON core.users
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM core.memberships m
|
||||
WHERE m.user_id = core.users.id AND m.tenant_id = core.current_tenant()
|
||||
));
|
||||
|
||||
-- Довідники без tenant_id читають усі
|
||||
ALTER TABLE core.plugins ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE core.check_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE core.permissions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE bill.plans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE bill.features ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY read_all ON core.plugins FOR SELECT USING (true);
|
||||
CREATE POLICY read_all ON core.check_types FOR SELECT USING (true);
|
||||
CREATE POLICY read_all ON core.permissions FOR SELECT USING (true);
|
||||
CREATE POLICY read_all ON bill.plans FOR SELECT USING (true);
|
||||
CREATE POLICY read_all ON bill.features FOR SELECT USING (true);
|
||||
|
||||
-- core.roles: системні (tenant_id IS NULL) + власні
|
||||
DROP POLICY IF EXISTS tenant_isolation ON core.roles;
|
||||
CREATE POLICY roles_visible ON core.roles
|
||||
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
-- ncm.profiles: вбудовані профілі спільні для всіх
|
||||
DROP POLICY IF EXISTS tenant_isolation ON ncm.profiles;
|
||||
CREATE POLICY profiles_visible ON ncm.profiles
|
||||
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Захист від забутого SET app.tenant_id:
|
||||
-- core.current_tenant() повертає NULL → жоден рядок не пройде політику.
|
||||
-- Це навмисно: "порожній результат" безпечніший за "усі тенанти".
|
||||
-- ---------------------------------------------------------------------
|
||||
302
db/tests/smoke.sql
Normal file
302
db/tests/smoke.sql
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: smoke.sql
|
||||
-- Функціональна перевірка схеми на чистій БД після всіх міграцій.
|
||||
-- Запуск: psql -d netpulse -v ON_ERROR_STOP=1 -f db/tests/smoke.sql
|
||||
-- Виконувати від суперкористувача (частина перевірок робить SET ROLE).
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
\timing off
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1. Два тенанти
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO core.tenants (id, slug, name, status) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'acme', 'ACME ISP', 'active'),
|
||||
('22222222-2222-2222-2222-222222222222', 'globex','Globex MSP', 'active');
|
||||
|
||||
INSERT INTO bill.entitlements (tenant_id, plan_key, max_devices, max_maps, max_map_nodes, features)
|
||||
SELECT '11111111-1111-1111-1111-111111111111', key, max_devices, max_maps, max_map_nodes, features
|
||||
FROM bill.plans WHERE key = 'free';
|
||||
|
||||
INSERT INTO bill.entitlements (tenant_id, plan_key, max_devices, max_maps, max_map_nodes, features)
|
||||
SELECT '22222222-2222-2222-2222-222222222222', key, max_devices, max_maps, max_map_nodes, features
|
||||
FROM bill.plans WHERE key = 'pro';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. Сайт, агент, пристрої, інтерфейси
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO inv.sites (id, tenant_id, name, code, lat, lon) VALUES
|
||||
('33333333-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'Kyiv DC1', 'KYV-DC1', 50.4501, 30.5234);
|
||||
|
||||
INSERT INTO core.agents (id, tenant_id, site_id, name, token_hash, status, enabled_modules) VALUES
|
||||
('44444444-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'33333333-0000-0000-0000-000000000001', 'probe-kyv-01',
|
||||
digest('agent-token-1','sha256'), 'online', '{icmp,snmp,topology}');
|
||||
|
||||
INSERT INTO inv.devices (id, tenant_id, site_id, agent_id, name, address, kind, vendor, chassis_id, system_name) VALUES
|
||||
('55555555-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'33333333-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001',
|
||||
'core-sw-01', '10.0.0.1', 'switch', 'cisco', '0011.2233.4455', 'core-sw-01'),
|
||||
('55555555-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111',
|
||||
'33333333-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001',
|
||||
'edge-rtr-01', '10.0.0.2', 'router', 'mikrotik', '0011.2233.6677', 'edge-rtr-01'),
|
||||
('55555555-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111',
|
||||
'33333333-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001',
|
||||
'ups-01', '10.0.0.3', 'ups', 'apc', NULL, NULL);
|
||||
|
||||
INSERT INTO inv.interfaces (id, tenant_id, device_id, if_index, name, speed_bps, oper_status, is_uplink) VALUES
|
||||
('66666666-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000001', 1, 'GigabitEthernet0/1', 1000000000, 'up', true),
|
||||
('66666666-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000002', 1, 'ether1', 1000000000, 'up', true);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3. Автовиявлення: LLDP-сусід → зведений лінк
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO topo.neighbors (tenant_id, device_id, interface_id, proto,
|
||||
remote_chassis_id, remote_system_name, remote_port_id,
|
||||
resolved_device_id, resolved_interface_id, confidence)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'lldp',
|
||||
'0011.2233.6677', 'edge-rtr-01', 'ether1',
|
||||
'55555555-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002', 95);
|
||||
|
||||
INSERT INTO topo.links (id, tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id,
|
||||
kind, capacity_bps, discovered_by, status)
|
||||
VALUES ('77777777-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001',
|
||||
'55555555-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002',
|
||||
'physical', 1000000000, 'lldp', 'up');
|
||||
|
||||
-- Перевірка нормалізації пари: дзеркальний лінк B→A має впертись в unique index
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO topo.links (tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002',
|
||||
'55555555-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001');
|
||||
RAISE EXCEPTION 'FAIL: дзеркальний лінк B->A створився (нормалізація пари не працює)';
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
RAISE NOTICE 'PASS: дзеркальний лінк B->A відхилено (links_pair_uniq)';
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. Мапа з підкладкою, вузлами та ребром port->port
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO topo.maps (id, tenant_id, site_id, name, slug, kind, layout_algo) VALUES
|
||||
('88888888-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'33333333-0000-0000-0000-000000000001', 'Kyiv DC1 — основна', 'kyiv-dc1', 'floor_plan', 'manual');
|
||||
|
||||
INSERT INTO topo.map_backgrounds (tenant_id, map_id, kind, storage_key, mime_type,
|
||||
natural_width, natural_height, width, height, opacity)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111', '88888888-0000-0000-0000-000000000001',
|
||||
'image', 's3://netpulse/acme/floorplan-dc1.svg', 'image/svg+xml',
|
||||
2400, 1600, 2400, 1600, 0.6);
|
||||
|
||||
INSERT INTO topo.map_nodes (id, tenant_id, map_id, kind, device_id, label, x, y, style) VALUES
|
||||
('99999999-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'88888888-0000-0000-0000-000000000001', 'device', '55555555-0000-0000-0000-000000000001',
|
||||
'core-sw-01', 400, 300, '{"icon":"switch","color":"#22c55e"}'),
|
||||
('99999999-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111',
|
||||
'88888888-0000-0000-0000-000000000001', 'device', '55555555-0000-0000-0000-000000000002',
|
||||
'edge-rtr-01', 800, 300, '{"icon":"router"}'),
|
||||
('99999999-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111',
|
||||
'88888888-0000-0000-0000-000000000001', 'text', NULL,
|
||||
'Серверна №2', 400, 120, '{"fontSize":18}');
|
||||
|
||||
-- Нода kind='device' без device_id має впертись у CHECK
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO topo.map_nodes (tenant_id, map_id, kind, x, y)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111','88888888-0000-0000-0000-000000000001','device',0,0);
|
||||
RAISE EXCEPTION 'FAIL: device-нода без device_id створилась';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
RAISE NOTICE 'PASS: device-нода без device_id відхилена (map_nodes_kind_ref_chk)';
|
||||
END $$;
|
||||
|
||||
INSERT INTO topo.map_edges (tenant_id, map_id, source_node_id, target_node_id,
|
||||
source_interface_id, target_interface_id, link_id,
|
||||
label, style, animation, thresholds)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111', '88888888-0000-0000-0000-000000000001',
|
||||
'99999999-0000-0000-0000-000000000001', '99999999-0000-0000-0000-000000000002',
|
||||
'66666666-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000002',
|
||||
'77777777-0000-0000-0000-000000000001',
|
||||
'Gi0/1 → ether1', 'smoothstep',
|
||||
'{"enabled":true,"speed_source":"utilization","direction":"a_to_b","max_speed":3}',
|
||||
'{"warn_pct":70,"crit_pct":90}');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. Телеметрія
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO ts.icmp_samples (ts, device_id, tenant_id, agent_id, rtt_avg_ms, rtt_min_ms, rtt_max_ms,
|
||||
jitter_ms, loss_pct, packets_sent, packets_recv, reachable)
|
||||
SELECT now() - (g || ' minutes')::interval,
|
||||
'55555555-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'44444444-0000-0000-0000-000000000001',
|
||||
1.2 + g * 0.05, 1.0, 2.5, 0.3, CASE WHEN g = 0 THEN 0 ELSE 0 END, 3, 3, true
|
||||
FROM generate_series(0, 59) g;
|
||||
|
||||
INSERT INTO ts.if_counters (ts, interface_id, device_id, tenant_id,
|
||||
in_octets, out_octets, in_bps, out_bps,
|
||||
util_in_pct, util_out_pct, oper_up)
|
||||
SELECT now() - (g || ' minutes')::interval,
|
||||
'66666666-0000-0000-0000-000000000001', '55555555-0000-0000-0000-000000000001',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
1000000 * g, 900000 * g,
|
||||
420000000, 780000000,
|
||||
42.0, 78.0, true
|
||||
FROM generate_series(0, 59) g;
|
||||
|
||||
-- Узагальнені метрики через series/samples
|
||||
INSERT INTO ts.series (tenant_id, device_id, plugin_key, metric_key, unit, labels)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111','55555555-0000-0000-0000-000000000001',
|
||||
'snmp','cpu.util','pct','{"core":"0"}');
|
||||
|
||||
INSERT INTO ts.samples (ts, series_id, value)
|
||||
SELECT now() - (g || ' minutes')::interval, s.id, 30 + (g % 17)
|
||||
FROM generate_series(0, 59) g,
|
||||
ts.series s
|
||||
WHERE s.metric_key = 'cpu.util';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 6. NCM: репо, конфіг, diff
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO ncm.repos (id, tenant_id, name, storage_path) VALUES
|
||||
('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
|
||||
'acme', '/var/lib/netpulse/git/acme.git');
|
||||
|
||||
INSERT INTO ncm.configs (id, tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path,
|
||||
size_bytes, line_count, content_hash, is_change, lines_added, lines_removed)
|
||||
VALUES
|
||||
('bbbbbbbb-0000-0000-0000-000000000001','11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000001','aaaaaaaa-0000-0000-0000-000000000001',
|
||||
repeat('a',40), repeat('b',40), 'device/core-sw-01', 'kyv-dc1/core-sw-01/running.cfg',
|
||||
4096, 180, digest('config-v1','sha256'), false, 0, 0),
|
||||
('bbbbbbbb-0000-0000-0000-000000000002','11111111-1111-1111-1111-111111111111',
|
||||
'55555555-0000-0000-0000-000000000001','aaaaaaaa-0000-0000-0000-000000000001',
|
||||
repeat('c',40), repeat('d',40), 'device/core-sw-01', 'kyv-dc1/core-sw-01/running.cfg',
|
||||
4130, 183, digest('config-v2','sha256'), true, 4, 1);
|
||||
|
||||
UPDATE ncm.configs SET prev_config_id = 'bbbbbbbb-0000-0000-0000-000000000001'
|
||||
WHERE id = 'bbbbbbbb-0000-0000-0000-000000000002';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 7. Алерт
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO alr.rules (id, tenant_id, name, source, severity, condition, for_seconds) VALUES
|
||||
('cccccccc-0000-0000-0000-000000000001','11111111-1111-1111-1111-111111111111',
|
||||
'Uplink > 90%', 'interface', 'high',
|
||||
'{"metric":"util_out_pct","op":">","value":90}', 600);
|
||||
|
||||
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, interface_id, link_id,
|
||||
severity, title, dedup_key, value, threshold)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111','cccccccc-0000-0000-0000-000000000001',
|
||||
'55555555-0000-0000-0000-000000000001','66666666-0000-0000-0000-000000000001',
|
||||
'77777777-0000-0000-0000-000000000001',
|
||||
'high','Uplink Gi0/1 завантажений на 94%','rule:uplink90:if:6666...0001', 94.0, 90.0);
|
||||
|
||||
-- Дедуплікація: другий активний алерт із тим самим ключем не пройде
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, title, dedup_key)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111','cccccccc-0000-0000-0000-000000000001',
|
||||
'55555555-0000-0000-0000-000000000001','high','дубль','rule:uplink90:if:6666...0001');
|
||||
RAISE EXCEPTION 'FAIL: дубль активного алерту створився';
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
RAISE NOTICE 'PASS: дубль активного алерту відхилено (alerts_active_dedup_uniq)';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- =====================================================================
|
||||
-- ПЕРЕВІРКИ
|
||||
-- =====================================================================
|
||||
|
||||
\echo ''
|
||||
\echo '--- 1. Стан полотна мапи одним запитом ---'
|
||||
SELECT n.label,
|
||||
n.kind,
|
||||
n.x, n.y,
|
||||
d.status AS device_status,
|
||||
round(i.rtt_avg_ms::numeric,2) AS rtt_ms,
|
||||
i.loss_pct
|
||||
FROM topo.map_nodes n
|
||||
LEFT JOIN inv.devices d ON d.id = n.device_id
|
||||
LEFT JOIN ts.device_last_icmp i ON i.device_id = n.device_id
|
||||
WHERE n.map_id = '88888888-0000-0000-0000-000000000001'
|
||||
ORDER BY n.x, n.y;
|
||||
|
||||
\echo ''
|
||||
\echo '--- 2. Ребро мапи з портами і живим завантаженням (джерело анімації) ---'
|
||||
SELECT e.label,
|
||||
si.name AS source_port,
|
||||
ti.name AS target_port,
|
||||
lv.status,
|
||||
round(lv.util_pct::numeric,1) AS util_pct,
|
||||
e.animation->>'speed_source' AS speed_source,
|
||||
e.thresholds->>'crit_pct' AS crit_pct
|
||||
FROM topo.map_edges e
|
||||
LEFT JOIN inv.interfaces si ON si.id = e.source_interface_id
|
||||
LEFT JOIN inv.interfaces ti ON ti.id = e.target_interface_id
|
||||
LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id
|
||||
WHERE e.map_id = '88888888-0000-0000-0000-000000000001';
|
||||
|
||||
\echo ''
|
||||
\echo '--- 3. Continuous aggregate: 5-хвилинні роллапи трафіку ---'
|
||||
CALL refresh_continuous_aggregate('ts.if_counters_5m', now() - interval '2 hours', now());
|
||||
SELECT bucket, round(in_bps_avg::numeric/1e6,1) AS in_mbps, round(util_out_max::numeric,1) AS util_out_max
|
||||
FROM ts.if_counters_5m
|
||||
WHERE interface_id = '66666666-0000-0000-0000-000000000001'
|
||||
ORDER BY bucket DESC LIMIT 5;
|
||||
|
||||
\echo ''
|
||||
\echo '--- 4. Ліміт пристроїв тарифу Free (15) ---'
|
||||
DO $$
|
||||
DECLARE i int;
|
||||
BEGIN
|
||||
FOR i IN 4..15 LOOP
|
||||
INSERT INTO inv.devices (tenant_id, name, address, kind)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111', 'filler-' || i,
|
||||
('10.0.1.' || i)::inet, 'other');
|
||||
END LOOP;
|
||||
BEGIN
|
||||
INSERT INTO inv.devices (tenant_id, name, address, kind)
|
||||
VALUES ('11111111-1111-1111-1111-111111111111', 'over-limit', '10.0.9.9', 'other');
|
||||
RAISE EXCEPTION 'FAIL: 16-й пристрій створився попри ліміт 15';
|
||||
EXCEPTION WHEN check_violation THEN
|
||||
RAISE NOTICE 'PASS: 16-й пристрій відхилено тригером bill.assert_device_limit';
|
||||
END;
|
||||
END $$;
|
||||
|
||||
\echo ''
|
||||
\echo '--- 5. RLS: ізоляція тенантів (від імені netpulse_app) ---'
|
||||
SET ROLE netpulse_app;
|
||||
|
||||
SET app.tenant_id = '11111111-1111-1111-1111-111111111111';
|
||||
SELECT 'acme бачить пристроїв' AS check, count(*) FROM inv.devices;
|
||||
SELECT 'acme бачить мап' AS check, count(*) FROM topo.maps;
|
||||
|
||||
SET app.tenant_id = '22222222-2222-2222-2222-222222222222';
|
||||
SELECT 'globex бачить пристроїв ACME' AS check, count(*) FROM inv.devices;
|
||||
SELECT 'globex бачить мап ACME' AS check, count(*) FROM topo.maps;
|
||||
|
||||
RESET app.tenant_id;
|
||||
SELECT 'без app.tenant_id бачить пристроїв' AS check, count(*) FROM inv.devices;
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
\echo ''
|
||||
\echo '--- 6. Зведення по об''єктах схеми ---'
|
||||
SELECT 'таблиць' AS obj, count(*) FROM pg_tables
|
||||
WHERE schemaname IN ('core','inv','topo','ts','ncm','alr','bill')
|
||||
UNION ALL SELECT 'hypertables', count(*) FROM timescaledb_information.hypertables
|
||||
UNION ALL SELECT 'continuous aggregates', count(*) FROM timescaledb_information.continuous_aggregates
|
||||
UNION ALL SELECT 'фонових job-ів', count(*) FROM timescaledb_information.jobs WHERE job_id >= 1000
|
||||
UNION ALL SELECT 'RLS-політик', count(*) FROM pg_policies
|
||||
WHERE schemaname IN ('core','inv','topo','ts','ncm','alr','bill')
|
||||
UNION ALL SELECT 'індексів', count(*) FROM pg_indexes
|
||||
WHERE schemaname IN ('core','inv','topo','ts','ncm','alr','bill');
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
version: "3.9"
|
||||
|
||||
# Локальний стенд для перевірки схеми: TimescaleDB + Redis-сумісний DragonflyDB.
|
||||
# Міграції накочуються скриптом db/migrate.ps1 (або psql -f у порядку номерів).
|
||||
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb:2.17.2-pg16
|
||||
container_name: netpulse-db
|
||||
environment:
|
||||
POSTGRES_USER: netpulse
|
||||
POSTGRES_PASSWORD: netpulse
|
||||
POSTGRES_DB: netpulse
|
||||
TIMESCALEDB_TELEMETRY: "off"
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=timescaledb
|
||||
- -c
|
||||
- max_connections=200
|
||||
- -c
|
||||
- shared_buffers=512MB
|
||||
- -c
|
||||
- timescaledb.max_background_workers=8
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U netpulse -d netpulse"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
cache:
|
||||
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
||||
container_name: netpulse-cache
|
||||
ulimits:
|
||||
memlock: -1
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- cache-data:/data
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
cache-data:
|
||||
15
gen/go/go.mod
Normal file
15
gen/go/go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module github.com/netpulse/netpulse/gen/go
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.83.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
38
gen/go/go.sum
Normal file
38
gen/go/go.sum
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
2600
gen/go/netpulse/v1/agent.pb.go
Normal file
2600
gen/go/netpulse/v1/agent.pb.go
Normal file
File diff suppressed because it is too large
Load diff
380
gen/go/netpulse/v1/agent_grpc.pb.go
Normal file
380
gen/go/netpulse/v1/agent_grpc.pb.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: agent.proto
|
||||
// Головний контракт агент↔сервер.
|
||||
//
|
||||
// ФУНДАМЕНТАЛЬНЕ ОБМЕЖЕННЯ: усі з'єднання ініціює агент.
|
||||
// Сервер ніколи не стукає в мережу клієнта — там немає ані відкритих
|
||||
// портів, ані прокидання NAT. Тому "команда з сервера" фізично є
|
||||
// повідомленням у зустрічному напрямку вже відкритого агентом
|
||||
// bidi-стріму Control.
|
||||
// =====================================================================
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v3.21.12
|
||||
// source: netpulse/v1/agent.proto
|
||||
|
||||
package netpulsev1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
EnrollmentService_Enroll_FullMethodName = "/netpulse.v1.EnrollmentService/Enroll"
|
||||
)
|
||||
|
||||
// EnrollmentServiceClient is the client API for EnrollmentService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type EnrollmentServiceClient interface {
|
||||
Enroll(ctx context.Context, in *EnrollRequest, opts ...grpc.CallOption) (*EnrollResponse, error)
|
||||
}
|
||||
|
||||
type enrollmentServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewEnrollmentServiceClient(cc grpc.ClientConnInterface) EnrollmentServiceClient {
|
||||
return &enrollmentServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *enrollmentServiceClient) Enroll(ctx context.Context, in *EnrollRequest, opts ...grpc.CallOption) (*EnrollResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(EnrollResponse)
|
||||
err := c.cc.Invoke(ctx, EnrollmentService_Enroll_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnrollmentServiceServer is the server API for EnrollmentService service.
|
||||
// All implementations must embed UnimplementedEnrollmentServiceServer
|
||||
// for forward compatibility.
|
||||
type EnrollmentServiceServer interface {
|
||||
Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error)
|
||||
mustEmbedUnimplementedEnrollmentServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedEnrollmentServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedEnrollmentServiceServer struct{}
|
||||
|
||||
func (UnimplementedEnrollmentServiceServer) Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Enroll not implemented")
|
||||
}
|
||||
func (UnimplementedEnrollmentServiceServer) mustEmbedUnimplementedEnrollmentServiceServer() {}
|
||||
func (UnimplementedEnrollmentServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeEnrollmentServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to EnrollmentServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeEnrollmentServiceServer interface {
|
||||
mustEmbedUnimplementedEnrollmentServiceServer()
|
||||
}
|
||||
|
||||
func RegisterEnrollmentServiceServer(s grpc.ServiceRegistrar, srv EnrollmentServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedEnrollmentServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&EnrollmentService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _EnrollmentService_Enroll_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(EnrollRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(EnrollmentServiceServer).Enroll(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: EnrollmentService_Enroll_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(EnrollmentServiceServer).Enroll(ctx, req.(*EnrollRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// EnrollmentService_ServiceDesc is the grpc.ServiceDesc for EnrollmentService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var EnrollmentService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "netpulse.v1.EnrollmentService",
|
||||
HandlerType: (*EnrollmentServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "Enroll",
|
||||
Handler: _EnrollmentService_Enroll_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "netpulse/v1/agent.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
AgentService_Control_FullMethodName = "/netpulse.v1.AgentService/Control"
|
||||
AgentService_StreamTelemetry_FullMethodName = "/netpulse.v1.AgentService/StreamTelemetry"
|
||||
AgentService_StreamLogs_FullMethodName = "/netpulse.v1.AgentService/StreamLogs"
|
||||
AgentService_ReportDiscovery_FullMethodName = "/netpulse.v1.AgentService/ReportDiscovery"
|
||||
AgentService_UploadConfig_FullMethodName = "/netpulse.v1.AgentService/UploadConfig"
|
||||
)
|
||||
|
||||
// AgentServiceClient is the client API for AgentService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type AgentServiceClient interface {
|
||||
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
|
||||
// з'єднання. Розрив стріму = кінець сесії з усім її станом
|
||||
// (таблиця серій, in-flight батчі).
|
||||
Control(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ControlUp, ControlDown], error)
|
||||
// Телеметрія окремим стрімом, щоб пачка на 10 000 семплів
|
||||
// не блокувала heartbeat і не затримувала команду з сервера.
|
||||
StreamTelemetry(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TelemetryBatch, TelemetryAck], error)
|
||||
// Syslog/трапи — теж окремо: сплеск логів під час аварії не має
|
||||
// топити телеметрію, за якою ця аварія й видно.
|
||||
StreamLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[LogBatch, LogAck], error)
|
||||
// Автовиявлення: рідко, великими звітами.
|
||||
ReportDiscovery(ctx context.Context, in *DiscoveryReport, opts ...grpc.CallOption) (*DiscoveryAck, error)
|
||||
// Вивантаження зібраного конфігу (чанками).
|
||||
UploadConfig(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt], error)
|
||||
}
|
||||
|
||||
type agentServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
|
||||
return &agentServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) Control(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ControlUp, ControlDown], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], AgentService_Control_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[ControlUp, ControlDown]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_ControlClient = grpc.BidiStreamingClient[ControlUp, ControlDown]
|
||||
|
||||
func (c *agentServiceClient) StreamTelemetry(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TelemetryBatch, TelemetryAck], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[1], AgentService_StreamTelemetry_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[TelemetryBatch, TelemetryAck]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_StreamTelemetryClient = grpc.BidiStreamingClient[TelemetryBatch, TelemetryAck]
|
||||
|
||||
func (c *agentServiceClient) StreamLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[LogBatch, LogAck], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[2], AgentService_StreamLogs_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[LogBatch, LogAck]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_StreamLogsClient = grpc.BidiStreamingClient[LogBatch, LogAck]
|
||||
|
||||
func (c *agentServiceClient) ReportDiscovery(ctx context.Context, in *DiscoveryReport, opts ...grpc.CallOption) (*DiscoveryAck, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DiscoveryAck)
|
||||
err := c.cc.Invoke(ctx, AgentService_ReportDiscovery_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) UploadConfig(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[3], AgentService_UploadConfig_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[ConfigUpload, ConfigReceipt]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_UploadConfigClient = grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt]
|
||||
|
||||
// AgentServiceServer is the server API for AgentService service.
|
||||
// All implementations must embed UnimplementedAgentServiceServer
|
||||
// for forward compatibility.
|
||||
type AgentServiceServer interface {
|
||||
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
|
||||
// з'єднання. Розрив стріму = кінець сесії з усім її станом
|
||||
// (таблиця серій, in-flight батчі).
|
||||
Control(grpc.BidiStreamingServer[ControlUp, ControlDown]) error
|
||||
// Телеметрія окремим стрімом, щоб пачка на 10 000 семплів
|
||||
// не блокувала heartbeat і не затримувала команду з сервера.
|
||||
StreamTelemetry(grpc.BidiStreamingServer[TelemetryBatch, TelemetryAck]) error
|
||||
// Syslog/трапи — теж окремо: сплеск логів під час аварії не має
|
||||
// топити телеметрію, за якою ця аварія й видно.
|
||||
StreamLogs(grpc.BidiStreamingServer[LogBatch, LogAck]) error
|
||||
// Автовиявлення: рідко, великими звітами.
|
||||
ReportDiscovery(context.Context, *DiscoveryReport) (*DiscoveryAck, error)
|
||||
// Вивантаження зібраного конфігу (чанками).
|
||||
UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error
|
||||
mustEmbedUnimplementedAgentServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedAgentServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedAgentServiceServer struct{}
|
||||
|
||||
func (UnimplementedAgentServiceServer) Control(grpc.BidiStreamingServer[ControlUp, ControlDown]) error {
|
||||
return status.Error(codes.Unimplemented, "method Control not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamTelemetry(grpc.BidiStreamingServer[TelemetryBatch, TelemetryAck]) error {
|
||||
return status.Error(codes.Unimplemented, "method StreamTelemetry not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) StreamLogs(grpc.BidiStreamingServer[LogBatch, LogAck]) error {
|
||||
return status.Error(codes.Unimplemented, "method StreamLogs not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) ReportDiscovery(context.Context, *DiscoveryReport) (*DiscoveryAck, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ReportDiscovery not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error {
|
||||
return status.Error(codes.Unimplemented, "method UploadConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
|
||||
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AgentServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAgentServiceServer interface {
|
||||
mustEmbedUnimplementedAgentServiceServer()
|
||||
}
|
||||
|
||||
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedAgentServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&AgentService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _AgentService_Control_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentServiceServer).Control(&grpc.GenericServerStream[ControlUp, ControlDown]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_ControlServer = grpc.BidiStreamingServer[ControlUp, ControlDown]
|
||||
|
||||
func _AgentService_StreamTelemetry_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentServiceServer).StreamTelemetry(&grpc.GenericServerStream[TelemetryBatch, TelemetryAck]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_StreamTelemetryServer = grpc.BidiStreamingServer[TelemetryBatch, TelemetryAck]
|
||||
|
||||
func _AgentService_StreamLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentServiceServer).StreamLogs(&grpc.GenericServerStream[LogBatch, LogAck]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_StreamLogsServer = grpc.BidiStreamingServer[LogBatch, LogAck]
|
||||
|
||||
func _AgentService_ReportDiscovery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DiscoveryReport)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AgentServiceServer).ReportDiscovery(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AgentService_ReportDiscovery_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AgentServiceServer).ReportDiscovery(ctx, req.(*DiscoveryReport))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AgentService_UploadConfig_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentServiceServer).UploadConfig(&grpc.GenericServerStream[ConfigUpload, ConfigReceipt]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_UploadConfigServer = grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]
|
||||
|
||||
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var AgentService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "netpulse.v1.AgentService",
|
||||
HandlerType: (*AgentServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ReportDiscovery",
|
||||
Handler: _AgentService_ReportDiscovery_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "Control",
|
||||
Handler: _AgentService_Control_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "StreamTelemetry",
|
||||
Handler: _AgentService_StreamTelemetry_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "StreamLogs",
|
||||
Handler: _AgentService_StreamLogs_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "UploadConfig",
|
||||
Handler: _AgentService_UploadConfig_Handler,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "netpulse/v1/agent.proto",
|
||||
}
|
||||
1072
gen/go/netpulse/v1/common.pb.go
Normal file
1072
gen/go/netpulse/v1/common.pb.go
Normal file
File diff suppressed because it is too large
Load diff
800
gen/go/netpulse/v1/discovery.pb.go
Normal file
800
gen/go/netpulse/v1/discovery.pb.go
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: discovery.proto
|
||||
// Автовиявлення сусідів (LLDP/CDP/ARP/FDB) та інвентаризація інтерфейсів.
|
||||
//
|
||||
// Агент НЕ вирішує, хто з ким з'єднаний. Він доповідає сире:
|
||||
// "на порту X я бачу chassis-id Y, port-id Z". Резолвер на сервері
|
||||
// зводить це в topo.links і виставляє confidence. Так автовиявлення
|
||||
// лишається відтворюваним і не залежить від версії агента.
|
||||
// =====================================================================
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc v3.21.12
|
||||
// source: netpulse/v1/discovery.proto
|
||||
|
||||
package netpulsev1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type NeighborRecord struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Пристрій, який доповідає (той, що ми опитали).
|
||||
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
// Локальний порт. Якщо агент ще не знає interface_id — заповнює
|
||||
// local_if_index/local_port_name, і сервер резолвить сам.
|
||||
LocalInterfaceId string `protobuf:"bytes,2,opt,name=local_interface_id,json=localInterfaceId,proto3" json:"local_interface_id,omitempty"`
|
||||
LocalIfIndex int64 `protobuf:"varint,3,opt,name=local_if_index,json=localIfIndex,proto3" json:"local_if_index,omitempty"`
|
||||
LocalPortName string `protobuf:"bytes,4,opt,name=local_port_name,json=localPortName,proto3" json:"local_port_name,omitempty"`
|
||||
Proto DiscoveryProto `protobuf:"varint,5,opt,name=proto,proto3,enum=netpulse.v1.DiscoveryProto" json:"proto,omitempty"`
|
||||
// Те, що видно з іншого боку. Порожні поля — норма: CDP не дає
|
||||
// chassis_id у форматі LLDP, ARP не дає port_id взагалі.
|
||||
RemoteChassisId string `protobuf:"bytes,6,opt,name=remote_chassis_id,json=remoteChassisId,proto3" json:"remote_chassis_id,omitempty"`
|
||||
RemoteSystemName string `protobuf:"bytes,7,opt,name=remote_system_name,json=remoteSystemName,proto3" json:"remote_system_name,omitempty"`
|
||||
RemotePortId string `protobuf:"bytes,8,opt,name=remote_port_id,json=remotePortId,proto3" json:"remote_port_id,omitempty"`
|
||||
RemotePortDescr string `protobuf:"bytes,9,opt,name=remote_port_descr,json=remotePortDescr,proto3" json:"remote_port_descr,omitempty"`
|
||||
RemoteMgmtIp string `protobuf:"bytes,10,opt,name=remote_mgmt_ip,json=remoteMgmtIp,proto3" json:"remote_mgmt_ip,omitempty"`
|
||||
RemoteMac string `protobuf:"bytes,11,opt,name=remote_mac,json=remoteMac,proto3" json:"remote_mac,omitempty"`
|
||||
RemotePlatform string `protobuf:"bytes,12,opt,name=remote_platform,json=remotePlatform,proto3" json:"remote_platform,omitempty"`
|
||||
RemoteCapabilities []string `protobuf:"bytes,13,rep,name=remote_capabilities,json=remoteCapabilities,proto3" json:"remote_capabilities,omitempty"` // bridge, router, wlan-ap
|
||||
SeenAt *timestamppb.Timestamp `protobuf:"bytes,14,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
|
||||
// Сирий запис як його віддав пристрій — для розбору спірних випадків.
|
||||
RawJson []byte `protobuf:"bytes,15,opt,name=raw_json,json=rawJson,proto3" json:"raw_json,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) Reset() {
|
||||
*x = NeighborRecord{}
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NeighborRecord) ProtoMessage() {}
|
||||
|
||||
func (x *NeighborRecord) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NeighborRecord.ProtoReflect.Descriptor instead.
|
||||
func (*NeighborRecord) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetLocalInterfaceId() string {
|
||||
if x != nil {
|
||||
return x.LocalInterfaceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetLocalIfIndex() int64 {
|
||||
if x != nil {
|
||||
return x.LocalIfIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetLocalPortName() string {
|
||||
if x != nil {
|
||||
return x.LocalPortName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetProto() DiscoveryProto {
|
||||
if x != nil {
|
||||
return x.Proto
|
||||
}
|
||||
return DiscoveryProto_DISCOVERY_PROTO_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemoteChassisId() string {
|
||||
if x != nil {
|
||||
return x.RemoteChassisId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemoteSystemName() string {
|
||||
if x != nil {
|
||||
return x.RemoteSystemName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemotePortId() string {
|
||||
if x != nil {
|
||||
return x.RemotePortId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemotePortDescr() string {
|
||||
if x != nil {
|
||||
return x.RemotePortDescr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemoteMgmtIp() string {
|
||||
if x != nil {
|
||||
return x.RemoteMgmtIp
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemoteMac() string {
|
||||
if x != nil {
|
||||
return x.RemoteMac
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemotePlatform() string {
|
||||
if x != nil {
|
||||
return x.RemotePlatform
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRemoteCapabilities() []string {
|
||||
if x != nil {
|
||||
return x.RemoteCapabilities
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetSeenAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.SeenAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *NeighborRecord) GetRawJson() []byte {
|
||||
if x != nil {
|
||||
return x.RawJson
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type InterfaceRecord struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
IfIndex int64 `protobuf:"varint,2,opt,name=if_index,json=ifIndex,proto3" json:"if_index,omitempty"`
|
||||
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Alias string `protobuf:"bytes,4,opt,name=alias,proto3" json:"alias,omitempty"`
|
||||
Mac string `protobuf:"bytes,5,opt,name=mac,proto3" json:"mac,omitempty"`
|
||||
Mtu uint32 `protobuf:"varint,6,opt,name=mtu,proto3" json:"mtu,omitempty"`
|
||||
Type string `protobuf:"bytes,7,opt,name=type,proto3" json:"type,omitempty"` // ethernetCsmacd, ieee8023adLag, l3ipvlan
|
||||
SpeedBps uint64 `protobuf:"varint,8,opt,name=speed_bps,json=speedBps,proto3" json:"speed_bps,omitempty"`
|
||||
Duplex string `protobuf:"bytes,9,opt,name=duplex,proto3" json:"duplex,omitempty"`
|
||||
AdminStatus string `protobuf:"bytes,10,opt,name=admin_status,json=adminStatus,proto3" json:"admin_status,omitempty"` // up | down | testing | unknown
|
||||
OperStatus string `protobuf:"bytes,11,opt,name=oper_status,json=operStatus,proto3" json:"oper_status,omitempty"`
|
||||
ParentIfIndex int64 `protobuf:"varint,12,opt,name=parent_if_index,json=parentIfIndex,proto3" json:"parent_if_index,omitempty"` // для членів LAG
|
||||
IpAddresses []string `protobuf:"bytes,13,rep,name=ip_addresses,json=ipAddresses,proto3" json:"ip_addresses,omitempty"` // CIDR: 10.0.0.1/24
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) Reset() {
|
||||
*x = InterfaceRecord{}
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*InterfaceRecord) ProtoMessage() {}
|
||||
|
||||
func (x *InterfaceRecord) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use InterfaceRecord.ProtoReflect.Descriptor instead.
|
||||
func (*InterfaceRecord) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetIfIndex() int64 {
|
||||
if x != nil {
|
||||
return x.IfIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetAlias() string {
|
||||
if x != nil {
|
||||
return x.Alias
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetMac() string {
|
||||
if x != nil {
|
||||
return x.Mac
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetMtu() uint32 {
|
||||
if x != nil {
|
||||
return x.Mtu
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetType() string {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetSpeedBps() uint64 {
|
||||
if x != nil {
|
||||
return x.SpeedBps
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetDuplex() string {
|
||||
if x != nil {
|
||||
return x.Duplex
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetAdminStatus() string {
|
||||
if x != nil {
|
||||
return x.AdminStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetOperStatus() string {
|
||||
if x != nil {
|
||||
return x.OperStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetParentIfIndex() int64 {
|
||||
if x != nil {
|
||||
return x.ParentIfIndex
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *InterfaceRecord) GetIpAddresses() []string {
|
||||
if x != nil {
|
||||
return x.IpAddresses
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DiscoveredDevice struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"`
|
||||
Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||
Mac string `protobuf:"bytes,3,opt,name=mac,proto3" json:"mac,omitempty"`
|
||||
SysName string `protobuf:"bytes,4,opt,name=sys_name,json=sysName,proto3" json:"sys_name,omitempty"`
|
||||
SysDescr string `protobuf:"bytes,5,opt,name=sys_descr,json=sysDescr,proto3" json:"sys_descr,omitempty"`
|
||||
SysObjectId string `protobuf:"bytes,6,opt,name=sys_object_id,json=sysObjectId,proto3" json:"sys_object_id,omitempty"`
|
||||
Vendor string `protobuf:"bytes,7,opt,name=vendor,proto3" json:"vendor,omitempty"`
|
||||
Model string `protobuf:"bytes,8,opt,name=model,proto3" json:"model,omitempty"`
|
||||
// Які транспорти відповіли під час сканування.
|
||||
ReachableVia []Transport `protobuf:"varint,9,rep,packed,name=reachable_via,json=reachableVia,proto3,enum=netpulse.v1.Transport" json:"reachable_via,omitempty"`
|
||||
// Здогад агента про тип пристрою — сервер може перевизначити.
|
||||
GuessedKind string `protobuf:"bytes,10,opt,name=guessed_kind,json=guessedKind,proto3" json:"guessed_kind,omitempty"`
|
||||
SeenAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) Reset() {
|
||||
*x = DiscoveredDevice{}
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DiscoveredDevice) ProtoMessage() {}
|
||||
|
||||
func (x *DiscoveredDevice) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DiscoveredDevice.ProtoReflect.Descriptor instead.
|
||||
func (*DiscoveredDevice) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetAddress() string {
|
||||
if x != nil {
|
||||
return x.Address
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetHostname() string {
|
||||
if x != nil {
|
||||
return x.Hostname
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetMac() string {
|
||||
if x != nil {
|
||||
return x.Mac
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetSysName() string {
|
||||
if x != nil {
|
||||
return x.SysName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetSysDescr() string {
|
||||
if x != nil {
|
||||
return x.SysDescr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetSysObjectId() string {
|
||||
if x != nil {
|
||||
return x.SysObjectId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetVendor() string {
|
||||
if x != nil {
|
||||
return x.Vendor
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetModel() string {
|
||||
if x != nil {
|
||||
return x.Model
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetReachableVia() []Transport {
|
||||
if x != nil {
|
||||
return x.ReachableVia
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetGuessedKind() string {
|
||||
if x != nil {
|
||||
return x.GuessedKind
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetSeenAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.SeenAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DiscoveryReport struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
|
||||
// Порожній, якщо це фонове періодичне виявлення, а не запуск із UI.
|
||||
RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
|
||||
Neighbors []*NeighborRecord `protobuf:"bytes,3,rep,name=neighbors,proto3" json:"neighbors,omitempty"`
|
||||
Interfaces []*InterfaceRecord `protobuf:"bytes,4,rep,name=interfaces,proto3" json:"interfaces,omitempty"`
|
||||
Devices []*DiscoveredDevice `protobuf:"bytes,5,rep,name=devices,proto3" json:"devices,omitempty"`
|
||||
// Звіт розбитий на частини: сервер має чекати final = true,
|
||||
// перш ніж закривати run і чистити застарілих сусідів.
|
||||
Final bool `protobuf:"varint,6,opt,name=final,proto3" json:"final,omitempty"`
|
||||
Part uint32 `protobuf:"varint,7,opt,name=part,proto3" json:"part,omitempty"`
|
||||
StartedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"`
|
||||
FinishedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=finished_at,json=finishedAt,proto3" json:"finished_at,omitempty"`
|
||||
Errors []*Error `protobuf:"bytes,10,rep,name=errors,proto3" json:"errors,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) Reset() {
|
||||
*x = DiscoveryReport{}
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DiscoveryReport) ProtoMessage() {}
|
||||
|
||||
func (x *DiscoveryReport) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DiscoveryReport.ProtoReflect.Descriptor instead.
|
||||
func (*DiscoveryReport) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetAgentId() string {
|
||||
if x != nil {
|
||||
return x.AgentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetRunId() string {
|
||||
if x != nil {
|
||||
return x.RunId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetNeighbors() []*NeighborRecord {
|
||||
if x != nil {
|
||||
return x.Neighbors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetInterfaces() []*InterfaceRecord {
|
||||
if x != nil {
|
||||
return x.Interfaces
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetDevices() []*DiscoveredDevice {
|
||||
if x != nil {
|
||||
return x.Devices
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetFinal() bool {
|
||||
if x != nil {
|
||||
return x.Final
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetPart() uint32 {
|
||||
if x != nil {
|
||||
return x.Part
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetStartedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.StartedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetFinishedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.FinishedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveryReport) GetErrors() []*Error {
|
||||
if x != nil {
|
||||
return x.Errors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DiscoveryAck struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
|
||||
// Скільки записів сервер зміг зіставити з інвентарем.
|
||||
NeighborsResolved uint32 `protobuf:"varint,2,opt,name=neighbors_resolved,json=neighborsResolved,proto3" json:"neighbors_resolved,omitempty"`
|
||||
LinksCreated uint32 `protobuf:"varint,3,opt,name=links_created,json=linksCreated,proto3" json:"links_created,omitempty"`
|
||||
DevicesCreated uint32 `protobuf:"varint,4,opt,name=devices_created,json=devicesCreated,proto3" json:"devices_created,omitempty"`
|
||||
Error *Error `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) Reset() {
|
||||
*x = DiscoveryAck{}
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DiscoveryAck) ProtoMessage() {}
|
||||
|
||||
func (x *DiscoveryAck) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_discovery_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DiscoveryAck.ProtoReflect.Descriptor instead.
|
||||
func (*DiscoveryAck) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) GetAccepted() bool {
|
||||
if x != nil {
|
||||
return x.Accepted
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) GetNeighborsResolved() uint32 {
|
||||
if x != nil {
|
||||
return x.NeighborsResolved
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) GetLinksCreated() uint32 {
|
||||
if x != nil {
|
||||
return x.LinksCreated
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) GetDevicesCreated() uint32 {
|
||||
if x != nil {
|
||||
return x.DevicesCreated
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DiscoveryAck) GetError() *Error {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_netpulse_v1_discovery_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_netpulse_v1_discovery_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1bnetpulse/v1/discovery.proto\x12\vnetpulse.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xf7\x04\n" +
|
||||
"\x0eNeighborRecord\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12,\n" +
|
||||
"\x12local_interface_id\x18\x02 \x01(\tR\x10localInterfaceId\x12$\n" +
|
||||
"\x0elocal_if_index\x18\x03 \x01(\x03R\flocalIfIndex\x12&\n" +
|
||||
"\x0flocal_port_name\x18\x04 \x01(\tR\rlocalPortName\x121\n" +
|
||||
"\x05proto\x18\x05 \x01(\x0e2\x1b.netpulse.v1.DiscoveryProtoR\x05proto\x12*\n" +
|
||||
"\x11remote_chassis_id\x18\x06 \x01(\tR\x0fremoteChassisId\x12,\n" +
|
||||
"\x12remote_system_name\x18\a \x01(\tR\x10remoteSystemName\x12$\n" +
|
||||
"\x0eremote_port_id\x18\b \x01(\tR\fremotePortId\x12*\n" +
|
||||
"\x11remote_port_descr\x18\t \x01(\tR\x0fremotePortDescr\x12$\n" +
|
||||
"\x0eremote_mgmt_ip\x18\n" +
|
||||
" \x01(\tR\fremoteMgmtIp\x12\x1d\n" +
|
||||
"\n" +
|
||||
"remote_mac\x18\v \x01(\tR\tremoteMac\x12'\n" +
|
||||
"\x0fremote_platform\x18\f \x01(\tR\x0eremotePlatform\x12/\n" +
|
||||
"\x13remote_capabilities\x18\r \x03(\tR\x12remoteCapabilities\x123\n" +
|
||||
"\aseen_at\x18\x0e \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\x12\x19\n" +
|
||||
"\braw_json\x18\x0f \x01(\fR\arawJson\"\xef\x02\n" +
|
||||
"\x0fInterfaceRecord\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x19\n" +
|
||||
"\bif_index\x18\x02 \x01(\x03R\aifIndex\x12\x12\n" +
|
||||
"\x04name\x18\x03 \x01(\tR\x04name\x12\x14\n" +
|
||||
"\x05alias\x18\x04 \x01(\tR\x05alias\x12\x10\n" +
|
||||
"\x03mac\x18\x05 \x01(\tR\x03mac\x12\x10\n" +
|
||||
"\x03mtu\x18\x06 \x01(\rR\x03mtu\x12\x12\n" +
|
||||
"\x04type\x18\a \x01(\tR\x04type\x12\x1b\n" +
|
||||
"\tspeed_bps\x18\b \x01(\x04R\bspeedBps\x12\x16\n" +
|
||||
"\x06duplex\x18\t \x01(\tR\x06duplex\x12!\n" +
|
||||
"\fadmin_status\x18\n" +
|
||||
" \x01(\tR\vadminStatus\x12\x1f\n" +
|
||||
"\voper_status\x18\v \x01(\tR\n" +
|
||||
"operStatus\x12&\n" +
|
||||
"\x0fparent_if_index\x18\f \x01(\x03R\rparentIfIndex\x12!\n" +
|
||||
"\fip_addresses\x18\r \x03(\tR\vipAddresses\"\xf9\x02\n" +
|
||||
"\x10DiscoveredDevice\x12\x18\n" +
|
||||
"\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" +
|
||||
"\bhostname\x18\x02 \x01(\tR\bhostname\x12\x10\n" +
|
||||
"\x03mac\x18\x03 \x01(\tR\x03mac\x12\x19\n" +
|
||||
"\bsys_name\x18\x04 \x01(\tR\asysName\x12\x1b\n" +
|
||||
"\tsys_descr\x18\x05 \x01(\tR\bsysDescr\x12\"\n" +
|
||||
"\rsys_object_id\x18\x06 \x01(\tR\vsysObjectId\x12\x16\n" +
|
||||
"\x06vendor\x18\a \x01(\tR\x06vendor\x12\x14\n" +
|
||||
"\x05model\x18\b \x01(\tR\x05model\x12;\n" +
|
||||
"\rreachable_via\x18\t \x03(\x0e2\x16.netpulse.v1.TransportR\freachableVia\x12!\n" +
|
||||
"\fguessed_kind\x18\n" +
|
||||
" \x01(\tR\vguessedKind\x123\n" +
|
||||
"\aseen_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\"\xc3\x03\n" +
|
||||
"\x0fDiscoveryReport\x12\x19\n" +
|
||||
"\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x15\n" +
|
||||
"\x06run_id\x18\x02 \x01(\tR\x05runId\x129\n" +
|
||||
"\tneighbors\x18\x03 \x03(\v2\x1b.netpulse.v1.NeighborRecordR\tneighbors\x12<\n" +
|
||||
"\n" +
|
||||
"interfaces\x18\x04 \x03(\v2\x1c.netpulse.v1.InterfaceRecordR\n" +
|
||||
"interfaces\x127\n" +
|
||||
"\adevices\x18\x05 \x03(\v2\x1d.netpulse.v1.DiscoveredDeviceR\adevices\x12\x14\n" +
|
||||
"\x05final\x18\x06 \x01(\bR\x05final\x12\x12\n" +
|
||||
"\x04part\x18\a \x01(\rR\x04part\x129\n" +
|
||||
"\n" +
|
||||
"started_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12;\n" +
|
||||
"\vfinished_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" +
|
||||
"finishedAt\x12*\n" +
|
||||
"\x06errors\x18\n" +
|
||||
" \x03(\v2\x12.netpulse.v1.ErrorR\x06errors\"\xd1\x01\n" +
|
||||
"\fDiscoveryAck\x12\x1a\n" +
|
||||
"\baccepted\x18\x01 \x01(\bR\baccepted\x12-\n" +
|
||||
"\x12neighbors_resolved\x18\x02 \x01(\rR\x11neighborsResolved\x12#\n" +
|
||||
"\rlinks_created\x18\x03 \x01(\rR\flinksCreated\x12'\n" +
|
||||
"\x0fdevices_created\x18\x04 \x01(\rR\x0edevicesCreated\x12(\n" +
|
||||
"\x05error\x18\x05 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_netpulse_v1_discovery_proto_rawDescOnce sync.Once
|
||||
file_netpulse_v1_discovery_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_netpulse_v1_discovery_proto_rawDescGZIP() []byte {
|
||||
file_netpulse_v1_discovery_proto_rawDescOnce.Do(func() {
|
||||
file_netpulse_v1_discovery_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_netpulse_v1_discovery_proto_rawDesc), len(file_netpulse_v1_discovery_proto_rawDesc)))
|
||||
})
|
||||
return file_netpulse_v1_discovery_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_netpulse_v1_discovery_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||
var file_netpulse_v1_discovery_proto_goTypes = []any{
|
||||
(*NeighborRecord)(nil), // 0: netpulse.v1.NeighborRecord
|
||||
(*InterfaceRecord)(nil), // 1: netpulse.v1.InterfaceRecord
|
||||
(*DiscoveredDevice)(nil), // 2: netpulse.v1.DiscoveredDevice
|
||||
(*DiscoveryReport)(nil), // 3: netpulse.v1.DiscoveryReport
|
||||
(*DiscoveryAck)(nil), // 4: netpulse.v1.DiscoveryAck
|
||||
(DiscoveryProto)(0), // 5: netpulse.v1.DiscoveryProto
|
||||
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
|
||||
(Transport)(0), // 7: netpulse.v1.Transport
|
||||
(*Error)(nil), // 8: netpulse.v1.Error
|
||||
}
|
||||
var file_netpulse_v1_discovery_proto_depIdxs = []int32{
|
||||
5, // 0: netpulse.v1.NeighborRecord.proto:type_name -> netpulse.v1.DiscoveryProto
|
||||
6, // 1: netpulse.v1.NeighborRecord.seen_at:type_name -> google.protobuf.Timestamp
|
||||
7, // 2: netpulse.v1.DiscoveredDevice.reachable_via:type_name -> netpulse.v1.Transport
|
||||
6, // 3: netpulse.v1.DiscoveredDevice.seen_at:type_name -> google.protobuf.Timestamp
|
||||
0, // 4: netpulse.v1.DiscoveryReport.neighbors:type_name -> netpulse.v1.NeighborRecord
|
||||
1, // 5: netpulse.v1.DiscoveryReport.interfaces:type_name -> netpulse.v1.InterfaceRecord
|
||||
2, // 6: netpulse.v1.DiscoveryReport.devices:type_name -> netpulse.v1.DiscoveredDevice
|
||||
6, // 7: netpulse.v1.DiscoveryReport.started_at:type_name -> google.protobuf.Timestamp
|
||||
6, // 8: netpulse.v1.DiscoveryReport.finished_at:type_name -> google.protobuf.Timestamp
|
||||
8, // 9: netpulse.v1.DiscoveryReport.errors:type_name -> netpulse.v1.Error
|
||||
8, // 10: netpulse.v1.DiscoveryAck.error:type_name -> netpulse.v1.Error
|
||||
11, // [11:11] is the sub-list for method output_type
|
||||
11, // [11:11] is the sub-list for method input_type
|
||||
11, // [11:11] is the sub-list for extension type_name
|
||||
11, // [11:11] is the sub-list for extension extendee
|
||||
0, // [0:11] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_netpulse_v1_discovery_proto_init() }
|
||||
func file_netpulse_v1_discovery_proto_init() {
|
||||
if File_netpulse_v1_discovery_proto != nil {
|
||||
return
|
||||
}
|
||||
file_netpulse_v1_common_proto_init()
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_discovery_proto_rawDesc), len(file_netpulse_v1_discovery_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_netpulse_v1_discovery_proto_goTypes,
|
||||
DependencyIndexes: file_netpulse_v1_discovery_proto_depIdxs,
|
||||
MessageInfos: file_netpulse_v1_discovery_proto_msgTypes,
|
||||
}.Build()
|
||||
File_netpulse_v1_discovery_proto = out.File
|
||||
file_netpulse_v1_discovery_proto_goTypes = nil
|
||||
file_netpulse_v1_discovery_proto_depIdxs = nil
|
||||
}
|
||||
539
gen/go/netpulse/v1/logs.pb.go
Normal file
539
gen/go/netpulse/v1/logs.pb.go
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: logs.proto
|
||||
// Syslog та SNMP-трапи, зібрані агентом. → ts.syslog / ts.snmp_traps
|
||||
//
|
||||
// Агент слухає 514/udp і 162/udp у мережі клієнта й тунелює події
|
||||
// назовні. Це важливо не лише для журналу: подія "%SYS-5-CONFIG_I"
|
||||
// тригерить позачерговий бекап конфігу (ncm.device_policies.on_syslog).
|
||||
// =====================================================================
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc v3.21.12
|
||||
// source: netpulse/v1/logs.proto
|
||||
|
||||
package netpulsev1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type SyslogEntry struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Ts *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=ts,proto3" json:"ts,omitempty"`
|
||||
// Резолвиться агентом за source_ip; порожній, якщо пристрій невідомий.
|
||||
DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
SourceIp string `protobuf:"bytes,3,opt,name=source_ip,json=sourceIp,proto3" json:"source_ip,omitempty"`
|
||||
Facility uint32 `protobuf:"varint,4,opt,name=facility,proto3" json:"facility,omitempty"`
|
||||
Severity uint32 `protobuf:"varint,5,opt,name=severity,proto3" json:"severity,omitempty"` // 0 emerg .. 7 debug
|
||||
Hostname string `protobuf:"bytes,6,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||
Tag string `protobuf:"bytes,7,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||
Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"`
|
||||
// Розібрані поля, якщо агент упізнав формат (RFC5424 structured data).
|
||||
Parsed map[string]string `protobuf:"bytes,9,rep,name=parsed,proto3" json:"parsed,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) Reset() {
|
||||
*x = SyslogEntry{}
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyslogEntry) ProtoMessage() {}
|
||||
|
||||
func (x *SyslogEntry) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyslogEntry.ProtoReflect.Descriptor instead.
|
||||
func (*SyslogEntry) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetTs() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.Ts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetSourceIp() string {
|
||||
if x != nil {
|
||||
return x.SourceIp
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetFacility() uint32 {
|
||||
if x != nil {
|
||||
return x.Facility
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetSeverity() uint32 {
|
||||
if x != nil {
|
||||
return x.Severity
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetHostname() string {
|
||||
if x != nil {
|
||||
return x.Hostname
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetTag() string {
|
||||
if x != nil {
|
||||
return x.Tag
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyslogEntry) GetParsed() map[string]string {
|
||||
if x != nil {
|
||||
return x.Parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SnmpTrap struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Ts *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=ts,proto3" json:"ts,omitempty"`
|
||||
DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
SourceIp string `protobuf:"bytes,3,opt,name=source_ip,json=sourceIp,proto3" json:"source_ip,omitempty"`
|
||||
TrapOid string `protobuf:"bytes,4,opt,name=trap_oid,json=trapOid,proto3" json:"trap_oid,omitempty"`
|
||||
Varbinds []*VarBind `protobuf:"bytes,5,rep,name=varbinds,proto3" json:"varbinds,omitempty"`
|
||||
// v2c community або v3 security name — сервер перевіряє довіру джерела.
|
||||
AuthContext string `protobuf:"bytes,6,opt,name=auth_context,json=authContext,proto3" json:"auth_context,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) Reset() {
|
||||
*x = SnmpTrap{}
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SnmpTrap) ProtoMessage() {}
|
||||
|
||||
func (x *SnmpTrap) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SnmpTrap.ProtoReflect.Descriptor instead.
|
||||
func (*SnmpTrap) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) GetTs() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.Ts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) GetSourceIp() string {
|
||||
if x != nil {
|
||||
return x.SourceIp
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) GetTrapOid() string {
|
||||
if x != nil {
|
||||
return x.TrapOid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) GetVarbinds() []*VarBind {
|
||||
if x != nil {
|
||||
return x.Varbinds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SnmpTrap) GetAuthContext() string {
|
||||
if x != nil {
|
||||
return x.AuthContext
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type VarBind struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Oid string `protobuf:"bytes,1,opt,name=oid,proto3" json:"oid,omitempty"`
|
||||
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` // INTEGER, OCTET STRING, Counter64, ...
|
||||
Value string `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VarBind) Reset() {
|
||||
*x = VarBind{}
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *VarBind) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*VarBind) ProtoMessage() {}
|
||||
|
||||
func (x *VarBind) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use VarBind.ProtoReflect.Descriptor instead.
|
||||
func (*VarBind) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *VarBind) GetOid() string {
|
||||
if x != nil {
|
||||
return x.Oid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VarBind) GetType() string {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VarBind) GetValue() string {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type LogBatch struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BatchId uint64 `protobuf:"varint,1,opt,name=batch_id,json=batchId,proto3" json:"batch_id,omitempty"`
|
||||
AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
|
||||
Syslog []*SyslogEntry `protobuf:"bytes,3,rep,name=syslog,proto3" json:"syslog,omitempty"`
|
||||
Traps []*SnmpTrap `protobuf:"bytes,4,rep,name=traps,proto3" json:"traps,omitempty"`
|
||||
// Скільки подій агент відкинув через переповнення (rate limit).
|
||||
// Ненульове — сигнал, що клієнт шумить або ліміт замалий.
|
||||
Dropped uint64 `protobuf:"varint,5,opt,name=dropped,proto3" json:"dropped,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LogBatch) Reset() {
|
||||
*x = LogBatch{}
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LogBatch) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LogBatch) ProtoMessage() {}
|
||||
|
||||
func (x *LogBatch) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LogBatch.ProtoReflect.Descriptor instead.
|
||||
func (*LogBatch) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *LogBatch) GetBatchId() uint64 {
|
||||
if x != nil {
|
||||
return x.BatchId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LogBatch) GetAgentId() string {
|
||||
if x != nil {
|
||||
return x.AgentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogBatch) GetSyslog() []*SyslogEntry {
|
||||
if x != nil {
|
||||
return x.Syslog
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *LogBatch) GetTraps() []*SnmpTrap {
|
||||
if x != nil {
|
||||
return x.Traps
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *LogBatch) GetDropped() uint64 {
|
||||
if x != nil {
|
||||
return x.Dropped
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type LogAck struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AckedThroughBatchId uint64 `protobuf:"varint,1,opt,name=acked_through_batch_id,json=ackedThroughBatchId,proto3" json:"acked_through_batch_id,omitempty"`
|
||||
// Сервер просить фільтрувати шум на агенті: не слати нижче цієї severity.
|
||||
MinSeverity uint32 `protobuf:"varint,2,opt,name=min_severity,json=minSeverity,proto3" json:"min_severity,omitempty"`
|
||||
// Ліміт подій за секунду з одного джерела.
|
||||
RateLimitPerSource uint32 `protobuf:"varint,3,opt,name=rate_limit_per_source,json=rateLimitPerSource,proto3" json:"rate_limit_per_source,omitempty"`
|
||||
Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LogAck) Reset() {
|
||||
*x = LogAck{}
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LogAck) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LogAck) ProtoMessage() {}
|
||||
|
||||
func (x *LogAck) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_logs_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LogAck.ProtoReflect.Descriptor instead.
|
||||
func (*LogAck) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *LogAck) GetAckedThroughBatchId() uint64 {
|
||||
if x != nil {
|
||||
return x.AckedThroughBatchId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LogAck) GetMinSeverity() uint32 {
|
||||
if x != nil {
|
||||
return x.MinSeverity
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LogAck) GetRateLimitPerSource() uint32 {
|
||||
if x != nil {
|
||||
return x.RateLimitPerSource
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LogAck) GetError() *Error {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_netpulse_v1_logs_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_netpulse_v1_logs_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x16netpulse/v1/logs.proto\x12\vnetpulse.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xec\x02\n" +
|
||||
"\vSyslogEntry\x12*\n" +
|
||||
"\x02ts\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12\x1b\n" +
|
||||
"\tsource_ip\x18\x03 \x01(\tR\bsourceIp\x12\x1a\n" +
|
||||
"\bfacility\x18\x04 \x01(\rR\bfacility\x12\x1a\n" +
|
||||
"\bseverity\x18\x05 \x01(\rR\bseverity\x12\x1a\n" +
|
||||
"\bhostname\x18\x06 \x01(\tR\bhostname\x12\x10\n" +
|
||||
"\x03tag\x18\a \x01(\tR\x03tag\x12\x18\n" +
|
||||
"\amessage\x18\b \x01(\tR\amessage\x12<\n" +
|
||||
"\x06parsed\x18\t \x03(\v2$.netpulse.v1.SyslogEntry.ParsedEntryR\x06parsed\x1a9\n" +
|
||||
"\vParsedEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe0\x01\n" +
|
||||
"\bSnmpTrap\x12*\n" +
|
||||
"\x02ts\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x1b\n" +
|
||||
"\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12\x1b\n" +
|
||||
"\tsource_ip\x18\x03 \x01(\tR\bsourceIp\x12\x19\n" +
|
||||
"\btrap_oid\x18\x04 \x01(\tR\atrapOid\x120\n" +
|
||||
"\bvarbinds\x18\x05 \x03(\v2\x14.netpulse.v1.VarBindR\bvarbinds\x12!\n" +
|
||||
"\fauth_context\x18\x06 \x01(\tR\vauthContext\"E\n" +
|
||||
"\aVarBind\x12\x10\n" +
|
||||
"\x03oid\x18\x01 \x01(\tR\x03oid\x12\x12\n" +
|
||||
"\x04type\x18\x02 \x01(\tR\x04type\x12\x14\n" +
|
||||
"\x05value\x18\x03 \x01(\tR\x05value\"\xb9\x01\n" +
|
||||
"\bLogBatch\x12\x19\n" +
|
||||
"\bbatch_id\x18\x01 \x01(\x04R\abatchId\x12\x19\n" +
|
||||
"\bagent_id\x18\x02 \x01(\tR\aagentId\x120\n" +
|
||||
"\x06syslog\x18\x03 \x03(\v2\x18.netpulse.v1.SyslogEntryR\x06syslog\x12+\n" +
|
||||
"\x05traps\x18\x04 \x03(\v2\x15.netpulse.v1.SnmpTrapR\x05traps\x12\x18\n" +
|
||||
"\adropped\x18\x05 \x01(\x04R\adropped\"\xbd\x01\n" +
|
||||
"\x06LogAck\x123\n" +
|
||||
"\x16acked_through_batch_id\x18\x01 \x01(\x04R\x13ackedThroughBatchId\x12!\n" +
|
||||
"\fmin_severity\x18\x02 \x01(\rR\vminSeverity\x121\n" +
|
||||
"\x15rate_limit_per_source\x18\x03 \x01(\rR\x12rateLimitPerSource\x12(\n" +
|
||||
"\x05error\x18\x04 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_netpulse_v1_logs_proto_rawDescOnce sync.Once
|
||||
file_netpulse_v1_logs_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_netpulse_v1_logs_proto_rawDescGZIP() []byte {
|
||||
file_netpulse_v1_logs_proto_rawDescOnce.Do(func() {
|
||||
file_netpulse_v1_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_netpulse_v1_logs_proto_rawDesc), len(file_netpulse_v1_logs_proto_rawDesc)))
|
||||
})
|
||||
return file_netpulse_v1_logs_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_netpulse_v1_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_netpulse_v1_logs_proto_goTypes = []any{
|
||||
(*SyslogEntry)(nil), // 0: netpulse.v1.SyslogEntry
|
||||
(*SnmpTrap)(nil), // 1: netpulse.v1.SnmpTrap
|
||||
(*VarBind)(nil), // 2: netpulse.v1.VarBind
|
||||
(*LogBatch)(nil), // 3: netpulse.v1.LogBatch
|
||||
(*LogAck)(nil), // 4: netpulse.v1.LogAck
|
||||
nil, // 5: netpulse.v1.SyslogEntry.ParsedEntry
|
||||
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
|
||||
(*Error)(nil), // 7: netpulse.v1.Error
|
||||
}
|
||||
var file_netpulse_v1_logs_proto_depIdxs = []int32{
|
||||
6, // 0: netpulse.v1.SyslogEntry.ts:type_name -> google.protobuf.Timestamp
|
||||
5, // 1: netpulse.v1.SyslogEntry.parsed:type_name -> netpulse.v1.SyslogEntry.ParsedEntry
|
||||
6, // 2: netpulse.v1.SnmpTrap.ts:type_name -> google.protobuf.Timestamp
|
||||
2, // 3: netpulse.v1.SnmpTrap.varbinds:type_name -> netpulse.v1.VarBind
|
||||
0, // 4: netpulse.v1.LogBatch.syslog:type_name -> netpulse.v1.SyslogEntry
|
||||
1, // 5: netpulse.v1.LogBatch.traps:type_name -> netpulse.v1.SnmpTrap
|
||||
7, // 6: netpulse.v1.LogAck.error:type_name -> netpulse.v1.Error
|
||||
7, // [7:7] is the sub-list for method output_type
|
||||
7, // [7:7] is the sub-list for method input_type
|
||||
7, // [7:7] is the sub-list for extension type_name
|
||||
7, // [7:7] is the sub-list for extension extendee
|
||||
0, // [0:7] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_netpulse_v1_logs_proto_init() }
|
||||
func file_netpulse_v1_logs_proto_init() {
|
||||
if File_netpulse_v1_logs_proto != nil {
|
||||
return
|
||||
}
|
||||
file_netpulse_v1_common_proto_init()
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_logs_proto_rawDesc), len(file_netpulse_v1_logs_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_netpulse_v1_logs_proto_goTypes,
|
||||
DependencyIndexes: file_netpulse_v1_logs_proto_depIdxs,
|
||||
MessageInfos: file_netpulse_v1_logs_proto_msgTypes,
|
||||
}.Build()
|
||||
File_netpulse_v1_logs_proto = out.File
|
||||
file_netpulse_v1_logs_proto_goTypes = nil
|
||||
file_netpulse_v1_logs_proto_depIdxs = nil
|
||||
}
|
||||
1093
gen/go/netpulse/v1/ncm.pb.go
Normal file
1093
gen/go/netpulse/v1/ncm.pb.go
Normal file
File diff suppressed because it is too large
Load diff
1109
gen/go/netpulse/v1/telemetry.pb.go
Normal file
1109
gen/go/netpulse/v1/telemetry.pb.go
Normal file
File diff suppressed because it is too large
Load diff
189
proto/README.md
Normal file
189
proto/README.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# NetPulse — контракт агент↔сервер (Етап 2)
|
||||
|
||||
gRPC / protobuf, пакет `netpulse.v1`. Джерело істини — файли в `netpulse/v1/`;
|
||||
`gen/go` згенерований і комітиться, щоб збірка агента не залежала від наявності `buf`.
|
||||
|
||||
## Головне обмеження, з якого випливає вся форма контракту
|
||||
|
||||
**Усі з'єднання ініціює агент.** У мережі клієнта немає ані відкритих портів, ані
|
||||
прокидання NAT — зонд стоїть за фаєрволом і має лише вихідний доступ. Тому
|
||||
«команда з сервера» фізично є повідомленням у зустрічному напрямку вже відкритого
|
||||
агентом bidi-стріму `Control`. Це не деталь реалізації, а причина, чому контракт
|
||||
побудований навколо довгоживучих потоків, а не навколо RPC-викликів у бік агента.
|
||||
|
||||
## Файли
|
||||
|
||||
| Файл | Зміст |
|
||||
|------|-------|
|
||||
| `common.proto` | Спільні типи: `Status`, `Transport`, `DiscoveryProto`, `Error`, `DeviceTarget`, `Credential`, `AgentBuild`, `AgentHealth` |
|
||||
| `agent.proto` | `EnrollmentService`, `AgentService`, `ControlUp`/`ControlDown` і все, що всередині них |
|
||||
| `telemetry.proto` | `SeriesDescriptor`, `MetricSample`, `IcmpResult`, `InterfaceCounters`, `TelemetryBatch`, `TelemetryAck` |
|
||||
| `discovery.proto` | `NeighborRecord`, `InterfaceRecord`, `DiscoveredDevice`, `DiscoveryReport` |
|
||||
| `ncm.proto` | `ConfigJob`, `ConfigUpload` (header/chunk/trailer), `ConfigApplyJob` |
|
||||
| `logs.proto` | `SyslogEntry`, `SnmpTrap`, `LogBatch` |
|
||||
|
||||
## Сервіси
|
||||
|
||||
```
|
||||
EnrollmentService
|
||||
Enroll(EnrollRequest) → EnrollResponse одноразово, без клієнтського сертифіката
|
||||
|
||||
AgentService усе далі — тільки mTLS
|
||||
Control(stream ControlUp) → stream ControlDown керування, довгоживучий
|
||||
StreamTelemetry(stream TelemetryBatch) → stream TelemetryAck
|
||||
StreamLogs(stream LogBatch) → stream LogAck
|
||||
ReportDiscovery(DiscoveryReport) → DiscoveryAck
|
||||
UploadConfig(stream ConfigUpload) → ConfigReceipt
|
||||
```
|
||||
|
||||
Чотири окремі стріми, а не один — навмисно. Пачка на 10 000 семплів не має
|
||||
блокувати heartbeat і затримувати команду з сервера, а сплеск syslog під час
|
||||
аварії не має топити телеметрію, за якою цю аварію й видно.
|
||||
|
||||
## Життєвий цикл сесії
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Агент (мережа клієнта)
|
||||
participant S as Сервер
|
||||
|
||||
Note over A,S: одноразово
|
||||
A->>S: Enroll(enrollment_token, CSR)
|
||||
S-->>A: сертифікат + CA + control_endpoint
|
||||
|
||||
Note over A,S: кожна сесія, тільки вихідне з'єднання
|
||||
A->>S: Control: Hello(build, task_plan_hash, last_acked_batch_id)
|
||||
S-->>A: Welcome(session_id, server_time, ліміти батчингу)
|
||||
S-->>A: TaskPlan(tasks, devices) або TaskDelta
|
||||
S-->>A: ModuleControl(які модулі активувати)
|
||||
S-->>A: CredentialBundle(розшифровані креди, з TTL)
|
||||
|
||||
loop опитування
|
||||
A->>S: StreamTelemetry: TelemetryBatch(new_series?, samples, icmp, interfaces)
|
||||
S-->>A: TelemetryAck(acked_through, max_in_flight)
|
||||
A->>S: Control: Heartbeat(AgentHealth)
|
||||
S-->>A: Control: Ping
|
||||
A->>S: Control: Pong
|
||||
end
|
||||
|
||||
Note over A,S: за подією або розкладом
|
||||
S-->>A: Control: ConfigJob(commands, prompt_regex)
|
||||
A->>S: UploadConfig: header → chunk* → trailer(sha256)
|
||||
S-->>A: ConfigReceipt(commit_sha | unchanged)
|
||||
```
|
||||
|
||||
## Рішення, які варто розуміти перед реалізацією агента
|
||||
|
||||
### Інтернування серій
|
||||
|
||||
Повторювати `device_id` (36 байт) + `metric_key` + labels у кожному семплі задорого:
|
||||
при 50 000 пристроїв це десятки байт службових даних на одне число `float64`.
|
||||
Тому агент реєструє серію один раз під локальним номером `series_ref`, далі шле
|
||||
лише номер і значення. Сервер тримає мапу `series_ref → ts.series.id` на час сесії.
|
||||
|
||||
Заміряно в `test/contract`: **66 байт → 25 байт** на семпл.
|
||||
|
||||
`series_ref` дійсний рівно в межах сесії. Після реконекту агент нумерує з 1 і
|
||||
реєструє все заново. Якщо сервер втратив стан — відповідає `reset_series_table = true`,
|
||||
а не мовчки викидає дані.
|
||||
|
||||
### Хто рахує швидкості
|
||||
|
||||
Агент шле **і** сирі 64-бітні лічильники, **і** вже пораховані `in_bps`/`out_bps`/
|
||||
`util_*_pct`. Швидкості рахує агент, бо лише він знає точний інтервал між двома
|
||||
опитуваннями — мережева затримка робить серверний розрахунок неточним. Сирі
|
||||
лічильники потрібні, щоб сервер міг перерахувати заднім числом. Обробка
|
||||
wrap/reset — на агенті: при перезавантаженні пристрою виставляється
|
||||
`counter_reset = true`, і швидкості цього разу недійсні.
|
||||
|
||||
### Хто чистить конфіги
|
||||
|
||||
Агент віддає **сирий** текст. Scrub (прибрати `uptime` та timestamp'и, щоб не шуміти
|
||||
в diff), redact (замаскувати паролі) і коміт у Git — на сервері. Це навмисно:
|
||||
правила очищення живуть у `ncm.profiles` і мають змінюватись без оновлення
|
||||
агентів у полі.
|
||||
|
||||
### Хто вирішує топологію
|
||||
|
||||
Агент доповідає сире: «на порту X я бачу chassis-id Y, port-id Z». Він **не**
|
||||
вирішує, хто з ким з'єднаний. Резолвер на сервері зводить це в `topo.links` і
|
||||
виставляє `confidence`. Так автовиявлення лишається відтворюваним і не залежить
|
||||
від версії агента в полі.
|
||||
|
||||
### Непрозорі параметри задач
|
||||
|
||||
`Task.params_json` — байти, а не типізоване поле. Валідність гарантує
|
||||
`params_schema` з `core.check_types`, розбирає їх сам модуль. Завдяки цьому новий
|
||||
плагін не потребує зміни `.proto` — саме те, заради чого затівалась плагінна
|
||||
архітектура. Модуль-виконавець визначається префіксом `check_type` до крапки:
|
||||
`snmp.if` → модуль `snmp`.
|
||||
|
||||
### Семантика доставки
|
||||
|
||||
**At-least-once.** Дедуплікацію забезпечує сама схема БД: первинні ключі
|
||||
`(ts, device_id)`, `(ts, interface_id)`, `(ts, series_id)` роблять повторний запис
|
||||
безпечним. Агент тримає неacknowledged батчі й після реконекту шле їх із
|
||||
`is_retransmit = true`, продовжуючи від `last_acked_batch_id` з `Hello`.
|
||||
|
||||
### Зворотний тиск
|
||||
|
||||
Розмір батчу, інтервал і `max_in_flight` диктує сервер у `Welcome`, а коригує в
|
||||
кожному `TelemetryAck`. Балакучого агента можна пригальмувати на льоту, не
|
||||
оновлюючи бінарник у полі. `AgentHealth.dropped_samples` показує, коли ліміти
|
||||
затиснуті надто сильно.
|
||||
|
||||
## Безпека
|
||||
|
||||
- **mTLS на всьому, крім `Enroll`.** Агент приходить із одноразовим enrollment-токеном
|
||||
і CSR, іде з власним сертифікатом. Приватний ключ ніколи не залишає агента.
|
||||
- **Креденшели передаються розшифрованими** — сервер дістає їх із `core.secrets` і
|
||||
розшифровує. Агент тримає їх лише в пам'яті, не пише на диск, не логує, і
|
||||
зобов'язаний занулити після `Credential.expires_at`.
|
||||
- **Самооновлення підписане.** `UpdateInfo` несе sha256 бінарника і підпис Ed25519
|
||||
над ним. Без валідного підпису оновлення не застосовується: інакше компрометація
|
||||
CDN перетворюється на RCE в мережі кожного клієнта.
|
||||
- **Відкат конфігурації** (`ConfigApplyJob`) агент виконує беззастережно — уся логіка
|
||||
погодження (`ncm.rollbacks`: draft → awaiting_approval → approved) лишається на
|
||||
сервері. `confirm_timeout` вмикає confirmed commit там, де пристрій це підтримує:
|
||||
найкращий захист від втрати керування після помилкового правила фаєрвола.
|
||||
|
||||
## Версіонування
|
||||
|
||||
Пакет `netpulse.v1`. Правила: не змінювати номери полів, не перевикористовувати
|
||||
видалені (позначати `reserved`), нові поля — тільки додавати. Ламкі зміни —
|
||||
через `netpulse.v2` поруч, бо агенти в полі оновлюються не одночасно з сервером.
|
||||
|
||||
`buf breaking` у CI проти головної гілки ловить порушення автоматично.
|
||||
|
||||
## Кодогенерація
|
||||
|
||||
```bash
|
||||
buf generate
|
||||
```
|
||||
|
||||
Без `buf` (те, чим це перевірялось):
|
||||
|
||||
```bash
|
||||
protoc -I proto --go_out=gen/go --go_opt=paths=source_relative --go-grpc_out=gen/go --go-grpc_opt=paths=source_relative proto/netpulse/v1/*.proto
|
||||
```
|
||||
|
||||
## Стан перевірки
|
||||
|
||||
Перевірено на стенді Debian 13: protoc 3.21.12, Go 1.24.4, buf 1.72.0.
|
||||
|
||||
| Крок | Результат |
|
||||
|------|-----------|
|
||||
| `protoc` — валідність усіх 6 файлів і залежностей | OK |
|
||||
| `buf lint` (STANDARD) | без зауважень |
|
||||
| Генерація Go + gRPC, `go build`, `go vet` | OK |
|
||||
| `go test ./test/contract/...` — 5 наскрізних тестів на bufconn | усі PASS |
|
||||
|
||||
Тести в [test/contract/contract_test.go](../test/contract/contract_test.go) перевіряють не компіляцію, а поведінку:
|
||||
|
||||
| Тест | Що доводить |
|
||||
|------|-------------|
|
||||
| `TestControlHandshakeAndTaskPush` | Hello → Welcome → TaskPlan доїжджає в зустрічному напрямку стріму; `params_json` розбирається; модуль виводиться з `check_type`; Ping/Pong міряє clock skew |
|
||||
| `TestTelemetrySeriesInterning` | Серія реєструється раз, далі розв'язується з номера; ICMP і `util_out_pct` доїжджають без спотворень; економія 66 → 25 байт |
|
||||
| `TestTelemetryUnknownSeriesRefTriggersReset` | Невідомий `series_ref` дає `reset_series_table` + retryable-помилку, а не тихе відкидання даних |
|
||||
| `TestConfigUploadChunked` | header → chunk × N → trailer збирається байт-у-байт зі звіркою sha256 |
|
||||
| `TestConfigUploadRejectsBadChecksum` | Пошкоджений конфіг відхиляється з `checksum_mismatch`, а не комітиться в Git |
|
||||
355
proto/netpulse/v1/agent.proto
Normal file
355
proto/netpulse/v1/agent.proto
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: agent.proto
|
||||
// Головний контракт агент↔сервер.
|
||||
//
|
||||
// ФУНДАМЕНТАЛЬНЕ ОБМЕЖЕННЯ: усі з'єднання ініціює агент.
|
||||
// Сервер ніколи не стукає в мережу клієнта — там немає ані відкритих
|
||||
// портів, ані прокидання NAT. Тому "команда з сервера" фізично є
|
||||
// повідомленням у зустрічному напрямку вже відкритого агентом
|
||||
// bidi-стріму Control.
|
||||
// =====================================================================
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package netpulse.v1;
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "netpulse/v1/common.proto";
|
||||
import "netpulse/v1/discovery.proto";
|
||||
import "netpulse/v1/logs.proto";
|
||||
import "netpulse/v1/ncm.proto";
|
||||
import "netpulse/v1/telemetry.proto";
|
||||
|
||||
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Реєстрація зонда
|
||||
//
|
||||
// Єдиний RPC без клієнтського сертифіката: агент приходить із
|
||||
// одноразовим enrollment-токеном (згенерованим у UI) і CSR, а йде
|
||||
// з власним сертифікатом. Далі — тільки mTLS.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
service EnrollmentService {
|
||||
rpc Enroll(EnrollRequest) returns (EnrollResponse);
|
||||
}
|
||||
|
||||
message EnrollRequest {
|
||||
// Одноразовий токен із UI: "np_enroll_...". Згорає після використання.
|
||||
string enrollment_token = 1;
|
||||
// PKCS#10. Приватний ключ ніколи не залишає агента.
|
||||
bytes csr = 2;
|
||||
string hostname = 3;
|
||||
AgentBuild build = 4;
|
||||
// Бажане ім'я зонда; сервер може змінити на унікальне.
|
||||
string requested_name = 5;
|
||||
}
|
||||
|
||||
message EnrollResponse {
|
||||
string agent_id = 1;
|
||||
string agent_name = 2;
|
||||
// Підписаний клієнтський сертифікат + ланцюг CA сервера.
|
||||
bytes certificate = 3;
|
||||
bytes ca_chain = 4;
|
||||
google.protobuf.Timestamp certificate_expires_at = 5;
|
||||
// Куди підключатись далі (може відрізнятись від адреси реєстрації:
|
||||
// балансувальник, регіональний шлюз).
|
||||
string control_endpoint = 6;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Основний сервіс
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
service AgentService {
|
||||
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
|
||||
// з'єднання. Розрив стріму = кінець сесії з усім її станом
|
||||
// (таблиця серій, in-flight батчі).
|
||||
rpc Control(stream ControlUp) returns (stream ControlDown);
|
||||
|
||||
// Телеметрія окремим стрімом, щоб пачка на 10 000 семплів
|
||||
// не блокувала heartbeat і не затримувала команду з сервера.
|
||||
rpc StreamTelemetry(stream TelemetryBatch) returns (stream TelemetryAck);
|
||||
|
||||
// Syslog/трапи — теж окремо: сплеск логів під час аварії не має
|
||||
// топити телеметрію, за якою ця аварія й видно.
|
||||
rpc StreamLogs(stream LogBatch) returns (stream LogAck);
|
||||
|
||||
// Автовиявлення: рідко, великими звітами.
|
||||
rpc ReportDiscovery(DiscoveryReport) returns (DiscoveryAck);
|
||||
|
||||
// Вивантаження зібраного конфігу (чанками).
|
||||
rpc UploadConfig(stream ConfigUpload) returns (ConfigReceipt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Агент → Сервер
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message ControlUp {
|
||||
// Монотонний номер повідомлення в сесії — для трасування.
|
||||
uint64 seq = 1;
|
||||
|
||||
oneof payload {
|
||||
Hello hello = 2;
|
||||
Heartbeat heartbeat = 3;
|
||||
TaskStatusUpdate task_status = 4;
|
||||
ModuleStatusUpdate module_status = 5;
|
||||
ConfigApplyResult config_apply_result = 6;
|
||||
CredentialRequest credential_request = 7;
|
||||
Pong pong = 8;
|
||||
AgentEvent event = 9;
|
||||
}
|
||||
}
|
||||
|
||||
// Перше повідомлення в стрімі. Сервер відповідає Welcome.
|
||||
message Hello {
|
||||
string agent_id = 1;
|
||||
AgentBuild build = 2;
|
||||
string hostname = 3;
|
||||
// Локальні адреси зонда — корисно для L2-виявлення й діагностики NAT.
|
||||
repeated string local_addresses = 4;
|
||||
google.protobuf.Timestamp started_at = 5;
|
||||
// Хеш конфігурації задач, яку агент має локально. Якщо збігається
|
||||
// з серверним — сервер не шле повний план, лише дельти.
|
||||
bytes task_plan_hash = 6;
|
||||
// Останній батч, який агент вважає підтвердженим. Дозволяє
|
||||
// продовжити з місця розриву замість повного перезливу.
|
||||
uint64 last_acked_batch_id = 7;
|
||||
}
|
||||
|
||||
message Heartbeat {
|
||||
google.protobuf.Timestamp ts = 1;
|
||||
AgentHealth health = 2;
|
||||
// Скільки задач зараз виконується / чекає в черзі.
|
||||
uint32 tasks_running = 3;
|
||||
uint32 tasks_queued = 4;
|
||||
}
|
||||
|
||||
// Життєвий цикл задачі. Сервер оновлює core.checks.last_run_at/last_error.
|
||||
message TaskStatusUpdate {
|
||||
string check_id = 1;
|
||||
enum State {
|
||||
STATE_UNSPECIFIED = 0;
|
||||
STATE_ACCEPTED = 1;
|
||||
STATE_RUNNING = 2;
|
||||
STATE_SUCCEEDED = 3;
|
||||
STATE_FAILED = 4;
|
||||
STATE_SKIPPED = 5; // не встиг у вікно інтервалу
|
||||
STATE_REJECTED = 6; // модуль не активний або параметри невалідні
|
||||
}
|
||||
State state = 2;
|
||||
google.protobuf.Timestamp ts = 3;
|
||||
Error error = 4;
|
||||
}
|
||||
|
||||
message ModuleStatusUpdate {
|
||||
string module_key = 1;
|
||||
bool active = 2;
|
||||
string version = 3;
|
||||
Error error = 4;
|
||||
}
|
||||
|
||||
// Агент просить креденшели: або вперше, або бо старі протермінувались.
|
||||
message CredentialRequest {
|
||||
repeated string device_ids = 1;
|
||||
string reason = 2; // "expired" | "missing" | "auth_failed"
|
||||
}
|
||||
|
||||
message Pong {
|
||||
uint64 ping_id = 1;
|
||||
google.protobuf.Timestamp agent_time = 2;
|
||||
}
|
||||
|
||||
// Позапланова подія самого зонда (не пристрою).
|
||||
message AgentEvent {
|
||||
enum Kind {
|
||||
KIND_UNSPECIFIED = 0;
|
||||
KIND_STARTED = 1;
|
||||
KIND_STOPPING = 2;
|
||||
KIND_MODULE_CRASH = 3;
|
||||
KIND_BUFFER_OVERFLOW = 4;
|
||||
KIND_CLOCK_JUMP = 5;
|
||||
KIND_UPDATE_APPLIED = 6;
|
||||
KIND_CONFIG_REJECTED = 7;
|
||||
}
|
||||
Kind kind = 1;
|
||||
google.protobuf.Timestamp ts = 2;
|
||||
string message = 3;
|
||||
map<string, string> details = 4;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Сервер → Агент
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message ControlDown {
|
||||
uint64 seq = 1;
|
||||
|
||||
oneof payload {
|
||||
Welcome welcome = 2;
|
||||
TaskPlan task_plan = 3;
|
||||
TaskDelta task_delta = 4;
|
||||
ModuleControl module_control = 5;
|
||||
CredentialBundle credentials = 6;
|
||||
ConfigJob config_job = 7;
|
||||
ConfigApplyJob config_apply_job = 8;
|
||||
DiscoveryRequest discovery_request = 9;
|
||||
Ping ping = 10;
|
||||
Directive directive = 11;
|
||||
}
|
||||
}
|
||||
|
||||
message Welcome {
|
||||
string session_id = 1;
|
||||
// Час сервера — агент рахує з нього clock_skew. Мітки часу в
|
||||
// телеметрії лишаються агентськими, але сервер знає поправку.
|
||||
google.protobuf.Timestamp server_time = 2;
|
||||
google.protobuf.Duration heartbeat_interval = 3;
|
||||
|
||||
// Параметри батчингу телеметрії. Сервер диктує їх на льоту —
|
||||
// так можна пригальмувати балакучого агента без оновлення бінарника.
|
||||
uint32 telemetry_max_batch_size = 4;
|
||||
google.protobuf.Duration telemetry_max_batch_interval = 5;
|
||||
uint32 telemetry_max_in_flight = 6;
|
||||
|
||||
// Скільки чеків агенту дозволено виконувати паралельно.
|
||||
uint32 max_concurrent_checks = 7;
|
||||
// Ліміт ICMP, щоб зонд не виглядав як сканер для IDS клієнта.
|
||||
uint32 icmp_rate_pps = 8;
|
||||
|
||||
// Повний план задач надійде окремим TaskPlan, якщо хеш не збігся.
|
||||
bool task_plan_follows = 9;
|
||||
}
|
||||
|
||||
// Повна синхронізація: те, що агент має виконувати. Заміщає все.
|
||||
message TaskPlan {
|
||||
bytes plan_hash = 1;
|
||||
repeated Task tasks = 2;
|
||||
repeated DeviceTarget devices = 3;
|
||||
// План може прийти частинами; агент застосовує його атомарно
|
||||
// лише після final = true.
|
||||
bool final = 4;
|
||||
uint32 part = 5;
|
||||
}
|
||||
|
||||
// Інкрементальна зміна плану — типовий випадок після додавання
|
||||
// одного пристрою в UI. Перезаливати 50 000 задач заради цього не треба.
|
||||
message TaskDelta {
|
||||
bytes plan_hash = 1; // хеш плану ПІСЛЯ застосування дельти
|
||||
repeated Task upsert = 2;
|
||||
repeated string remove_check_ids = 3;
|
||||
repeated DeviceTarget upsert_devices = 4;
|
||||
repeated string remove_device_ids = 5;
|
||||
}
|
||||
|
||||
// Одна задача опитування. Дзеркалить core.checks.
|
||||
message Task {
|
||||
string check_id = 1;
|
||||
string device_id = 2;
|
||||
// Заповнюється для чеків рівня інтерфейсу.
|
||||
string interface_id = 3;
|
||||
|
||||
// "<plugin>.<check>": icmp.ping, snmp.if, http.status, ncm.backup.
|
||||
// Модуль-виконавець визначається префіксом до крапки.
|
||||
string check_type = 4;
|
||||
|
||||
// Параметри чека — непрозорий для ядра JSON. Валідність гарантує
|
||||
// params_schema з core.check_types; розбирає їх сам модуль.
|
||||
// Так новий плагін не потребує зміни .proto.
|
||||
bytes params_json = 5;
|
||||
|
||||
google.protobuf.Duration interval = 6;
|
||||
google.protobuf.Duration timeout = 7;
|
||||
uint32 retries = 8;
|
||||
bool enabled = 9;
|
||||
|
||||
// Зсув у межах інтервалу, щоб 5000 чеків не стартували одночасно.
|
||||
// Розраховує сервер — детерміновано від check_id, щоб зберігався
|
||||
// між перезапусками.
|
||||
google.protobuf.Duration schedule_offset = 10;
|
||||
|
||||
string credential_id = 11;
|
||||
}
|
||||
|
||||
// Активація/деактивація модулів на зонді.
|
||||
message ModuleControl {
|
||||
repeated ModuleSpec modules = 1;
|
||||
// Модулі, відсутні в списку, вимкнути.
|
||||
bool exclusive = 2;
|
||||
}
|
||||
|
||||
message ModuleSpec {
|
||||
string key = 1; // icmp, snmp, topology, ncm, modbus
|
||||
bool enabled = 2;
|
||||
string min_version = 3;
|
||||
// Налаштування модуля (JSON), напр. розмір SNMP-пулу.
|
||||
bytes config_json = 4;
|
||||
}
|
||||
|
||||
message CredentialBundle {
|
||||
// device_id → креденшели, які до нього застосовні, за пріоритетом.
|
||||
map<string, CredentialList> by_device = 1;
|
||||
// Спільний строк придатності комплекту.
|
||||
google.protobuf.Timestamp expires_at = 2;
|
||||
}
|
||||
|
||||
message CredentialList {
|
||||
repeated Credential credentials = 1;
|
||||
}
|
||||
|
||||
message DiscoveryRequest {
|
||||
string run_id = 1;
|
||||
repeated DiscoveryProto protocols = 2;
|
||||
// Пристрої, які опитати на предмет сусідів.
|
||||
repeated string device_ids = 3;
|
||||
// Підмережі для сканування (CIDR). Порожньо — не сканувати.
|
||||
repeated string subnets = 4;
|
||||
// Обмеження темпу сканування, щоб не збурювати мережу клієнта.
|
||||
uint32 scan_rate_pps = 5;
|
||||
google.protobuf.Duration timeout = 6;
|
||||
}
|
||||
|
||||
message Ping {
|
||||
uint64 ping_id = 1;
|
||||
google.protobuf.Timestamp server_time = 2;
|
||||
}
|
||||
|
||||
// Команди життєвого циклу самого агента.
|
||||
message Directive {
|
||||
enum Action {
|
||||
ACTION_UNSPECIFIED = 0;
|
||||
// Перечитати конфігурацію, не розриваючи сесію.
|
||||
ACTION_RELOAD = 1;
|
||||
// Дозбирати й відправити буфер, потім коректно завершитись.
|
||||
ACTION_DRAIN = 2;
|
||||
// Зупинити опитування, лишити канал (несплата → grace-період).
|
||||
ACTION_PAUSE = 3;
|
||||
ACTION_RESUME = 4;
|
||||
// Доступне оновлення: деталі в update.
|
||||
ACTION_UPDATE = 5;
|
||||
// Перепідключитись (перебалансування сервера). Агент чекає
|
||||
// reconnect_after і йде на новий endpoint.
|
||||
ACTION_RECONNECT = 6;
|
||||
// Скинути локальну таблицю series_ref і перереєструвати серії.
|
||||
ACTION_RESET_SERIES_TABLE = 7;
|
||||
}
|
||||
Action action = 1;
|
||||
string reason = 2;
|
||||
google.protobuf.Duration reconnect_after = 3;
|
||||
string new_endpoint = 4;
|
||||
UpdateInfo update = 5;
|
||||
}
|
||||
|
||||
message UpdateInfo {
|
||||
string version = 1;
|
||||
string download_url = 2;
|
||||
// sha256 бінарника — агент зобов'язаний звірити перед запуском.
|
||||
bytes sha256 = 3;
|
||||
// Підпис постачальника (Ed25519) над sha256. Без валідного підпису
|
||||
// оновлення не застосовується: інакше компрометація CDN
|
||||
// перетворюється на RCE в мережі кожного клієнта.
|
||||
bytes signature = 4;
|
||||
bool mandatory = 5;
|
||||
}
|
||||
153
proto/netpulse/v1/common.proto
Normal file
153
proto/netpulse/v1/common.proto
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: common.proto
|
||||
// Спільні типи для всіх сервісів контракту агент↔сервер.
|
||||
// =====================================================================
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package netpulse.v1;
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Ідентифікатори
|
||||
//
|
||||
// UUID передаються рядком у канонічному вигляді (36 байт). Це дорожче за
|
||||
// bytes(16), але контрольний канал низькочастотний, а логи й трейси
|
||||
// читабельні. Гарячий шлях телеметрії натомість використовує числові
|
||||
// series_ref (див. telemetry.proto) — саме там економія має значення.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Статус об'єкта. Дзеркалить inv.device_status у БД.
|
||||
enum Status {
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
STATUS_UP = 1;
|
||||
STATUS_DOWN = 2;
|
||||
STATUS_WARNING = 3;
|
||||
STATUS_UNKNOWN = 4;
|
||||
STATUS_MAINTENANCE = 5;
|
||||
}
|
||||
|
||||
// Транспорт підключення до пристрою. Дзеркалить inv.credential_proto.
|
||||
enum Transport {
|
||||
TRANSPORT_UNSPECIFIED = 0;
|
||||
TRANSPORT_SSH = 1;
|
||||
TRANSPORT_TELNET = 2;
|
||||
TRANSPORT_SNMP_V2C = 3;
|
||||
TRANSPORT_SNMP_V3 = 4;
|
||||
TRANSPORT_HTTP = 5;
|
||||
TRANSPORT_HTTPS = 6;
|
||||
TRANSPORT_API = 7;
|
||||
TRANSPORT_MODBUS = 8;
|
||||
}
|
||||
|
||||
// Протокол автовиявлення сусідів. Дзеркалить topo.discovery_proto.
|
||||
enum DiscoveryProto {
|
||||
DISCOVERY_PROTO_UNSPECIFIED = 0;
|
||||
DISCOVERY_PROTO_LLDP = 1;
|
||||
DISCOVERY_PROTO_CDP = 2;
|
||||
DISCOVERY_PROTO_ARP = 3;
|
||||
DISCOVERY_PROTO_FDB = 4;
|
||||
DISCOVERY_PROTO_STP = 5;
|
||||
DISCOVERY_PROTO_ROUTING = 6;
|
||||
DISCOVERY_PROTO_SNMP_TOPO = 7;
|
||||
}
|
||||
|
||||
// Помилка виконання. Свідомо не gRPC-статус: одна задача може впасти,
|
||||
// поки решта потоку жива, тому помилки їдуть у корисному навантаженні.
|
||||
message Error {
|
||||
// Стабільний машинний код: "timeout", "auth_failed", "unreachable",
|
||||
// "snmp_no_such_object", "prompt_mismatch", "permission_denied".
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
// Чи має сенс повторювати. Сервер вирішує, чи планувати retry.
|
||||
bool retryable = 3;
|
||||
map<string, string> details = 4;
|
||||
}
|
||||
|
||||
// Мінімальний опис пристрою, потрібний агенту для опитування.
|
||||
// Агент не має жодного уявлення про тенанти, тарифи чи мапи —
|
||||
// це навмисно: зонд знає лише "куди йти і як".
|
||||
message DeviceTarget {
|
||||
string device_id = 1;
|
||||
string name = 2;
|
||||
// Адреса опитування: IP або FQDN.
|
||||
string address = 3;
|
||||
// Ідентифікатори для кореляції автовиявлення (topo.neighbors).
|
||||
string chassis_id = 4;
|
||||
string system_name = 5;
|
||||
}
|
||||
|
||||
// Облікові дані. Приходять із сервера вже РОЗШИФРОВАНИМИ.
|
||||
//
|
||||
// БЕЗПЕКА:
|
||||
// * передаються виключно всередині mTLS-каналу;
|
||||
// * агент тримає їх лише в пам'яті, ніколи не пише на диск і не логує;
|
||||
// * мають TTL (expires_at) — після спливу агент зобов'язаний запитати
|
||||
// новий комплект, а старий занулити.
|
||||
message Credential {
|
||||
string credential_id = 1;
|
||||
Transport transport = 2;
|
||||
string username = 3;
|
||||
uint32 port = 4;
|
||||
|
||||
oneof secret {
|
||||
string password = 5;
|
||||
bytes private_key = 6;
|
||||
string community = 7; // SNMP v2c
|
||||
string token = 8; // HTTP/API
|
||||
}
|
||||
|
||||
// Enable/privileged-пароль (Cisco-подібні).
|
||||
string enable_password = 9;
|
||||
// Параметри SNMPv3.
|
||||
SnmpV3Options snmp_v3 = 10;
|
||||
|
||||
google.protobuf.Timestamp expires_at = 11;
|
||||
}
|
||||
|
||||
message SnmpV3Options {
|
||||
enum SecurityLevel {
|
||||
SECURITY_LEVEL_UNSPECIFIED = 0;
|
||||
SECURITY_LEVEL_NO_AUTH_NO_PRIV = 1;
|
||||
SECURITY_LEVEL_AUTH_NO_PRIV = 2;
|
||||
SECURITY_LEVEL_AUTH_PRIV = 3;
|
||||
}
|
||||
SecurityLevel level = 1;
|
||||
string auth_protocol = 2; // MD5 | SHA | SHA224 | SHA256 | SHA384 | SHA512
|
||||
string auth_password = 3;
|
||||
string priv_protocol = 4; // DES | AES | AES192 | AES256
|
||||
string priv_password = 5;
|
||||
string context_name = 6;
|
||||
string security_name = 7;
|
||||
}
|
||||
|
||||
// Опис версії агента — для сумісності й самооновлення.
|
||||
message AgentBuild {
|
||||
string version = 1; // 1.4.2
|
||||
string commit = 2;
|
||||
string os = 3; // linux | windows | darwin
|
||||
string arch = 4; // amd64 | arm64
|
||||
string go_version = 5;
|
||||
// Модулі, вкомпільовані в цей бінарник (не обов'язково активні).
|
||||
repeated string compiled_modules = 6;
|
||||
}
|
||||
|
||||
// Самометрики агента. Лягають у ts.agent_health.
|
||||
message AgentHealth {
|
||||
float cpu_pct = 1;
|
||||
uint64 rss_bytes = 2; // цільовий бюджет: < 30 МБ
|
||||
uint32 goroutines = 3;
|
||||
uint32 queue_depth = 4; // скільки результатів чекає на відправку
|
||||
float checks_per_sec = 5;
|
||||
float errors_per_min = 6;
|
||||
// Скільки семплів довелося викинути через переповнення буфера.
|
||||
// Ненульове значення — привід підняти алерт на самого агента.
|
||||
uint64 dropped_samples = 7;
|
||||
google.protobuf.Duration uptime = 8;
|
||||
// Розсинхронізація годинника з сервером; агент рахує її з Welcome.server_time.
|
||||
google.protobuf.Duration clock_skew = 9;
|
||||
}
|
||||
124
proto/netpulse/v1/discovery.proto
Normal file
124
proto/netpulse/v1/discovery.proto
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: discovery.proto
|
||||
// Автовиявлення сусідів (LLDP/CDP/ARP/FDB) та інвентаризація інтерфейсів.
|
||||
//
|
||||
// Агент НЕ вирішує, хто з ким з'єднаний. Він доповідає сире:
|
||||
// "на порту X я бачу chassis-id Y, port-id Z". Резолвер на сервері
|
||||
// зводить це в topo.links і виставляє confidence. Так автовиявлення
|
||||
// лишається відтворюваним і не залежить від версії агента.
|
||||
// =====================================================================
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package netpulse.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "netpulse/v1/common.proto";
|
||||
|
||||
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Сусід, побачений з конкретного порту. → topo.neighbors
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message NeighborRecord {
|
||||
// Пристрій, який доповідає (той, що ми опитали).
|
||||
string device_id = 1;
|
||||
// Локальний порт. Якщо агент ще не знає interface_id — заповнює
|
||||
// local_if_index/local_port_name, і сервер резолвить сам.
|
||||
string local_interface_id = 2;
|
||||
int64 local_if_index = 3;
|
||||
string local_port_name = 4;
|
||||
|
||||
DiscoveryProto proto = 5;
|
||||
|
||||
// Те, що видно з іншого боку. Порожні поля — норма: CDP не дає
|
||||
// chassis_id у форматі LLDP, ARP не дає port_id взагалі.
|
||||
string remote_chassis_id = 6;
|
||||
string remote_system_name = 7;
|
||||
string remote_port_id = 8;
|
||||
string remote_port_descr = 9;
|
||||
string remote_mgmt_ip = 10;
|
||||
string remote_mac = 11;
|
||||
string remote_platform = 12;
|
||||
repeated string remote_capabilities = 13; // bridge, router, wlan-ap
|
||||
|
||||
google.protobuf.Timestamp seen_at = 14;
|
||||
// Сирий запис як його віддав пристрій — для розбору спірних випадків.
|
||||
bytes raw_json = 15;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Інвентаризація інтерфейсів. → inv.interfaces (upsert за ifIndex)
|
||||
// Йде разом із discovery, бо обидва беруться з одного SNMP-walk.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message InterfaceRecord {
|
||||
string device_id = 1;
|
||||
int64 if_index = 2;
|
||||
string name = 3;
|
||||
string alias = 4;
|
||||
string mac = 5;
|
||||
uint32 mtu = 6;
|
||||
string type = 7; // ethernetCsmacd, ieee8023adLag, l3ipvlan
|
||||
uint64 speed_bps = 8;
|
||||
string duplex = 9;
|
||||
string admin_status = 10; // up | down | testing | unknown
|
||||
string oper_status = 11;
|
||||
int64 parent_if_index = 12; // для членів LAG
|
||||
repeated string ip_addresses = 13; // CIDR: 10.0.0.1/24
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Знайдений, але ще не заведений пристрій (сканування підмережі).
|
||||
// Сервер вирішує, чи створювати його в inv.devices — це впирається
|
||||
// в ліміт тарифу, тому рішення не може бути на агенті.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message DiscoveredDevice {
|
||||
string address = 1;
|
||||
string hostname = 2;
|
||||
string mac = 3;
|
||||
string sys_name = 4;
|
||||
string sys_descr = 5;
|
||||
string sys_object_id = 6;
|
||||
string vendor = 7;
|
||||
string model = 8;
|
||||
// Які транспорти відповіли під час сканування.
|
||||
repeated Transport reachable_via = 9;
|
||||
// Здогад агента про тип пристрою — сервер може перевизначити.
|
||||
string guessed_kind = 10;
|
||||
google.protobuf.Timestamp seen_at = 11;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Звіт про запуск автовиявлення → topo.discovery_runs
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message DiscoveryReport {
|
||||
string agent_id = 1;
|
||||
// Порожній, якщо це фонове періодичне виявлення, а не запуск із UI.
|
||||
string run_id = 2;
|
||||
|
||||
repeated NeighborRecord neighbors = 3;
|
||||
repeated InterfaceRecord interfaces = 4;
|
||||
repeated DiscoveredDevice devices = 5;
|
||||
|
||||
// Звіт розбитий на частини: сервер має чекати final = true,
|
||||
// перш ніж закривати run і чистити застарілих сусідів.
|
||||
bool final = 6;
|
||||
uint32 part = 7;
|
||||
|
||||
google.protobuf.Timestamp started_at = 8;
|
||||
google.protobuf.Timestamp finished_at = 9;
|
||||
repeated Error errors = 10;
|
||||
}
|
||||
|
||||
message DiscoveryAck {
|
||||
bool accepted = 1;
|
||||
// Скільки записів сервер зміг зіставити з інвентарем.
|
||||
uint32 neighbors_resolved = 2;
|
||||
uint32 links_created = 3;
|
||||
uint32 devices_created = 4;
|
||||
Error error = 5;
|
||||
}
|
||||
66
proto/netpulse/v1/logs.proto
Normal file
66
proto/netpulse/v1/logs.proto
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: logs.proto
|
||||
// Syslog та SNMP-трапи, зібрані агентом. → ts.syslog / ts.snmp_traps
|
||||
//
|
||||
// Агент слухає 514/udp і 162/udp у мережі клієнта й тунелює події
|
||||
// назовні. Це важливо не лише для журналу: подія "%SYS-5-CONFIG_I"
|
||||
// тригерить позачерговий бекап конфігу (ncm.device_policies.on_syslog).
|
||||
// =====================================================================
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package netpulse.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "netpulse/v1/common.proto";
|
||||
|
||||
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
|
||||
|
||||
message SyslogEntry {
|
||||
google.protobuf.Timestamp ts = 1;
|
||||
// Резолвиться агентом за source_ip; порожній, якщо пристрій невідомий.
|
||||
string device_id = 2;
|
||||
string source_ip = 3;
|
||||
uint32 facility = 4;
|
||||
uint32 severity = 5; // 0 emerg .. 7 debug
|
||||
string hostname = 6;
|
||||
string tag = 7;
|
||||
string message = 8;
|
||||
// Розібрані поля, якщо агент упізнав формат (RFC5424 structured data).
|
||||
map<string, string> parsed = 9;
|
||||
}
|
||||
|
||||
message SnmpTrap {
|
||||
google.protobuf.Timestamp ts = 1;
|
||||
string device_id = 2;
|
||||
string source_ip = 3;
|
||||
string trap_oid = 4;
|
||||
repeated VarBind varbinds = 5;
|
||||
// v2c community або v3 security name — сервер перевіряє довіру джерела.
|
||||
string auth_context = 6;
|
||||
}
|
||||
|
||||
message VarBind {
|
||||
string oid = 1;
|
||||
string type = 2; // INTEGER, OCTET STRING, Counter64, ...
|
||||
string value = 3;
|
||||
}
|
||||
|
||||
message LogBatch {
|
||||
uint64 batch_id = 1;
|
||||
string agent_id = 2;
|
||||
repeated SyslogEntry syslog = 3;
|
||||
repeated SnmpTrap traps = 4;
|
||||
// Скільки подій агент відкинув через переповнення (rate limit).
|
||||
// Ненульове — сигнал, що клієнт шумить або ліміт замалий.
|
||||
uint64 dropped = 5;
|
||||
}
|
||||
|
||||
message LogAck {
|
||||
uint64 acked_through_batch_id = 1;
|
||||
// Сервер просить фільтрувати шум на агенті: не слати нижче цієї severity.
|
||||
uint32 min_severity = 2;
|
||||
// Ліміт подій за секунду з одного джерела.
|
||||
uint32 rate_limit_per_source = 3;
|
||||
Error error = 4;
|
||||
}
|
||||
164
proto/netpulse/v1/ncm.proto
Normal file
164
proto/netpulse/v1/ncm.proto
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: ncm.proto
|
||||
// Збір конфігурацій (SSH/Telnet/API) та застосування відкату.
|
||||
//
|
||||
// Розподіл відповідальності:
|
||||
// Агент — відкриває сесію, виконує команди, віддає сирий текст.
|
||||
// Сервер — scrub/redact, хешування, коміт у Git, diff, compliance.
|
||||
//
|
||||
// Це навмисно: правила очищення живуть у ncm.profiles і мають
|
||||
// змінюватись без оновлення агентів у полі.
|
||||
// =====================================================================
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package netpulse.v1;
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "netpulse/v1/common.proto";
|
||||
|
||||
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Завдання на збір конфігу (сервер → агент)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message ConfigJob {
|
||||
string job_id = 1;
|
||||
DeviceTarget device = 2;
|
||||
Credential credential = 3;
|
||||
|
||||
Transport transport = 4;
|
||||
uint32 port = 5;
|
||||
|
||||
// Команди по черзі: ["terminal length 0", "show running-config"].
|
||||
// Вивід останньої команди вважається тілом конфігу; вивід попередніх
|
||||
// (підготовчих) відкидається.
|
||||
repeated string commands = 6;
|
||||
// Регекс запрошення командного рядка — за ним агент розуміє,
|
||||
// що команда відпрацювала.
|
||||
string prompt_regex = 7;
|
||||
// Потрібен enable/privileged режим перед виконанням.
|
||||
bool enable_required = 8;
|
||||
string enable_prompt_regex = 9;
|
||||
|
||||
// running | startup | vlan | license | inventory
|
||||
string config_type = 10;
|
||||
|
||||
google.protobuf.Duration timeout = 11;
|
||||
// Ліміт розміру: захист від пристрою, що віддає гігабайт сміття.
|
||||
uint64 max_bytes = 12;
|
||||
// Записувати повний транскрипт сесії (для діагностики prompt_regex).
|
||||
bool capture_transcript = 13;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Вивантаження конфігу (агент → сервер), потоком
|
||||
//
|
||||
// Порядок повідомлень: header → chunk* → trailer.
|
||||
// Тіло конфігу може бути кілька МБ, тому чанками, а не одним blob.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message ConfigUpload {
|
||||
oneof part {
|
||||
ConfigHeader header = 1;
|
||||
ConfigChunk chunk = 2;
|
||||
ConfigTrailer trailer = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ConfigHeader {
|
||||
string job_id = 1;
|
||||
string agent_id = 2;
|
||||
string device_id = 3;
|
||||
string config_type = 4;
|
||||
google.protobuf.Timestamp collected_at = 5;
|
||||
// gzip | none — агент стискає, бо конфіги добре жмуться,
|
||||
// а канал може бути вузьким.
|
||||
string encoding = 6;
|
||||
}
|
||||
|
||||
message ConfigChunk {
|
||||
// Номер чанка з 0; сервер збирає в порядку зростання.
|
||||
uint32 sequence = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message ConfigTrailer {
|
||||
bool success = 1;
|
||||
Error error = 2;
|
||||
|
||||
// sha256 ПОВНОГО тіла ДО стиснення. Сервер звіряє й лише потім комітить.
|
||||
bytes content_sha256 = 3;
|
||||
uint64 size_bytes = 4;
|
||||
uint32 line_count = 5;
|
||||
uint32 chunk_count = 6;
|
||||
|
||||
google.protobuf.Duration duration = 7;
|
||||
// Транскрипт сесії, якщо capture_transcript = true.
|
||||
string transcript = 8;
|
||||
}
|
||||
|
||||
message ConfigReceipt {
|
||||
string job_id = 1;
|
||||
bool accepted = 2;
|
||||
// Сервер порівняв content_hash із попередньою версією: конфіг не змінився,
|
||||
// нового коміту не буде. Агенту корисно знати для локальної статистики.
|
||||
bool unchanged = 3;
|
||||
// Заповнюється, якщо коміт відбувся.
|
||||
string commit_sha = 4;
|
||||
string config_id = 5;
|
||||
Error error = 6;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Застосування конфігурації / відкат (сервер → агент)
|
||||
//
|
||||
// НЕБЕЗПЕЧНА операція. Агент виконує її беззастережно, бо вся логіка
|
||||
// погодження (ncm.rollbacks: draft → awaiting_approval → approved)
|
||||
// лишається на сервері. Агент лише перевіряє, що завдання прийшло
|
||||
// у межах живої автентифікованої сесії.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message ConfigApplyJob {
|
||||
string rollback_id = 1;
|
||||
DeviceTarget device = 2;
|
||||
Credential credential = 3;
|
||||
Transport transport = 4;
|
||||
|
||||
// Рядки конфігу, які треба виконати послідовно.
|
||||
repeated string commands = 5;
|
||||
string prompt_regex = 6;
|
||||
bool enable_required = 7;
|
||||
|
||||
// Команда збереження після успіху ("write memory", "/system backup save").
|
||||
string commit_command = 8;
|
||||
// Якщо пристрій підтримує confirmed commit — відкотиться сам,
|
||||
// коли агент не підтвердить у цей строк. Найкращий захист від
|
||||
// втрати керування після помилкового правила фаєрвола.
|
||||
google.protobuf.Duration confirm_timeout = 9;
|
||||
|
||||
// Зупинитись на першій помилці (за замовчуванням) чи виконати все.
|
||||
bool continue_on_error = 10;
|
||||
google.protobuf.Duration timeout = 11;
|
||||
}
|
||||
|
||||
message ConfigApplyResult {
|
||||
string rollback_id = 1;
|
||||
bool success = 2;
|
||||
// Результат кожної команди — саме тут видно, на якій усе стало.
|
||||
repeated CommandOutcome outcomes = 3;
|
||||
bool committed = 4;
|
||||
string transcript = 5;
|
||||
Error error = 6;
|
||||
google.protobuf.Duration duration = 7;
|
||||
}
|
||||
|
||||
message CommandOutcome {
|
||||
uint32 index = 1;
|
||||
string command = 2;
|
||||
string output = 3;
|
||||
bool success = 4;
|
||||
string error_line = 5;
|
||||
}
|
||||
213
proto/netpulse/v1/telemetry.proto
Normal file
213
proto/netpulse/v1/telemetry.proto
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: telemetry.proto
|
||||
// Гарячий шлях: метрики від агента до сервера.
|
||||
//
|
||||
// Три типи навантаження, свідомо різні:
|
||||
// 1. IcmpResult → ts.icmp_samples (широка таблиця, колір вузла)
|
||||
// 2. InterfaceCounters → ts.if_counters (широка таблиця, анімація лінії)
|
||||
// 3. MetricSample → ts.series/samples (узагальнена, будь-який плагін)
|
||||
//
|
||||
// Перші два денормалізовані, бо їх читає мапа на кожному тику WebSocket.
|
||||
// Третій — розширюваний без зміни контракту: плагін реєструє свій
|
||||
// metric_key і працює.
|
||||
// =====================================================================
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package netpulse.v1;
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "netpulse/v1/common.proto";
|
||||
|
||||
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Інтернування серій
|
||||
//
|
||||
// Проблема: у кожному семплі повторювати device_id (36 байт) + metric_key
|
||||
// + labels — це десятки байт службових даних на одне число float64.
|
||||
// При 50 000 пристроїв × метрика/хв це помітний трафік.
|
||||
//
|
||||
// Рішення: агент один раз реєструє серію під локальним номером
|
||||
// (series_ref, унікальний у межах СЕСІЇ), далі шле лише номер + значення.
|
||||
// Сервер тримає мапу series_ref → ts.series.id на час сесії.
|
||||
//
|
||||
// Після реконекту таблиця недійсна: агент починає нумерацію з 1 і
|
||||
// реєструє все заново. Сервер може примусово скинути її директивою
|
||||
// RESET_SERIES_TABLE, якщо втратив стан.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message SeriesDescriptor {
|
||||
// Локальний номер у межах сесії. Нумерація з 1; 0 — недійсний.
|
||||
uint32 series_ref = 1;
|
||||
|
||||
string device_id = 2;
|
||||
// Заповнюється для метрик рівня інтерфейсу.
|
||||
string interface_id = 3;
|
||||
// Плагін-джерело: snmp, modbus, http...
|
||||
string plugin_key = 4;
|
||||
// cpu.util, mem.used, ups.battery.pct, sensor.temp
|
||||
string metric_key = 5;
|
||||
// pct, bps, C, V, ms
|
||||
string unit = 6;
|
||||
// Додаткові виміри: {"core":"0","phase":"L1"}
|
||||
map<string, string> labels = 7;
|
||||
}
|
||||
|
||||
// Один вимір узагальненої метрики.
|
||||
message MetricSample {
|
||||
uint32 series_ref = 1;
|
||||
google.protobuf.Timestamp ts = 2;
|
||||
double value = 3;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ICMP → ts.icmp_samples
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message IcmpResult {
|
||||
string device_id = 1;
|
||||
string check_id = 2;
|
||||
google.protobuf.Timestamp ts = 3;
|
||||
|
||||
float rtt_avg_ms = 4;
|
||||
float rtt_min_ms = 5;
|
||||
float rtt_max_ms = 6;
|
||||
float jitter_ms = 7;
|
||||
float loss_pct = 8;
|
||||
uint32 packets_sent = 9;
|
||||
uint32 packets_recv = 10;
|
||||
bool reachable = 11;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Лічильники інтерфейсів → ts.if_counters
|
||||
//
|
||||
// Агент шле І сирі 64-бітні лічильники, І вже пораховані швидкості.
|
||||
// Сирі потрібні, щоб сервер міг перерахувати заднім числом; швидкості
|
||||
// рахує агент, бо лише він знає точний інтервал між двома опитуваннями
|
||||
// (мережева затримка робить серверний розрахунок неточним).
|
||||
//
|
||||
// Обробку wrap/reset лічильника робить агент: якщо пристрій
|
||||
// перезавантажився, counter_reset = true, і швидкості не заповнюються.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message InterfaceCounters {
|
||||
string device_id = 1;
|
||||
string interface_id = 2;
|
||||
google.protobuf.Timestamp ts = 3;
|
||||
|
||||
uint64 in_octets = 4;
|
||||
uint64 out_octets = 5;
|
||||
uint64 in_ucast_pkts = 6;
|
||||
uint64 out_ucast_pkts = 7;
|
||||
uint64 in_errors = 8;
|
||||
uint64 out_errors = 9;
|
||||
uint64 in_discards = 10;
|
||||
uint64 out_discards = 11;
|
||||
|
||||
// Похідні швидкості за фактичний інтервал між опитуваннями.
|
||||
double in_bps = 12;
|
||||
double out_bps = 13;
|
||||
double in_pps = 14;
|
||||
double out_pps = 15;
|
||||
// % від номінальної швидкості порту — джерело анімації трафіку на мапі.
|
||||
float util_in_pct = 16;
|
||||
float util_out_pct = 17;
|
||||
|
||||
bool oper_up = 18;
|
||||
bool admin_up = 19;
|
||||
// Лічильник обнулився (reboot/wrap) — швидкості цього разу недійсні.
|
||||
bool counter_reset = 20;
|
||||
// Фактичний інтервал, за який пораховані швидкості.
|
||||
google.protobuf.Duration interval = 21;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Зміна стану пристрою → ts.device_status_history + подія на мапу
|
||||
// Надсилається одразу при переході, не чекаючи наступного батчу.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message StatusChange {
|
||||
string device_id = 1;
|
||||
google.protobuf.Timestamp ts = 2;
|
||||
Status status = 3;
|
||||
Status previous_status = 4;
|
||||
string reason = 5;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Результат довільного чека плагіна.
|
||||
// Метрики вже розкладені у samples; сюди йде лише службова частина.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message CheckResult {
|
||||
string check_id = 1;
|
||||
string device_id = 2;
|
||||
string check_type = 3; // icmp.ping, snmp.if, http.status
|
||||
google.protobuf.Timestamp ts = 4;
|
||||
google.protobuf.Duration duration = 5;
|
||||
bool success = 6;
|
||||
Error error = 7;
|
||||
// Довільне корисне навантаження плагіна (JSON), яке не є метрикою:
|
||||
// напр. http.status → {"status_code":200,"redirects":1}.
|
||||
bytes payload_json = 8;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Батч
|
||||
//
|
||||
// Агент накопичує результати й шле пачками. Розмір і період батчу
|
||||
// диктує сервер у Welcome — так можна на льоту пригальмувати
|
||||
// балакучого агента, не оновлюючи бінарник.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message TelemetryBatch {
|
||||
// Монотонний номер батчу в межах сесії. Основа для ack і ретраїв.
|
||||
uint64 batch_id = 1;
|
||||
string agent_id = 2;
|
||||
// Час формування батчу на агенті (не час вимірів усередині).
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
|
||||
// Нові серії, які вперше зустрічаються в цій сесії.
|
||||
// Мають бути в тому ж батчі, що й перші семпли, які на них посилаються.
|
||||
repeated SeriesDescriptor new_series = 4;
|
||||
|
||||
repeated MetricSample samples = 5;
|
||||
repeated IcmpResult icmp = 6;
|
||||
repeated InterfaceCounters interfaces = 7;
|
||||
repeated StatusChange status_changes = 8;
|
||||
repeated CheckResult check_results = 9;
|
||||
|
||||
// Батч — повтор раніше не підтвердженого (після реконекту).
|
||||
// Сервер робить upsert: PK (ts, device_id) робить це безпечним.
|
||||
bool is_retransmit = 10;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підтвердження + зворотний тиск
|
||||
//
|
||||
// Семантика доставки — at-least-once. Дедуплікацію забезпечує сама
|
||||
// схема: PK (ts, device_id) / (ts, interface_id) / (ts, series_id),
|
||||
// тому повторний батч не псує дані.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
message TelemetryAck {
|
||||
// Усі батчі з номером <= цього прийняті й записані.
|
||||
uint64 acked_through_batch_id = 1;
|
||||
|
||||
// Батчі, які треба переслати (напр. частковий збій запису).
|
||||
repeated uint64 nack_batch_ids = 2;
|
||||
|
||||
// Скільки неacknowledged батчів агенту дозволено тримати в польоті.
|
||||
uint32 max_in_flight = 3;
|
||||
// Сервер під навантаженням: пригальмувати на цей час.
|
||||
google.protobuf.Duration retry_after = 4;
|
||||
|
||||
// Сервер втратив мапу series_ref → id. Агент має обнулити локальну
|
||||
// таблицю й перереєструвати всі серії наступним батчем.
|
||||
bool reset_series_table = 5;
|
||||
|
||||
Error error = 6;
|
||||
}
|
||||
805
test/contract/contract_test.go
Normal file
805
test/contract/contract_test.go
Normal file
|
|
@ -0,0 +1,805 @@
|
|||
// =====================================================================
|
||||
// NetPulse :: contract_test.go
|
||||
//
|
||||
// Наскрізна перевірка контракту агент↔сервер на справжньому gRPC
|
||||
// (bufconn, без мережі). Мета — не покрити код тестами, а довести,
|
||||
// що модель із .proto реально працює:
|
||||
//
|
||||
// 1. Рукостискання: агент відкриває стрім, шле Hello, отримує Welcome.
|
||||
// 2. Сервер віддає план задач у зустрічному напрямку вже відкритого
|
||||
// агентом стріму — тобто "команда з сервера" працює без жодного
|
||||
// вхідного з'єднання в мережу клієнта.
|
||||
// 3. Інтернування серій: агент реєструє серію один раз, далі шле
|
||||
// лише номер; сервер розв'язує його назад у метрику.
|
||||
// 4. Зворотний тиск: сервер підтверджує батчі й диктує max_in_flight.
|
||||
// 5. Ping/Pong для вимірювання розсинхронізації годинника.
|
||||
// 6. Вивантаження конфігу чанками зі звіркою sha256.
|
||||
//
|
||||
// Запуск: go test ./test/contract/... -v
|
||||
// =====================================================================
|
||||
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Мінімальна реалізація серверного боку
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// resolvedSample — семпл, уже розв'язаний із series_ref у повний опис.
|
||||
// Саме це сервер записав би в ts.series + ts.samples.
|
||||
type resolvedSample struct {
|
||||
deviceID string
|
||||
metricKey string
|
||||
unit string
|
||||
labels map[string]string
|
||||
value float64
|
||||
}
|
||||
|
||||
type storedConfig struct {
|
||||
jobID string
|
||||
body []byte
|
||||
}
|
||||
|
||||
type testServer struct {
|
||||
npv1.UnimplementedAgentServiceServer
|
||||
|
||||
hello chan *npv1.Hello
|
||||
pong chan *npv1.Pong
|
||||
samples chan resolvedSample
|
||||
icmp chan *npv1.IcmpResult
|
||||
ifc chan *npv1.InterfaceCounters
|
||||
acks chan uint64
|
||||
config chan storedConfig
|
||||
planSent chan struct{}
|
||||
}
|
||||
|
||||
func newTestServer() *testServer {
|
||||
return &testServer{
|
||||
hello: make(chan *npv1.Hello, 1),
|
||||
pong: make(chan *npv1.Pong, 1),
|
||||
samples: make(chan resolvedSample, 16),
|
||||
icmp: make(chan *npv1.IcmpResult, 16),
|
||||
ifc: make(chan *npv1.InterfaceCounters, 16),
|
||||
acks: make(chan uint64, 16),
|
||||
config: make(chan storedConfig, 1),
|
||||
planSent: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testServer) Control(stream npv1.AgentService_ControlServer) error {
|
||||
// Перше повідомлення зобов'язане бути Hello — інакше сесії немає.
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hello := first.GetHello()
|
||||
if hello == nil {
|
||||
return errors.New("перше повідомлення в Control має бути Hello")
|
||||
}
|
||||
s.hello <- hello
|
||||
|
||||
if err := stream.Send(&npv1.ControlDown{
|
||||
Seq: 1,
|
||||
Payload: &npv1.ControlDown_Welcome{Welcome: &npv1.Welcome{
|
||||
SessionId: "sess-0001",
|
||||
ServerTime: timestamppb.Now(),
|
||||
HeartbeatInterval: durationpb.New(30 * time.Second),
|
||||
TelemetryMaxBatchSize: 500,
|
||||
TelemetryMaxBatchInterval: durationpb.New(5 * time.Second),
|
||||
TelemetryMaxInFlight: 4,
|
||||
MaxConcurrentChecks: 256,
|
||||
IcmpRatePps: 500,
|
||||
TaskPlanFollows: true,
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Параметри чека — непрозорий JSON: ядро їх не тлумачить.
|
||||
params, err := json.Marshal(map[string]any{"count": 3, "packet_size": 56})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.ControlDown{
|
||||
Seq: 2,
|
||||
Payload: &npv1.ControlDown_TaskPlan{TaskPlan: &npv1.TaskPlan{
|
||||
PlanHash: []byte("plan-v1"),
|
||||
Devices: []*npv1.DeviceTarget{{
|
||||
DeviceId: "dev-1", Name: "core-sw-01", Address: "10.0.0.1",
|
||||
}},
|
||||
Tasks: []*npv1.Task{{
|
||||
CheckId: "chk-1",
|
||||
DeviceId: "dev-1",
|
||||
CheckType: "icmp.ping",
|
||||
ParamsJson: params,
|
||||
Interval: durationpb.New(60 * time.Second),
|
||||
Timeout: durationpb.New(3 * time.Second),
|
||||
Retries: 2,
|
||||
Enabled: true,
|
||||
ScheduleOffset: durationpb.New(7 * time.Second),
|
||||
}},
|
||||
Final: true,
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.planSent <- struct{}{}
|
||||
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch p := msg.Payload.(type) {
|
||||
case *npv1.ControlUp_Heartbeat:
|
||||
// На heartbeat відповідаємо Ping — так міряється clock skew.
|
||||
if err := stream.Send(&npv1.ControlDown{
|
||||
Seq: 3,
|
||||
Payload: &npv1.ControlDown_Ping{Ping: &npv1.Ping{
|
||||
PingId: 42, ServerTime: timestamppb.Now(),
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
case *npv1.ControlUp_Pong:
|
||||
s.pong <- p.Pong
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testServer) StreamTelemetry(stream npv1.AgentService_StreamTelemetryServer) error {
|
||||
// Таблиця серій жива рівно стільки, скільки сесія. Після реконекту
|
||||
// агент нумерує заново — тому вона локальна для цього виклику.
|
||||
table := make(map[uint32]*npv1.SeriesDescriptor)
|
||||
|
||||
for {
|
||||
batch, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range batch.NewSeries {
|
||||
table[d.SeriesRef] = d
|
||||
}
|
||||
|
||||
for _, smp := range batch.Samples {
|
||||
d, ok := table[smp.SeriesRef]
|
||||
if !ok {
|
||||
// Ми загубили стан — чесно просимо перереєструвати все,
|
||||
// замість тихо викидати дані.
|
||||
return stream.Send(&npv1.TelemetryAck{
|
||||
AckedThroughBatchId: batch.BatchId - 1,
|
||||
ResetSeriesTable: true,
|
||||
Error: &npv1.Error{
|
||||
Code: "unknown_series_ref",
|
||||
Message: fmt.Sprintf("series_ref %d не зареєстровано", smp.SeriesRef),
|
||||
Retryable: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
s.samples <- resolvedSample{
|
||||
deviceID: d.DeviceId,
|
||||
metricKey: d.MetricKey,
|
||||
unit: d.Unit,
|
||||
labels: d.Labels,
|
||||
value: smp.Value,
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range batch.Icmp {
|
||||
s.icmp <- r
|
||||
}
|
||||
for _, r := range batch.Interfaces {
|
||||
s.ifc <- r
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.TelemetryAck{
|
||||
AckedThroughBatchId: batch.BatchId,
|
||||
MaxInFlight: 4,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.acks <- batch.BatchId
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testServer) UploadConfig(stream npv1.AgentService_UploadConfigServer) error {
|
||||
var hdr *npv1.ConfigHeader
|
||||
var body []byte
|
||||
var wantSeq uint32
|
||||
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return errors.New("стрім завершився без trailer")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch p := msg.Part.(type) {
|
||||
case *npv1.ConfigUpload_Header:
|
||||
hdr = p.Header
|
||||
|
||||
case *npv1.ConfigUpload_Chunk:
|
||||
if hdr == nil {
|
||||
return errors.New("chunk прийшов раніше за header")
|
||||
}
|
||||
if p.Chunk.Sequence != wantSeq {
|
||||
return fmt.Errorf("чанки не по порядку: чекали %d, отримали %d",
|
||||
wantSeq, p.Chunk.Sequence)
|
||||
}
|
||||
wantSeq++
|
||||
body = append(body, p.Chunk.Data...)
|
||||
|
||||
case *npv1.ConfigUpload_Trailer:
|
||||
if hdr == nil {
|
||||
return errors.New("trailer без header")
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if !bytes.Equal(sum[:], p.Trailer.ContentSha256) {
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: hdr.JobId,
|
||||
Accepted: false,
|
||||
Error: &npv1.Error{Code: "checksum_mismatch", Retryable: true},
|
||||
})
|
||||
}
|
||||
if uint64(len(body)) != p.Trailer.SizeBytes {
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: hdr.JobId, Accepted: false,
|
||||
Error: &npv1.Error{Code: "size_mismatch"},
|
||||
})
|
||||
}
|
||||
s.config <- storedConfig{jobID: hdr.JobId, body: body}
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: hdr.JobId,
|
||||
Accepted: true,
|
||||
CommitSha: "3f1a2b8c9d0e",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Обв'язка
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func dial(t *testing.T, s *testServer) npv1.AgentServiceClient {
|
||||
t.Helper()
|
||||
|
||||
lis := bufconn.Listen(1 << 20)
|
||||
srv := grpc.NewServer()
|
||||
npv1.RegisterAgentServiceServer(srv, s)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return lis.DialContext(ctx)
|
||||
}),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("не вдалося створити клієнт: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
srv.Stop()
|
||||
_ = lis.Close()
|
||||
})
|
||||
return npv1.NewAgentServiceClient(conn)
|
||||
}
|
||||
|
||||
// proto1Size — скільки байтів займе на дроті один семпл разом із
|
||||
// дескриптором серії (якщо той ще не інтернований).
|
||||
func proto1Size(t *testing.T, sample *npv1.MetricSample, desc *npv1.SeriesDescriptor) int {
|
||||
t.Helper()
|
||||
b, err := proto.Marshal(sample)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(sample): %v", err)
|
||||
}
|
||||
n := len(b)
|
||||
if desc != nil {
|
||||
d, err := proto.Marshal(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(descriptor): %v", err)
|
||||
}
|
||||
n += len(d)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func recvWithin[T any](t *testing.T, ch <-chan T, what string) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case v := <-ch:
|
||||
return v
|
||||
case <-time.After(5 * time.Second):
|
||||
var zero T
|
||||
t.Fatalf("не дочекались: %s", what)
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Контрольний канал: Hello → Welcome → TaskPlan → Heartbeat → Ping/Pong
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestControlHandshakeAndTaskPush(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
client := dial(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.Control(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Control: %v", err)
|
||||
}
|
||||
|
||||
// Агент представляється.
|
||||
agentStart := time.Now()
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Seq: 1,
|
||||
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
|
||||
AgentId: "agent-1",
|
||||
Hostname: "probe-kyv-01",
|
||||
Build: &npv1.AgentBuild{
|
||||
Version: "1.0.0", Os: "linux", Arch: "amd64",
|
||||
CompiledModules: []string{"icmp", "snmp", "topology"},
|
||||
},
|
||||
LocalAddresses: []string{"10.0.0.250/24"},
|
||||
StartedAt: timestamppb.New(agentStart),
|
||||
TaskPlanHash: nil, // плану ще немає — чекаємо повний
|
||||
LastAckedBatchId: 0,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(Hello): %v", err)
|
||||
}
|
||||
|
||||
hello := recvWithin(t, srv.hello, "Hello на сервері")
|
||||
if hello.AgentId != "agent-1" {
|
||||
t.Fatalf("agent_id = %q, очікували agent-1", hello.AgentId)
|
||||
}
|
||||
|
||||
// Welcome
|
||||
down, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv(Welcome): %v", err)
|
||||
}
|
||||
welcome := down.GetWelcome()
|
||||
if welcome == nil {
|
||||
t.Fatalf("перше повідомлення від сервера — не Welcome, а %T", down.Payload)
|
||||
}
|
||||
if welcome.SessionId == "" {
|
||||
t.Fatal("Welcome без session_id")
|
||||
}
|
||||
if !welcome.TaskPlanFollows {
|
||||
t.Fatal("сервер не попередив, що надішле план")
|
||||
}
|
||||
if got := welcome.TelemetryMaxBatchSize; got != 500 {
|
||||
t.Fatalf("telemetry_max_batch_size = %d, очікували 500", got)
|
||||
}
|
||||
|
||||
// Розсинхронізація годинника — те, заради чого в Welcome є server_time.
|
||||
skew := welcome.ServerTime.AsTime().Sub(agentStart)
|
||||
if skew > time.Minute {
|
||||
t.Fatalf("підозріло великий clock skew: %v", skew)
|
||||
}
|
||||
|
||||
// План задач приходить у зустрічному напрямку вже відкритого агентом
|
||||
// стріму — жодного вхідного з'єднання в мережу клієнта.
|
||||
down, err = stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv(TaskPlan): %v", err)
|
||||
}
|
||||
plan := down.GetTaskPlan()
|
||||
if plan == nil {
|
||||
t.Fatalf("очікували TaskPlan, отримали %T", down.Payload)
|
||||
}
|
||||
if !plan.Final {
|
||||
t.Fatal("план не позначений як final — агент не має права його застосовувати")
|
||||
}
|
||||
if len(plan.Tasks) != 1 || len(plan.Devices) != 1 {
|
||||
t.Fatalf("план: %d задач, %d пристроїв", len(plan.Tasks), len(plan.Devices))
|
||||
}
|
||||
|
||||
task := plan.Tasks[0]
|
||||
if task.CheckType != "icmp.ping" {
|
||||
t.Fatalf("check_type = %q", task.CheckType)
|
||||
}
|
||||
// Префікс до крапки визначає модуль-виконавця.
|
||||
if module, _, ok := strings.Cut(task.CheckType, "."); !ok || module != "icmp" {
|
||||
t.Fatalf("з check_type %q не виводиться модуль", task.CheckType)
|
||||
}
|
||||
// Непрозорі параметри розбирає сам модуль.
|
||||
var p struct {
|
||||
Count int `json:"count"`
|
||||
PacketSize int `json:"packet_size"`
|
||||
}
|
||||
if err := json.Unmarshal(task.ParamsJson, &p); err != nil {
|
||||
t.Fatalf("params_json не розбирається: %v", err)
|
||||
}
|
||||
if p.Count != 3 || p.PacketSize != 56 {
|
||||
t.Fatalf("params_json спотворені: %+v", p)
|
||||
}
|
||||
if task.Interval.AsDuration() != time.Minute {
|
||||
t.Fatalf("interval = %v", task.Interval.AsDuration())
|
||||
}
|
||||
if task.ScheduleOffset.AsDuration() != 7*time.Second {
|
||||
t.Fatalf("schedule_offset = %v", task.ScheduleOffset.AsDuration())
|
||||
}
|
||||
|
||||
<-srv.planSent
|
||||
|
||||
// Heartbeat із самометриками агента.
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Seq: 2,
|
||||
Payload: &npv1.ControlUp_Heartbeat{Heartbeat: &npv1.Heartbeat{
|
||||
Ts: timestamppb.Now(),
|
||||
Health: &npv1.AgentHealth{
|
||||
CpuPct: 1.5, RssBytes: 22 * 1024 * 1024,
|
||||
Goroutines: 48, QueueDepth: 0, ChecksPerSec: 12.5,
|
||||
Uptime: durationpb.New(time.Hour),
|
||||
},
|
||||
TasksRunning: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(Heartbeat): %v", err)
|
||||
}
|
||||
|
||||
// Сервер відповідає Ping — агент має відповісти Pong.
|
||||
down, err = stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv(Ping): %v", err)
|
||||
}
|
||||
ping := down.GetPing()
|
||||
if ping == nil {
|
||||
t.Fatalf("очікували Ping, отримали %T", down.Payload)
|
||||
}
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Seq: 3,
|
||||
Payload: &npv1.ControlUp_Pong{Pong: &npv1.Pong{PingId: ping.PingId, AgentTime: timestamppb.Now()}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(Pong): %v", err)
|
||||
}
|
||||
|
||||
pong := recvWithin(t, srv.pong, "Pong на сервері")
|
||||
if pong.PingId != ping.PingId {
|
||||
t.Fatalf("ping_id не збігся: %d != %d", pong.PingId, ping.PingId)
|
||||
}
|
||||
|
||||
if err := stream.CloseSend(); err != nil {
|
||||
t.Fatalf("CloseSend: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. Телеметрія: інтернування серій, гарячі шляхи, ack
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestTelemetrySeriesInterning(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
client := dial(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.StreamTelemetry(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTelemetry: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Батч 1: реєструємо серію разом із першим семплом.
|
||||
batch1 := &npv1.TelemetryBatch{
|
||||
BatchId: 1,
|
||||
AgentId: "agent-1",
|
||||
CreatedAt: timestamppb.New(now),
|
||||
NewSeries: []*npv1.SeriesDescriptor{{
|
||||
SeriesRef: 1,
|
||||
DeviceId: "dev-1",
|
||||
PluginKey: "snmp",
|
||||
MetricKey: "cpu.util",
|
||||
Unit: "pct",
|
||||
Labels: map[string]string{"core": "0"},
|
||||
}},
|
||||
Samples: []*npv1.MetricSample{
|
||||
{SeriesRef: 1, Ts: timestamppb.New(now), Value: 37.5},
|
||||
},
|
||||
Icmp: []*npv1.IcmpResult{{
|
||||
DeviceId: "dev-1", CheckId: "chk-1", Ts: timestamppb.New(now),
|
||||
RttAvgMs: 1.24, RttMinMs: 1.10, RttMaxMs: 1.55, JitterMs: 0.2,
|
||||
LossPct: 0, PacketsSent: 3, PacketsRecv: 3, Reachable: true,
|
||||
}},
|
||||
Interfaces: []*npv1.InterfaceCounters{{
|
||||
DeviceId: "dev-1", InterfaceId: "if-1", Ts: timestamppb.New(now),
|
||||
InOctets: 1 << 40, OutOctets: 1 << 39,
|
||||
InBps: 420e6, OutBps: 780e6,
|
||||
UtilInPct: 42, UtilOutPct: 78,
|
||||
OperUp: true, AdminUp: true,
|
||||
Interval: durationpb.New(60 * time.Second),
|
||||
}},
|
||||
}
|
||||
if err := stream.Send(batch1); err != nil {
|
||||
t.Fatalf("Send(batch1): %v", err)
|
||||
}
|
||||
|
||||
ack, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv(ack1): %v", err)
|
||||
}
|
||||
if ack.AckedThroughBatchId != 1 {
|
||||
t.Fatalf("acked_through = %d, очікували 1", ack.AckedThroughBatchId)
|
||||
}
|
||||
if ack.ResetSeriesTable {
|
||||
t.Fatal("сервер попросив скинути таблицю серій одразу після реєстрації")
|
||||
}
|
||||
if ack.MaxInFlight == 0 {
|
||||
t.Fatal("сервер не повідомив max_in_flight — агент не знатиме межі зворотного тиску")
|
||||
}
|
||||
|
||||
got := recvWithin(t, srv.samples, "розв'язаний семпл")
|
||||
if got.metricKey != "cpu.util" || got.deviceID != "dev-1" || got.unit != "pct" {
|
||||
t.Fatalf("семпл розв'язався неправильно: %+v", got)
|
||||
}
|
||||
if got.labels["core"] != "0" {
|
||||
t.Fatalf("labels загубились: %+v", got.labels)
|
||||
}
|
||||
if got.value != 37.5 {
|
||||
t.Fatalf("value = %v", got.value)
|
||||
}
|
||||
|
||||
icmp := recvWithin(t, srv.icmp, "ICMP-результат")
|
||||
if !icmp.Reachable || icmp.PacketsRecv != 3 {
|
||||
t.Fatalf("icmp спотворений: %+v", icmp)
|
||||
}
|
||||
|
||||
ifc := recvWithin(t, srv.ifc, "лічильники інтерфейсу")
|
||||
if ifc.UtilOutPct != 78 {
|
||||
t.Fatalf("util_out_pct = %v — це джерело швидкості анімації, воно має доїхати точно", ifc.UtilOutPct)
|
||||
}
|
||||
if ifc.Interval.AsDuration() != time.Minute {
|
||||
t.Fatalf("interval = %v", ifc.Interval.AsDuration())
|
||||
}
|
||||
|
||||
// Батч 2: серія вже відома — шлемо лише номер.
|
||||
// Саме заради цього економія й затівалась.
|
||||
batch2 := &npv1.TelemetryBatch{
|
||||
BatchId: 2,
|
||||
AgentId: "agent-1",
|
||||
CreatedAt: timestamppb.New(now.Add(time.Minute)),
|
||||
Samples: []*npv1.MetricSample{
|
||||
{SeriesRef: 1, Ts: timestamppb.New(now.Add(time.Minute)), Value: 41.0},
|
||||
},
|
||||
}
|
||||
if err := stream.Send(batch2); err != nil {
|
||||
t.Fatalf("Send(batch2): %v", err)
|
||||
}
|
||||
|
||||
ack, err = stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv(ack2): %v", err)
|
||||
}
|
||||
if ack.AckedThroughBatchId != 2 {
|
||||
t.Fatalf("acked_through = %d, очікували 2", ack.AckedThroughBatchId)
|
||||
}
|
||||
|
||||
got = recvWithin(t, srv.samples, "семпл із батчу без дескриптора")
|
||||
if got.metricKey != "cpu.util" || got.value != 41.0 {
|
||||
t.Fatalf("серія не розв'язалась із таблиці сесії: %+v", got)
|
||||
}
|
||||
|
||||
// Розмір на дроті: другий батч має бути помітно компактнішим,
|
||||
// хоч і несе стільки ж корисних чисел.
|
||||
size1 := proto1Size(t, batch1.Samples[0], batch1.NewSeries[0])
|
||||
size2 := proto1Size(t, batch2.Samples[0], nil)
|
||||
t.Logf("семпл із дескриптором: %d байт, семпл із series_ref: %d байт", size1, size2)
|
||||
if size2 >= size1 {
|
||||
t.Fatalf("інтернування не дає економії: %d >= %d", size2, size1)
|
||||
}
|
||||
|
||||
if err := stream.CloseSend(); err != nil {
|
||||
t.Fatalf("CloseSend: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3. Невідомий series_ref → сервер чесно просить перереєстрацію
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestTelemetryUnknownSeriesRefTriggersReset(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
client := dial(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.StreamTelemetry(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTelemetry: %v", err)
|
||||
}
|
||||
|
||||
// Шлемо семпл на серію, яку ніколи не реєстрували.
|
||||
if err := stream.Send(&npv1.TelemetryBatch{
|
||||
BatchId: 7,
|
||||
AgentId: "agent-1",
|
||||
Samples: []*npv1.MetricSample{
|
||||
{SeriesRef: 999, Ts: timestamppb.Now(), Value: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
ack, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv: %v", err)
|
||||
}
|
||||
if !ack.ResetSeriesTable {
|
||||
t.Fatal("сервер мовчки проковтнув невідомий series_ref замість запиту на перереєстрацію")
|
||||
}
|
||||
if ack.Error == nil || ack.Error.Code != "unknown_series_ref" {
|
||||
t.Fatalf("немає діагностики помилки: %+v", ack.Error)
|
||||
}
|
||||
if !ack.Error.Retryable {
|
||||
t.Fatal("помилка має бути позначена як retryable — дані не втрачені, їх треба переслати")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. NCM: вивантаження конфігу чанками зі звіркою sha256
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestConfigUploadChunked(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
client := dial(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.UploadConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadConfig: %v", err)
|
||||
}
|
||||
|
||||
// Конфіг, більший за один чанк.
|
||||
var sb strings.Builder
|
||||
sb.WriteString("!\nversion 15.2\nhostname core-sw-01\n!\n")
|
||||
for i := 0; i < 500; i++ {
|
||||
fmt.Fprintf(&sb, "interface GigabitEthernet0/%d\n description port-%d\n!\n", i, i)
|
||||
}
|
||||
body := []byte(sb.String())
|
||||
want := sha256.Sum256(body)
|
||||
|
||||
if err := stream.Send(&npv1.ConfigUpload{
|
||||
Part: &npv1.ConfigUpload_Header{Header: &npv1.ConfigHeader{
|
||||
JobId: "job-1",
|
||||
AgentId: "agent-1",
|
||||
DeviceId: "dev-1",
|
||||
ConfigType: "running",
|
||||
CollectedAt: timestamppb.Now(),
|
||||
Encoding: "none",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(header): %v", err)
|
||||
}
|
||||
|
||||
const chunkSize = 4096
|
||||
var seq uint32
|
||||
for off := 0; off < len(body); off += chunkSize {
|
||||
end := min(off+chunkSize, len(body))
|
||||
if err := stream.Send(&npv1.ConfigUpload{
|
||||
Part: &npv1.ConfigUpload_Chunk{Chunk: &npv1.ConfigChunk{
|
||||
Sequence: seq, Data: body[off:end],
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(chunk %d): %v", seq, err)
|
||||
}
|
||||
seq++
|
||||
}
|
||||
if seq < 2 {
|
||||
t.Fatalf("тест не перевіряє чанкування: вийшов %d чанк", seq)
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.ConfigUpload{
|
||||
Part: &npv1.ConfigUpload_Trailer{Trailer: &npv1.ConfigTrailer{
|
||||
Success: true,
|
||||
ContentSha256: want[:],
|
||||
SizeBytes: uint64(len(body)),
|
||||
LineCount: uint32(bytes.Count(body, []byte("\n"))),
|
||||
ChunkCount: seq,
|
||||
Duration: durationpb.New(2 * time.Second),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send(trailer): %v", err)
|
||||
}
|
||||
|
||||
receipt, err := stream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndRecv: %v", err)
|
||||
}
|
||||
if !receipt.Accepted {
|
||||
t.Fatalf("сервер відхилив конфіг: %+v", receipt.Error)
|
||||
}
|
||||
if receipt.CommitSha == "" {
|
||||
t.Fatal("немає commit_sha — конфіг не потрапив у Git")
|
||||
}
|
||||
|
||||
stored := recvWithin(t, srv.config, "збережений конфіг")
|
||||
if !bytes.Equal(stored.body, body) {
|
||||
t.Fatalf("тіло спотворилось: %d байт замість %d", len(stored.body), len(body))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5. Пошкоджений конфіг має бути відхилений, а не тихо збережений
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestConfigUploadRejectsBadChecksum(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
client := dial(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.UploadConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadConfig: %v", err)
|
||||
}
|
||||
|
||||
body := []byte("hostname core-sw-01\n")
|
||||
bogus := sha256.Sum256([]byte("щось зовсім інше"))
|
||||
|
||||
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Header{
|
||||
Header: &npv1.ConfigHeader{JobId: "job-2", DeviceId: "dev-1", ConfigType: "running"},
|
||||
}})
|
||||
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Chunk{
|
||||
Chunk: &npv1.ConfigChunk{Sequence: 0, Data: body},
|
||||
}})
|
||||
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Trailer{
|
||||
Trailer: &npv1.ConfigTrailer{
|
||||
Success: true, ContentSha256: bogus[:], SizeBytes: uint64(len(body)), ChunkCount: 1,
|
||||
},
|
||||
}})
|
||||
|
||||
receipt, err := stream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndRecv: %v", err)
|
||||
}
|
||||
if receipt.Accepted {
|
||||
t.Fatal("сервер прийняв конфіг із неправильною контрольною сумою")
|
||||
}
|
||||
if receipt.Error == nil || receipt.Error.Code != "checksum_mismatch" {
|
||||
t.Fatalf("немає зрозумілої причини відмови: %+v", receipt.Error)
|
||||
}
|
||||
}
|
||||
12
test/contract/go.mod
Normal file
12
test/contract/go.mod
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
module github.com/netpulse/netpulse/test/contract
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/netpulse/netpulse/gen/go v0.0.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
)
|
||||
|
||||
// Згенерований код лежить у репозиторії поруч, а не тягнеться з проксі.
|
||||
replace github.com/netpulse/netpulse/gen/go => ../../gen/go
|
||||
38
test/contract/go.sum
Normal file
38
test/contract/go.sum
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
Loading…
Add table
Reference in a new issue