Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
778 lines
31 KiB
Go
778 lines
31 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
)
|
||
|
||
// Скільки знімків полотна тримати. Undo глибше 50 кроків ніхто не
|
||
// робить, а кожен знімок — це повна копія мапи в jsonb.
|
||
const maxRevisionsKept = 50
|
||
|
||
var (
|
||
// ErrConflict — мапу змінив хтось інший, поки клієнт редагував.
|
||
ErrConflict = errors.New("мапу змінено іншим користувачем")
|
||
// ErrPlanLimit — упёрлись у ліміт тарифу (тригер у БД).
|
||
ErrPlanLimit = errors.New("досягнуто ліміт тарифу")
|
||
// ErrInvalid — некоректні дані від клієнта.
|
||
ErrInvalid = errors.New("некоректний запит")
|
||
)
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Форма патча
|
||
//
|
||
// Усі скалярні поля — вказівники: nil означає «не чіпати». Це не
|
||
// формальність. Перетягування вузла шле лише x/y, і якби відсутні поля
|
||
// трактувались як порожні, кожен рух миші стирав би стиль, розмір і
|
||
// прив'язку до пристрою.
|
||
// ---------------------------------------------------------------------
|
||
|
||
type MapPatch struct {
|
||
// Очікувана ревізія. nil вимикає перевірку — так робить лише
|
||
// серверний код (автопобудова), не UI.
|
||
Revision *int64 `json:"revision"`
|
||
Name *string `json:"name,omitempty"`
|
||
LayoutAlgo *string `json:"layout_algo,omitempty"`
|
||
Viewport json.RawMessage `json:"viewport,omitempty"`
|
||
Grid json.RawMessage `json:"grid,omitempty"`
|
||
Clustering json.RawMessage `json:"clustering,omitempty"`
|
||
|
||
Nodes NodePatch `json:"nodes"`
|
||
Edges EdgePatch `json:"edges"`
|
||
Backgrounds BackgroundPatch `json:"backgrounds"`
|
||
|
||
Comment string `json:"comment,omitempty"`
|
||
}
|
||
|
||
type NodePatch struct {
|
||
Upsert []NodeInput `json:"upsert"`
|
||
Remove []string `json:"remove"`
|
||
}
|
||
|
||
type NodeInput struct {
|
||
ID string `json:"id,omitempty"`
|
||
// Тимчасовий ідентифікатор нового вузла. Сервер поверне мапу
|
||
// client_id → id, щоб полотно не перемальовувалось повністю після
|
||
// кожного створення.
|
||
ClientID string `json:"client_id,omitempty"`
|
||
|
||
Kind string `json:"kind,omitempty"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
ParentID string `json:"parent_id,omitempty"`
|
||
TargetMapID string `json:"target_map_id,omitempty"`
|
||
|
||
Label *string `json:"label,omitempty"`
|
||
X *float64 `json:"x,omitempty"`
|
||
Y *float64 `json:"y,omitempty"`
|
||
Width *float64 `json:"width,omitempty"`
|
||
Height *float64 `json:"height,omitempty"`
|
||
ZIndex *int `json:"z_index,omitempty"`
|
||
Style json.RawMessage `json:"style,omitempty"`
|
||
Data json.RawMessage `json:"data,omitempty"`
|
||
Collapsed *bool `json:"collapsed,omitempty"`
|
||
Locked *bool `json:"locked,omitempty"`
|
||
Hidden *bool `json:"hidden,omitempty"`
|
||
}
|
||
|
||
type EdgePatch struct {
|
||
Upsert []EdgeInput `json:"upsert"`
|
||
Remove []string `json:"remove"`
|
||
}
|
||
|
||
type EdgeInput struct {
|
||
ID string `json:"id,omitempty"`
|
||
ClientID string `json:"client_id,omitempty"`
|
||
|
||
SourceNodeID string `json:"source_node_id,omitempty"`
|
||
TargetNodeID string `json:"target_node_id,omitempty"`
|
||
SourceInterfaceID string `json:"source_interface_id,omitempty"`
|
||
TargetInterfaceID string `json:"target_interface_id,omitempty"`
|
||
SourceHandle string `json:"source_handle,omitempty"`
|
||
TargetHandle string `json:"target_handle,omitempty"`
|
||
LinkID string `json:"link_id,omitempty"`
|
||
|
||
Label *string `json:"label,omitempty"`
|
||
Style *string `json:"style,omitempty"`
|
||
Dash *string `json:"dash,omitempty"`
|
||
Color *string `json:"color,omitempty"`
|
||
WidthPx *float64 `json:"width_px,omitempty"`
|
||
Waypoints json.RawMessage `json:"waypoints,omitempty"`
|
||
Animation json.RawMessage `json:"animation,omitempty"`
|
||
Thresholds json.RawMessage `json:"thresholds,omitempty"`
|
||
ShowMetrics *bool `json:"show_metrics,omitempty"`
|
||
// Подання лінії. Приходить цілим об'єктом, як і style у вузла:
|
||
// зливати частковий jsonb на боці БД означало б завести другий,
|
||
// невидимий у типах спосіб задати те саме поле.
|
||
View json.RawMessage `json:"view,omitempty"`
|
||
ZIndex *int `json:"z_index,omitempty"`
|
||
Locked *bool `json:"locked,omitempty"`
|
||
Hidden *bool `json:"hidden,omitempty"`
|
||
}
|
||
|
||
type BackgroundPatch struct {
|
||
Upsert []BackgroundInput `json:"upsert"`
|
||
Remove []string `json:"remove"`
|
||
}
|
||
|
||
type BackgroundInput struct {
|
||
ID string `json:"id,omitempty"`
|
||
ClientID string `json:"client_id,omitempty"`
|
||
|
||
Kind string `json:"kind,omitempty"`
|
||
StorageKey *string `json:"storage_key,omitempty"`
|
||
MimeType *string `json:"mime_type,omitempty"`
|
||
X *float64 `json:"x,omitempty"`
|
||
Y *float64 `json:"y,omitempty"`
|
||
Width *float64 `json:"width,omitempty"`
|
||
Height *float64 `json:"height,omitempty"`
|
||
Rotation *float64 `json:"rotation,omitempty"`
|
||
Opacity *float64 `json:"opacity,omitempty"`
|
||
Locked *bool `json:"locked,omitempty"`
|
||
ZIndex *int `json:"z_index,omitempty"`
|
||
Geo json.RawMessage `json:"geo,omitempty"`
|
||
Rack json.RawMessage `json:"rack,omitempty"`
|
||
}
|
||
|
||
// PatchResult — що повернути клієнту після збереження.
|
||
type PatchResult struct {
|
||
Revision int64 `json:"revision"`
|
||
NodeIDs map[string]string `json:"node_ids,omitempty"`
|
||
EdgeIDs map[string]string `json:"edge_ids,omitempty"`
|
||
BgIDs map[string]string `json:"background_ids,omitempty"`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Створення й видалення мапи
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Store) CreateMap(ctx context.Context, tenantID, name, slug, kind string) (*MapSummary, error) {
|
||
if name == "" || slug == "" {
|
||
return nil, fmt.Errorf("%w: потрібні name і slug", ErrInvalid)
|
||
}
|
||
if kind == "" {
|
||
kind = "logical"
|
||
}
|
||
|
||
var m MapSummary
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO topo.maps (tenant_id, name, slug, kind)
|
||
VALUES ($1, $2, $3, $4::topo.map_kind)
|
||
RETURNING id::text, name, slug, kind::text, is_default, revision, updated_at
|
||
`, tenantID, name, slug, kind).Scan(&m.ID, &m.Name, &m.Slug, &m.Kind,
|
||
&m.IsDefault, &m.Revision, &m.UpdatedAt)
|
||
})
|
||
if err != nil {
|
||
return nil, mapPgError(err)
|
||
}
|
||
return &m, nil
|
||
}
|
||
|
||
// DeleteMap — м'яке видалення: топологія й історія лишаються, зникає
|
||
// лише полотно. Мапа — це подання, а не дані.
|
||
func (s *Store) DeleteMap(ctx context.Context, tenantID, mapID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE topo.maps SET deleted_at = now(), updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
`, mapID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Патч
|
||
// ---------------------------------------------------------------------
|
||
|
||
// ApplyMapPatch застосовує зміни полотна атомарно.
|
||
//
|
||
// Оптимістичне блокування: клієнт надсилає ревізію, яку бачив. Якщо за
|
||
// цей час мапу змінив хтось інший — 409, а не тихе затирання. У NOC над
|
||
// однією мапою часто працюють кілька людей, і мовчазна втрата чужих
|
||
// правок гірша за помилку.
|
||
func (s *Store) ApplyMapPatch(ctx context.Context, tenantID, mapID, authorID string, p *MapPatch) (*PatchResult, error) {
|
||
res := &PatchResult{
|
||
NodeIDs: map[string]string{},
|
||
EdgeIDs: map[string]string{},
|
||
BgIDs: map[string]string{},
|
||
}
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Блокуємо рядок мапи: усі зміни полотна серіалізуються через
|
||
// нього, тому паралельні патчі не переплітаються.
|
||
var current int64
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT revision FROM topo.maps
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
FOR UPDATE
|
||
`, mapID, tenantID).Scan(¤t)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if p.Revision != nil && *p.Revision != current {
|
||
return fmt.Errorf("%w: очікувано %d, у базі %d", ErrConflict, *p.Revision, current)
|
||
}
|
||
|
||
if err := applyNodes(ctx, tx, tenantID, mapID, &p.Nodes, res); err != nil {
|
||
return err
|
||
}
|
||
// Ребра після вузлів: нове ребро може посилатися на вузол,
|
||
// створений у цьому ж патчі.
|
||
if err := applyEdges(ctx, tx, tenantID, mapID, &p.Edges, res); err != nil {
|
||
return err
|
||
}
|
||
if err := applyBackgrounds(ctx, tx, tenantID, mapID, &p.Backgrounds, res); err != nil {
|
||
return err
|
||
}
|
||
|
||
var newRev int64
|
||
if err := tx.QueryRow(ctx, `
|
||
UPDATE topo.maps SET
|
||
name = COALESCE($3, name),
|
||
layout_algo = COALESCE($4::topo.layout_algo, layout_algo),
|
||
viewport = COALESCE($5::jsonb, viewport),
|
||
grid = COALESCE($6::jsonb, grid),
|
||
clustering = COALESCE($7::jsonb, clustering),
|
||
revision = revision + 1,
|
||
updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
RETURNING revision
|
||
`, mapID, tenantID, p.Name, p.LayoutAlgo,
|
||
rawArg(p.Viewport), rawArg(p.Grid), rawArg(p.Clustering)).Scan(&newRev); err != nil {
|
||
return err
|
||
}
|
||
res.Revision = newRev
|
||
|
||
return snapshotMap(ctx, tx, tenantID, mapID, newRev, authorID, p.Comment)
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, mapPgError(err)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
func applyNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *NodePatch, res *PatchResult) error {
|
||
// Видаляємо першими: клієнт міг видалити вузол і одразу створити
|
||
// новий на тому ж місці.
|
||
if len(p.Remove) > 0 {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM topo.map_nodes
|
||
WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[])
|
||
`, mapID, tenantID, p.Remove); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
for i, n := range p.Upsert {
|
||
if n.ID == "" {
|
||
if n.Kind == "" {
|
||
return fmt.Errorf("%w: вузол[%d] без kind", ErrInvalid, i)
|
||
}
|
||
if n.X == nil || n.Y == nil {
|
||
return fmt.Errorf("%w: вузол[%d] без координат", ErrInvalid, i)
|
||
}
|
||
|
||
var id string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO topo.map_nodes
|
||
(tenant_id, map_id, kind, device_id, parent_node_id, target_map_id,
|
||
label, x, y, width, height, z_index, style, data, collapsed, locked, hidden)
|
||
VALUES ($1,$2,$3::topo.node_kind,$4,$5,$6,$7,$8,$9,$10,$11,
|
||
COALESCE($12,0), COALESCE($13::jsonb,'{}'::jsonb),
|
||
COALESCE($14::jsonb,'{}'::jsonb),
|
||
COALESCE($15,false), COALESCE($16,false), COALESCE($17,false))
|
||
RETURNING id::text
|
||
`, tenantID, mapID, n.Kind, nullUUID(n.DeviceID), nullUUID(n.ParentID),
|
||
nullUUID(n.TargetMapID), n.Label, *n.X, *n.Y, n.Width, n.Height,
|
||
n.ZIndex, rawArg(n.Style), rawArg(n.Data),
|
||
n.Collapsed, n.Locked, n.Hidden).Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
if n.ClientID != "" {
|
||
res.NodeIDs[n.ClientID] = id
|
||
}
|
||
continue
|
||
}
|
||
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE topo.map_nodes SET
|
||
label = COALESCE($4, label),
|
||
x = COALESCE($5, x),
|
||
y = COALESCE($6, y),
|
||
width = COALESCE($7, width),
|
||
height = COALESCE($8, height),
|
||
z_index = COALESCE($9, z_index),
|
||
style = COALESCE($10::jsonb, style),
|
||
data = COALESCE($11::jsonb, data),
|
||
collapsed = COALESCE($12, collapsed),
|
||
locked = COALESCE($13, locked),
|
||
hidden = COALESCE($14, hidden),
|
||
parent_node_id = COALESCE($15, parent_node_id),
|
||
updated_at = now()
|
||
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
|
||
`, n.ID, mapID, tenantID, n.Label, n.X, n.Y, n.Width, n.Height, n.ZIndex,
|
||
rawArg(n.Style), rawArg(n.Data), n.Collapsed, n.Locked, n.Hidden,
|
||
nullUUID(n.ParentID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return fmt.Errorf("%w: вузол %s не належить цій мапі", ErrInvalid, n.ID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgePatch, res *PatchResult) error {
|
||
if len(p.Remove) > 0 {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM topo.map_edges
|
||
WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[])
|
||
`, mapID, tenantID, p.Remove); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
for i, e := range p.Upsert {
|
||
src, dst := e.SourceNodeID, e.TargetNodeID
|
||
// Ребро могло бути намальоване до вузлів, створених цим же
|
||
// патчем, — підставляємо їхні справжні id.
|
||
if id, ok := res.NodeIDs[src]; ok {
|
||
src = id
|
||
}
|
||
if id, ok := res.NodeIDs[dst]; ok {
|
||
dst = id
|
||
}
|
||
|
||
if e.ID == "" {
|
||
if src == "" || dst == "" {
|
||
return fmt.Errorf("%w: ребро[%d] без кінців", ErrInvalid, i)
|
||
}
|
||
var id string
|
||
if err := tx.QueryRow(ctx, `
|
||
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, dash, color, width_px, waypoints, animation, thresholds,
|
||
show_metrics, z_index, locked, hidden,
|
||
source_handle, target_handle, view)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||
COALESCE($9::topo.edge_style,'smoothstep'),
|
||
COALESCE($10::topo.edge_dash,'solid'),
|
||
$11, COALESCE($12,2),
|
||
COALESCE($13::jsonb,'[]'::jsonb),
|
||
COALESCE($14::jsonb,'{"enabled":true,"speed_source":"utilization"}'::jsonb),
|
||
COALESCE($15::jsonb,'{}'::jsonb),
|
||
COALESCE($16,true), COALESCE($17,0),
|
||
COALESCE($18,false), COALESCE($19,false),
|
||
NULLIF($20,''), NULLIF($21,''), $22::jsonb)
|
||
RETURNING id::text
|
||
`, tenantID, mapID, src, dst,
|
||
clearableUUID(e.SourceInterfaceID), clearableUUID(e.TargetInterfaceID),
|
||
clearableUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
|
||
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
|
||
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden,
|
||
e.SourceHandle, e.TargetHandle, rawArg(e.View)).Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
if e.ClientID != "" {
|
||
res.EdgeIDs[e.ClientID] = id
|
||
}
|
||
continue
|
||
}
|
||
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE topo.map_edges SET
|
||
label = COALESCE($4, label),
|
||
style = COALESCE($5::topo.edge_style, style),
|
||
dash = COALESCE($6::topo.edge_dash, dash),
|
||
color = COALESCE($7, color),
|
||
width_px = COALESCE($8, width_px),
|
||
waypoints = COALESCE($9::jsonb, waypoints),
|
||
animation = COALESCE($10::jsonb, animation),
|
||
thresholds = COALESCE($11::jsonb, thresholds),
|
||
show_metrics = COALESCE($12, show_metrics),
|
||
z_index = COALESCE($13, z_index),
|
||
locked = COALESCE($14, locked),
|
||
hidden = COALESCE($15, hidden),
|
||
-- Порти й прив'язка до лінка міняються трьома станами,
|
||
-- як і бік підключення: не передали — не чіпати,
|
||
-- 'none' — зняти, uuid — поставити. Без зняття
|
||
-- помилково обраний порт лишався б на ребрі назавжди:
|
||
-- COALESCE не відрізняє «прибери» від «не передавали».
|
||
--
|
||
-- Ознака «зняти» їде окремим булевим параметром, а не
|
||
-- рядком 'none' у тому ж полі. Причина в планувальнику:
|
||
-- на CASE $16 WHEN '' ... ELSE $16::uuid він згортає
|
||
-- константи ще до виконання й падає на порожньому рядку
|
||
-- в гілці, до якої черга не дійшла б.
|
||
source_interface_id = CASE WHEN $21 THEN NULL
|
||
ELSE COALESCE($16, source_interface_id) END,
|
||
target_interface_id = CASE WHEN $22 THEN NULL
|
||
ELSE COALESCE($17, target_interface_id) END,
|
||
link_id = CASE WHEN $23 THEN NULL
|
||
ELSE COALESCE($18, link_id) END,
|
||
-- Бік підключення міняється трьома станами, а не двома:
|
||
-- порожньо — не чіпати, 'auto' — зняти привʼязку й
|
||
-- віддати вибік боку полотну, решта — конкретний бік.
|
||
-- Без цього лінію, що причепилась не до того краю,
|
||
-- лишалось хіба видалити й намалювати заново.
|
||
source_handle = CASE $19 WHEN '' THEN source_handle
|
||
WHEN 'auto' THEN NULL ELSE $19 END,
|
||
target_handle = CASE $20 WHEN '' THEN target_handle
|
||
WHEN 'auto' THEN NULL ELSE $20 END,
|
||
view = COALESCE($24::jsonb, view),
|
||
updated_at = now()
|
||
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
|
||
`, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
|
||
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
|
||
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden,
|
||
clearableUUID(e.SourceInterfaceID), clearableUUID(e.TargetInterfaceID),
|
||
clearableUUID(e.LinkID),
|
||
e.SourceHandle, e.TargetHandle,
|
||
e.SourceInterfaceID == clearRef, e.TargetInterfaceID == clearRef,
|
||
e.LinkID == clearRef, rawArg(e.View))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return fmt.Errorf("%w: ребро %s не належить цій мапі", ErrInvalid, e.ID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func applyBackgrounds(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *BackgroundPatch, res *PatchResult) error {
|
||
if len(p.Remove) > 0 {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM topo.map_backgrounds
|
||
WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[])
|
||
`, mapID, tenantID, p.Remove); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
for i, b := range p.Upsert {
|
||
if b.ID == "" {
|
||
if b.Kind == "" {
|
||
return fmt.Errorf("%w: підкладка[%d] без kind", ErrInvalid, i)
|
||
}
|
||
var id string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO topo.map_backgrounds
|
||
(tenant_id, map_id, kind, storage_key, mime_type,
|
||
x, y, width, height, rotation, opacity, locked, z_index, geo, rack)
|
||
VALUES ($1,$2,$3::topo.background_kind,$4,$5,
|
||
COALESCE($6,0), COALESCE($7,0), $8, $9,
|
||
COALESCE($10,0), COALESCE($11,1),
|
||
COALESCE($12,true), COALESCE($13,0),
|
||
$14::jsonb, $15::jsonb)
|
||
RETURNING id::text
|
||
`, tenantID, mapID, b.Kind, b.StorageKey, b.MimeType,
|
||
b.X, b.Y, b.Width, b.Height, b.Rotation, b.Opacity,
|
||
b.Locked, b.ZIndex, rawArg(b.Geo), rawArg(b.Rack)).Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
if b.ClientID != "" {
|
||
res.BgIDs[b.ClientID] = id
|
||
}
|
||
continue
|
||
}
|
||
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE topo.map_backgrounds SET
|
||
storage_key = COALESCE($4, storage_key),
|
||
mime_type = COALESCE($5, mime_type),
|
||
x = COALESCE($6, x), y = COALESCE($7, y),
|
||
width = COALESCE($8, width), height = COALESCE($9, height),
|
||
rotation = COALESCE($10, rotation),
|
||
opacity = COALESCE($11, opacity),
|
||
locked = COALESCE($12, locked),
|
||
z_index = COALESCE($13, z_index),
|
||
geo = COALESCE($14::jsonb, geo),
|
||
rack = COALESCE($15::jsonb, rack),
|
||
updated_at = now()
|
||
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
|
||
`, b.ID, mapID, tenantID, b.StorageKey, b.MimeType, b.X, b.Y,
|
||
b.Width, b.Height, b.Rotation, b.Opacity, b.Locked, b.ZIndex,
|
||
rawArg(b.Geo), rawArg(b.Rack))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return fmt.Errorf("%w: підкладка %s не належить цій мапі", ErrInvalid, b.ID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// snapshotMap зберігає повний стан полотна для undo.
|
||
//
|
||
// Знімок робиться в тій самій транзакції, що й зміна: інакше після збою
|
||
// в історії лишався б крок, якого в мапі немає, і відкат ламав би її.
|
||
func snapshotMap(ctx context.Context, tx pgx.Tx, tenantID, mapID string, revision int64, authorID, comment string) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO topo.map_revisions (tenant_id, map_id, revision, author_id, comment, snapshot)
|
||
SELECT $1, $2, $3, $4, NULLIF($5,''), jsonb_build_object(
|
||
'nodes', COALESCE((SELECT jsonb_agg(to_jsonb(n) ORDER BY n.id)
|
||
FROM topo.map_nodes n WHERE n.map_id = $2), '[]'::jsonb),
|
||
'edges', COALESCE((SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id)
|
||
FROM topo.map_edges e WHERE e.map_id = $2), '[]'::jsonb),
|
||
'backgrounds', COALESCE((SELECT jsonb_agg(to_jsonb(b) ORDER BY b.id)
|
||
FROM topo.map_backgrounds b WHERE b.map_id = $2), '[]'::jsonb)
|
||
)
|
||
ON CONFLICT (map_id, revision) DO NOTHING
|
||
`, tenantID, mapID, revision, nullUUID(authorID), comment); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Історія не має рости вічно: кожен знімок — повна копія полотна.
|
||
//
|
||
// Типи вказані явно: віднімання двох нетипізованих параметрів
|
||
// Postgres розібрати не може («operator is not unique: unknown - unknown»).
|
||
_, err := tx.Exec(ctx, `
|
||
DELETE FROM topo.map_revisions
|
||
WHERE map_id = $1 AND revision <= $2::bigint - $3::int
|
||
`, mapID, revision, maxRevisionsKept)
|
||
return err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Автопобудова
|
||
// ---------------------------------------------------------------------
|
||
|
||
type BuildStats struct {
|
||
NodesAdded int `json:"nodes_added"`
|
||
EdgesAdded int `json:"edges_added"`
|
||
// Скільки намальованих руками ліній привʼязалось до виявлених
|
||
// лінків. Окремо від доданих: для людини це різні події — «зʼявилось
|
||
// нове» і «те, що я малював, нарешті звірено з мережею».
|
||
EdgesLinked int `json:"edges_linked"`
|
||
Revision int64 `json:"revision"`
|
||
}
|
||
|
||
// BuildFromTopology доповнює мапу тим, що виявив агент.
|
||
//
|
||
// Ідемпотентна: вже наявні вузли й ребра не дублюються, координати
|
||
// існуючих не чіпаються. Це принципово — інакше кожен запуск скидав би
|
||
// розкладку, яку оператор робив руками.
|
||
//
|
||
// Нові вузли розставляються сіткою: осмислену розкладку дає лише
|
||
// клієнт (він знає розміри полотна й алгоритм), а сервер має покласти
|
||
// їх хоч кудись, але не в одну точку.
|
||
func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID string) (*BuildStats, error) {
|
||
st := &BuildStats{}
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var revision int64
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT revision FROM topo.maps
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
FOR UPDATE
|
||
`, mapID, tenantID).Scan(&revision)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Пристрої, які є в лінках, але ще не на мапі.
|
||
tag, err := tx.Exec(ctx, `
|
||
WITH existing AS (
|
||
SELECT count(*) AS n FROM topo.map_nodes WHERE map_id = $1
|
||
), missing AS (
|
||
SELECT d.id, d.name,
|
||
row_number() OVER (ORDER BY d.name) - 1 AS pos
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = $2
|
||
AND d.deleted_at IS NULL
|
||
AND d.enabled
|
||
AND EXISTS (
|
||
SELECT 1 FROM topo.links l
|
||
WHERE l.tenant_id = $2 AND (l.a_device_id = d.id OR l.b_device_id = d.id)
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM topo.map_nodes n
|
||
WHERE n.map_id = $1 AND n.device_id = d.id
|
||
)
|
||
)
|
||
INSERT INTO topo.map_nodes (tenant_id, map_id, kind, device_id, label, x, y)
|
||
SELECT $2, $1, 'device', m.id, m.name,
|
||
120 + 260 * ((existing.n + m.pos) % 6),
|
||
120 + 180 * ((existing.n + m.pos) / 6)
|
||
FROM missing m, existing
|
||
`, mapID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
st.NodesAdded = int(tag.RowsAffected())
|
||
|
||
// Спершу привʼязуємо те, що вже намальовано руками.
|
||
//
|
||
// Без цього кроку автопобудова клала поверх ручної лінії другу,
|
||
// свою: між тими самими вузлами зʼявлялись дві паралельні
|
||
// лінії, з яких «жива» лише одна. Людина малювала звʼязок ще до
|
||
// того, як зонд його побачив, — і за це отримувала дубль.
|
||
//
|
||
// DISTINCT ON: між парою пристроїв може бути кілька лінків
|
||
// (LAG, друге волокно). Ручній лінії дістається найвпевненіший,
|
||
// решта поїде окремими ребрами нижче.
|
||
tag, err = tx.Exec(ctx, `
|
||
WITH matched AS (
|
||
SELECT DISTINCT ON (e.id)
|
||
e.id AS edge_id, l.id AS link_id,
|
||
CASE WHEN l.a_device_id = ns.device_id
|
||
THEN l.a_interface_id ELSE l.b_interface_id END AS src_if,
|
||
CASE WHEN l.a_device_id = ns.device_id
|
||
THEN l.b_interface_id ELSE l.a_interface_id END AS dst_if
|
||
FROM topo.map_edges e
|
||
JOIN topo.map_nodes ns ON ns.id = e.source_node_id
|
||
JOIN topo.map_nodes nt ON nt.id = e.target_node_id
|
||
JOIN topo.links l ON l.tenant_id = $2
|
||
AND ns.device_id IS NOT NULL AND nt.device_id IS NOT NULL
|
||
AND LEAST(l.a_device_id, l.b_device_id)
|
||
= LEAST(ns.device_id, nt.device_id)
|
||
AND GREATEST(l.a_device_id, l.b_device_id)
|
||
= GREATEST(ns.device_id, nt.device_id)
|
||
WHERE e.map_id = $1 AND e.tenant_id = $2 AND e.link_id IS NULL
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM topo.map_edges e2
|
||
WHERE e2.map_id = $1 AND e2.link_id = l.id
|
||
)
|
||
ORDER BY e.id, l.confidence DESC, l.last_seen_at DESC
|
||
)
|
||
UPDATE topo.map_edges e
|
||
SET link_id = m.link_id,
|
||
-- Обраний людиною порт не чіпаємо: вона могла знати
|
||
-- більше за LLDP.
|
||
source_interface_id = COALESCE(e.source_interface_id, m.src_if),
|
||
target_interface_id = COALESCE(e.target_interface_id, m.dst_if),
|
||
updated_at = now()
|
||
FROM matched m
|
||
WHERE e.id = m.edge_id
|
||
`, mapID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
st.EdgesLinked = int(tag.RowsAffected())
|
||
|
||
// Ребра для лінків, обидва кінці яких уже на мапі.
|
||
tag, err = tx.Exec(ctx, `
|
||
INSERT INTO topo.map_edges
|
||
(tenant_id, map_id, source_node_id, target_node_id,
|
||
source_interface_id, target_interface_id, link_id, label)
|
||
SELECT $2, $1, na.id, nb.id, l.a_interface_id, l.b_interface_id, l.id,
|
||
COALESCE(ia.name,'?') || ' → ' || COALESCE(ib.name,'?')
|
||
FROM topo.links l
|
||
JOIN topo.map_nodes na ON na.map_id = $1 AND na.device_id = l.a_device_id
|
||
JOIN topo.map_nodes nb ON nb.map_id = $1 AND nb.device_id = l.b_device_id
|
||
LEFT JOIN inv.interfaces ia ON ia.id = l.a_interface_id
|
||
LEFT JOIN inv.interfaces ib ON ib.id = l.b_interface_id
|
||
WHERE l.tenant_id = $2
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM topo.map_edges e
|
||
WHERE e.map_id = $1 AND e.link_id = l.id
|
||
)
|
||
`, mapID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
st.EdgesAdded = int(tag.RowsAffected())
|
||
|
||
if st.NodesAdded == 0 && st.EdgesAdded == 0 && st.EdgesLinked == 0 {
|
||
st.Revision = revision
|
||
return nil
|
||
}
|
||
|
||
if err := tx.QueryRow(ctx, `
|
||
UPDATE topo.maps SET revision = revision + 1, updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
RETURNING revision
|
||
`, mapID, tenantID).Scan(&st.Revision); err != nil {
|
||
return err
|
||
}
|
||
|
||
return snapshotMap(ctx, tx, tenantID, mapID, st.Revision, authorID,
|
||
fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер, звірено %d",
|
||
st.NodesAdded, st.EdgesAdded, st.EdgesLinked))
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, mapPgError(err)
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
// clearRef — «зняти привʼязку» в полях, де порожнє значення вже
|
||
// означає «не чіпати».
|
||
const clearRef = "none"
|
||
|
||
// clearableUUID прибирає службове 'none' зі значення, яке їде в колонку
|
||
// uuid: сам намір «зняти» передається окремим прапорцем.
|
||
func clearableUUID(v string) any {
|
||
if v == clearRef {
|
||
return nil
|
||
}
|
||
return nullUUID(v)
|
||
}
|
||
|
||
// rawArg перетворює порожній json.RawMessage на NULL, щоб COALESCE у
|
||
// запиті лишив старе значення.
|
||
func rawArg(m json.RawMessage) any {
|
||
if len(m) == 0 {
|
||
return nil
|
||
}
|
||
return string(m)
|
||
}
|
||
|
||
// mapPgError перекладає помилки БД у доменні.
|
||
//
|
||
// Ліміт тарифу піднімає тригер bill.assert_device_limit /
|
||
// assert_map_node_limit з підказкою upgrade_plan. Клієнту треба
|
||
// показати пропозицію змінити тариф, а не «внутрішню помилку».
|
||
func mapPgError(err error) error {
|
||
var pgErr *pgconn.PgError
|
||
if errors.As(err, &pgErr) {
|
||
if pgErr.Hint == "upgrade_plan" {
|
||
return fmt.Errorf("%w: %s", ErrPlanLimit, pgErr.Message)
|
||
}
|
||
switch pgErr.Code {
|
||
case "23505": // unique_violation
|
||
// Ім'я індексу — єдине, що відрізняє «намалював лінію
|
||
// вдруге» від «додав хост, який уже на схемі». Обидва
|
||
// випадки трапляються щодня, і загальне «такий запис уже
|
||
// існує» не каже людині, що саме робити далі.
|
||
switch pgErr.ConstraintName {
|
||
case "map_edges_ports_uniq":
|
||
return fmt.Errorf("%w: між цими вузлами вже є звʼязок — "+
|
||
"щоб змінити його вигляд, оберіть наявну лінію", ErrInvalid)
|
||
case "map_nodes_device_uniq":
|
||
return fmt.Errorf("%w: цей хост уже на схемі", ErrInvalid)
|
||
}
|
||
return fmt.Errorf("%w: такий запис уже існує", ErrInvalid)
|
||
case "23503": // foreign_key_violation
|
||
return fmt.Errorf("%w: посилання на неіснуючий об'єкт", ErrInvalid)
|
||
case "23514", "22P02": // check_violation, invalid_text_representation
|
||
return fmt.Errorf("%w: %s", ErrInvalid, pgErr.Message)
|
||
}
|
||
}
|
||
return err
|
||
}
|