Netpulse_SasS/server/internal/store/maps_topology.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

272 lines
11 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"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
// Тут живе те, чого мапі бракувало, щоб перестати бути малюнком:
// перелік портів для ручного зіставлення й прохання зібрати топологію
// з хостів, які на цій мапі стоять.
// DiscoverCheckType — повне автовиявлення: сусіди плюс інвентар портів.
const DiscoverCheckType = "topology.discover"
// Параметри повного обходу.
//
// Лише LLDP і CDP, хоч модуль уміє ще ARP і FDB. Причина не в трафіку:
// ARP-таблиця маршрутизатора містить усі хости підмережі, і кожен
// резолвлений сусід став би «лінком» — на мапі це десятки ліній, яких
// фізично немає. ARP і FDB корисні як доповнення там, де LLDP вимкнено,
// і вмикати їх має людина свідомо, у формі перевірок хоста.
const discoverCheckParams = `{"protos":["lldp","cdp"],"collect_interfaces":true}`
// Раз на годину. Топологія змінюється рідко, а обхід ifTable на
// комутаторі з сотнями портів — тисячі PDU: частіше означало б
// вантажити обладнання заради даних, які й так не змінились.
const discoverCheckInterval = time.Hour
// Обхід усієї таблиці інтерфейсів не вкладається в типові три секунди.
const discoverCheckTimeoutMs = 30000
// MapInterface — порт хоста, який стоїть на мапі.
type MapInterface struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Name string `json:"name"`
Alias string `json:"alias,omitempty"`
SpeedBps *int64 `json:"speed_bps,omitempty"`
OperStatus string `json:"oper_status,omitempty"`
}
// MapInterfaces віддає порти всіх пристроїв мапи одним запитом.
//
// Одним, а не по вузлу: інспектор ребра відкривають кліком, і
// дозапитувати два хости на кожен клік означало б показувати порожні
// списки рівно ту секунду, поки людина в них дивиться.
func (s *Store) MapInterfaces(ctx context.Context, tenantID, mapID string) ([]MapInterface, error) {
var out []MapInterface
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT i.id::text, i.device_id::text, i.name, COALESCE(i.alias,''),
i.speed_bps, i.oper_status::text
FROM inv.interfaces i
WHERE i.tenant_id = $1
AND i.device_id IN (
SELECT n.device_id FROM topo.map_nodes n
WHERE n.map_id = $2 AND n.tenant_id = $1 AND n.device_id IS NOT NULL
)
ORDER BY i.device_id, i.if_index NULLS LAST, i.name
`, tenantID, mapID)
if err != nil {
return err
}
defer rows.Close()
out = []MapInterface{}
for rows.Next() {
var i MapInterface
if err := rows.Scan(&i.ID, &i.DeviceID, &i.Name, &i.Alias,
&i.SpeedBps, &i.OperStatus); err != nil {
return err
}
out = append(out, i)
}
return rows.Err()
})
return out, err
}
// MapDiscoveryResult — підсумок «зібрати топологію».
type MapDiscoveryResult struct {
// Скільки хостів на мапі взагалі.
Devices int `json:"devices"`
// Скільком поставлено прохання опитати зараз.
Queued int `json:"queued"`
// Скільком автовиявлення завелося щойно (раніше його не було).
Enabled int `json:"enabled"`
// Зонд не на зв'язку: прохання лишається й спрацює, коли він
// повернеться.
Waiting int `json:"waiting"`
// Кого пропустили й чому — готовим для показу текстом.
Skipped []MapDiscoverySkip `json:"skipped,omitempty"`
}
// MapDiscoverySkip — хост, якому автовиявлення не завести.
type MapDiscoverySkip struct {
Device string `json:"device"`
Reason string `json:"reason"`
}
// RequestMapDiscovery заводить і зрушує повне автовиявлення на хостах мапи.
//
// Навіщо окрема дія. Мапа приречена лишатись малюнком, доки
// `topology.discover` не заведений на кожному хості руками: без нього в
// системі немає ні сусідів, ні інвентарю портів, а отже ні лінків, ні
// підписів на лініях. Місце для цієї дії — сторінка мапи: саме там
// людина бачить, що зв'язків немає, і саме там питає «чому».
//
// Механізм — той самий, яким ходить «Розпізнати зараз» (див.
// RequestIdentify): REST лишає намір у базі, диспетчер колектора його
// забирає й надсилає живій сесії DiscoveryRequest. Зонд зрушує задачі з
// власного розкладу за префіксом `topology.`, тому чек мусить існувати
// ДО поштовху — інакше прохання не влучає нікуди.
//
// Розпізнавання підвищується до повного обходу, а не додається поруч:
// обидва чеки знімають ту саму системну групу, і два чеки одного модуля
// лише подвоїли б трафік. Зворотної дії тут немає навмисно — прибрати
// автовиявлення можна у формі перевірок хоста, де видно всі його чеки.
func (s *Store) RequestMapDiscovery(ctx context.Context, tenantID, mapID string, sc Scope) (*MapDiscoveryResult, error) {
res := &MapDiscoveryResult{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var exists bool
if err := tx.QueryRow(ctx, `
SELECT true FROM topo.maps
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, mapID, tenantID).Scan(&exists); errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
type candidate struct {
id string
name string
hasAgent bool
agentOnline bool
hasSNMP bool
}
rows, err := tx.Query(ctx, `
SELECT DISTINCT d.id::text, d.name,
d.agent_id IS NOT NULL,
COALESCE(a.status::text = 'online', false),
EXISTS (
SELECT 1 FROM inv.device_credentials dc
JOIN inv.credentials c ON c.id = dc.credential_id
WHERE dc.device_id = d.id
AND c.tenant_id = d.tenant_id
AND c.proto IN ('snmp_v2c','snmp_v3')
)
FROM topo.map_nodes n
JOIN inv.devices d ON d.id = n.device_id
LEFT JOIN core.agents a ON a.id = d.agent_id
WHERE n.map_id = $1 AND n.tenant_id = $2
AND d.tenant_id = $2 AND d.deleted_at IS NULL AND d.enabled
ORDER BY d.name
`, mapID, tenantID)
if err != nil {
return err
}
var cands []candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.id, &c.name, &c.hasAgent, &c.agentOnline, &c.hasSNMP); err != nil {
rows.Close()
return err
}
cands = append(cands, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
res.Devices = len(cands)
for _, c := range cands {
switch {
case !sc.CanWrite(c.id):
res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name,
"немає доступу на запис до цього хоста"})
continue
case !c.hasSNMP:
res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name,
"немає SNMP-доступу — сусідів нема чим питати"})
continue
case !c.hasAgent:
res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name,
"не прив'язаний до зонда — нікому його опитати"})
continue
}
// Спершу підвищуємо розпізнавання: воно вже є майже в
// кожного хоста зі SNMP, і другий чек того самого модуля
// був би подвоєним трафіком.
tag, err := tx.Exec(ctx, `
UPDATE core.checks ch
SET check_type = $3,
params = $4::jsonb,
interval_sec = $5,
timeout_ms = GREATEST(ch.timeout_ms, $6),
enabled = true,
updated_at = now()
WHERE ch.tenant_id = $2 AND ch.device_id = $1
AND ch.check_type = $7
AND NOT EXISTS (
SELECT 1 FROM core.checks c2
WHERE c2.tenant_id = $2 AND c2.device_id = $1
AND c2.check_type = $3
)
`, c.id, tenantID, DiscoverCheckType, discoverCheckParams,
int32(discoverCheckInterval/time.Second), int32(discoverCheckTimeoutMs),
IdentifyCheckType)
if err != nil {
return err
}
made := tag.RowsAffected()
// Хоста без розпізнавання (наприклад, заведеного до появи
// автоматики) заводимо з нуля.
tag, err = tx.Exec(ctx, `
INSERT INTO core.checks
(tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries)
SELECT $2, $1, $3, $4::jsonb, $5, $6, 1
WHERE NOT EXISTS (
SELECT 1 FROM core.checks ch
WHERE ch.tenant_id = $2 AND ch.device_id = $1
AND ch.check_type = $3
)
`, c.id, tenantID, DiscoverCheckType, discoverCheckParams,
int32(discoverCheckInterval/time.Second), int32(discoverCheckTimeoutMs))
if err != nil {
return err
}
made += tag.RowsAffected()
if made > 0 {
res.Enabled++
}
// Поштовх. Колонка називається identify_requested_at, але
// несе саме «опитай цей хост зараз»: диспетчер шле
// DiscoveryRequest, а зонд зрушує за ним усі задачі модуля
// topology — і розпізнавання, і повний обхід.
if _, err := tx.Exec(ctx, `
UPDATE inv.devices
SET identify_requested_at = now(), updated_at = now()
WHERE id = $1 AND tenant_id = $2
`, c.id, tenantID); err != nil {
return err
}
res.Queued++
if !c.agentOnline {
res.Waiting++
}
}
return nil
})
if err != nil {
return nil, err
}
return res, nil
}