Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
272 lines
11 KiB
Go
272 lines
11 KiB
Go
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
|
||
}
|