Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім StreamLogs, а не контрольний канал: сплеск логів під час аварії не має заважати heartbeat і командам. Хост зіставляється за адресою джерела на зонді: у сервера немає контексту мережі клієнта, а один приватний діапазон трапляється в десятках кабінетів. Невідома адреса не привід викинути подію. Подія, що збіглася зі зразком у ncm.device_policies.syslog_match, ставить позачерговий збір конфігу. Типовий зразок покриває Cisco, HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує секунд, пропущений — цілої зміни. Заразом увесь репозиторій прогнано через gofmt: CI, написаний два кроки тому, перевіряє це і впав би на 29 файлах із порушеннями, накопиченими за весь проєкт. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.8 KiB
Go
135 lines
4.8 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// UndoResult — що вийшло після відкату.
|
||
type UndoResult struct {
|
||
Revision int64 `json:"revision"`
|
||
RestoredFrom int64 `json:"restored_from"`
|
||
}
|
||
|
||
// ErrNothingToUndo — історії ще немає (мапу щойно створили).
|
||
var ErrNothingToUndo = errors.New("немає до чого відкочуватись")
|
||
|
||
// UndoMap повертає полотно до попереднього знімка.
|
||
//
|
||
// Відкат оформлюється як НОВА ревізія, а не як відмотування лічильника.
|
||
// Інакше клієнти, що тримають номер 8, після відкату до 7 отримали б
|
||
// «свою» ревізію знову актуальною й тихо перезаписали б відкочене.
|
||
// Undo — така сама зміна, як будь-яка інша, і має рухати історію вперед.
|
||
//
|
||
// Ідентифікатори вузлів зберігаються зі знімка: інакше ребра, що на них
|
||
// посилаються, довелося б переписувати, а зовнішні посилання (виділення
|
||
// в UI, закладки) зламалися б.
|
||
func (s *Store) UndoMap(ctx context.Context, tenantID, mapID, authorID string) (*UndoResult, error) {
|
||
res := &UndoResult{}
|
||
|
||
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
|
||
}
|
||
|
||
// Найсвіжіший знімок ДО поточного стану. Номери ревізій можуть
|
||
// мати проміжки (автопобудова, кілька правок), тому шукаємо
|
||
// максимальний менший, а не current-1.
|
||
var (
|
||
from int64
|
||
nodes []byte
|
||
edges []byte
|
||
backs []byte
|
||
)
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT revision,
|
||
(snapshot->'nodes')::text::bytea,
|
||
(snapshot->'edges')::text::bytea,
|
||
(snapshot->'backgrounds')::text::bytea
|
||
FROM topo.map_revisions
|
||
WHERE map_id = $1 AND revision < $2
|
||
ORDER BY revision DESC
|
||
LIMIT 1
|
||
`, mapID, current).Scan(&from, &nodes, &edges, &backs)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNothingToUndo
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Порядок важливий: ребра посилаються на вузли, тож видаляємо
|
||
// їх першими, а вставляємо — останніми.
|
||
if _, err := tx.Exec(ctx, `DELETE FROM topo.map_edges WHERE map_id = $1`, mapID); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx, `DELETE FROM topo.map_nodes WHERE map_id = $1`, mapID); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx, `DELETE FROM topo.map_backgrounds WHERE map_id = $1`, mapID); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Вузли вставляємо двома проходами: спершу без parent_node_id,
|
||
// потім проставляємо його. Інакше вкладений вузол міг би
|
||
// зустрітися раніше за свого батька й упертись у FK.
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO topo.map_nodes
|
||
SELECT (r).* FROM (
|
||
SELECT jsonb_populate_record(null::topo.map_nodes, e) AS r
|
||
FROM jsonb_array_elements($1::jsonb) e
|
||
) t
|
||
`, string(nodes)); err != nil {
|
||
return fmt.Errorf("відновлення вузлів: %w", err)
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO topo.map_backgrounds
|
||
SELECT (r).* FROM (
|
||
SELECT jsonb_populate_record(null::topo.map_backgrounds, e) AS r
|
||
FROM jsonb_array_elements($1::jsonb) e
|
||
) t
|
||
`, string(backs)); err != nil {
|
||
return fmt.Errorf("відновлення підкладок: %w", err)
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO topo.map_edges
|
||
SELECT (r).* FROM (
|
||
SELECT jsonb_populate_record(null::topo.map_edges, e) AS r
|
||
FROM jsonb_array_elements($1::jsonb) e
|
||
) t
|
||
`, string(edges)); err != nil {
|
||
return fmt.Errorf("відновлення ребер: %w", err)
|
||
}
|
||
|
||
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(&res.Revision); err != nil {
|
||
return err
|
||
}
|
||
res.RestoredFrom = from
|
||
|
||
return snapshotMap(ctx, tx, tenantID, mapID, res.Revision, authorID,
|
||
fmt.Sprintf("відкат до ревізії %d", from))
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, mapPgError(err)
|
||
}
|
||
return res, nil
|
||
}
|