Netpulse_SasS/server/internal/store/maps_write.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
2026-08-27 17:32:49 +03:00

778 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&current)
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
}