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

500 lines
20 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"
"time"
"github.com/jackc/pgx/v5"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"google.golang.org/protobuf/types/known/durationpb"
)
// ErrNoProfile лишається для порівняння через errors.Is; текст
// уточнюється на місці — «немає профілю» має три різні причини, і
// кожна лікується по-своєму.
var ErrNoProfile = errors.New("для хоста не задано профіль збору конфігу")
// EnqueueConfigJob ставить збір конфігу в чергу.
//
// Саме черга в БД, а не прямий виклик: HTTP-процес і AgentService —
// різні процеси, і живу сесію зонда тримає лише другий. Черга робить
// передачу між ними явною й переживає перезапуск обох.
func (s *Store) EnqueueConfigJob(ctx context.Context, tenantID, deviceID, trigger, userID string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Другий queued для того самого хоста нічого не додає: перший
// збере той самий конфіг. Тому натискання кнопки двічі не
// множить роботу.
err := tx.QueryRow(ctx, `
SELECT id::text FROM ncm.jobs
WHERE tenant_id = $1 AND device_id = $2 AND status IN ('queued','running')
LIMIT 1
`, tenantID, deviceID).Scan(&id)
if err == nil {
return nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
return tx.QueryRow(ctx, `
INSERT INTO ncm.jobs (tenant_id, device_id, agent_id, trigger, requested_by)
SELECT $1, d.id, d.agent_id, $3::ncm.job_trigger, $4
FROM inv.devices d
WHERE d.id = $2 AND d.tenant_id = $1 AND d.deleted_at IS NULL
RETURNING id::text
`, tenantID, deviceID, trigger, nullUUID(userID)).Scan(&id)
})
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
// PendingConfigJob — завдання, готове до відправки зонду.
type PendingConfigJob struct {
JobID string
TenantID string
AgentID string
Job *npv1.ConfigJob
}
// ClaimConfigJobs забирає завдання для зондів, які зараз на зв'язку.
//
// Забирає, а не читає: рядок одразу переходить у 'running'. Два
// екземпляри AgentService за балансувальником інакше надіслали б одне
// завдання двічі, і пристрій отримав би дві паралельні сесії.
func (s *Store) ClaimConfigJobs(ctx context.Context, onlineAgents []string, limit int, ring *crypto.Keyring) ([]PendingConfigJob, error) {
if len(onlineAgents) == 0 {
return nil, nil
}
if limit <= 0 {
limit = 16
}
rows, err := s.bg.Query(ctx, `
UPDATE ncm.jobs j
SET status = 'running', started_at = now()
WHERE j.id IN (
SELECT id FROM ncm.jobs
WHERE status = 'queued' AND agent_id = ANY($1::uuid[])
ORDER BY created_at
-- SKIP LOCKED: другий екземпляр не чекає на нас, а бере
-- наступні завдання. Без цього паралельні диспетчери
-- вишикувались би в чергу за одним рядком.
FOR UPDATE SKIP LOCKED
LIMIT $2
)
RETURNING j.id::text, j.tenant_id::text, j.agent_id::text, j.device_id::text
`, onlineAgents, limit)
if err != nil {
return nil, err
}
type claimed struct{ jobID, tenantID, agentID, deviceID string }
var list []claimed
for rows.Next() {
var c claimed
if err := rows.Scan(&c.jobID, &c.tenantID, &c.agentID, &c.deviceID); err != nil {
rows.Close()
return nil, err
}
list = append(list, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]PendingConfigJob, 0, len(list))
for _, c := range list {
job, err := s.buildConfigJob(ctx, c.tenantID, c.deviceID, c.jobID, ring)
if err != nil {
// Завдання, яке неможливо зібрати, має впасти зараз і з
// поясненням, а не висіти в 'running' до перезапуску.
_ = s.FinishConfigJob(ctx, c.jobID, "failed", err.Error(), "")
continue
}
out = append(out, PendingConfigJob{
JobID: c.jobID, TenantID: c.tenantID, AgentID: c.agentID, Job: job,
})
}
return out, nil
}
// buildConfigJob збирає завдання з профілю, хоста й доступу.
func (s *Store) buildConfigJob(ctx context.Context, tenantID, deviceID, jobID string, ring *crypto.Keyring) (*npv1.ConfigJob, error) {
// Хост, оголошений машиною власного зонда, іде окремою гілкою — і
// вона стоїть найпершою, до читання адреси. Ні адреси, ні доступу,
// ні профілю в такого хоста немає й бути не може: зонд читає власний
// диск, а не набирає когось по мережі. Перевірки нижче відмовили б
// йому саме за те, чого для нього не існує.
if agentID, self, err := s.SelfDeviceOf(ctx, tenantID, deviceID); err != nil {
return nil, err
} else if self {
return s.buildLocalFilesJob(ctx, tenantID, deviceID, jobID, agentID)
}
var (
devName, address string
profileID *string
credID *string
)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT d.name, COALESCE(host(d.address),''),
p.profile_id::text, p.credential_id::text
FROM inv.devices d
LEFT JOIN ncm.device_policies p ON p.device_id = d.id
WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL
`, deviceID, tenantID).Scan(&devName, &address, &profileID, &credID)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if address == "" {
return nil, fmt.Errorf("у хоста %s немає адреси", devName)
}
prof, err := s.resolveProfile(ctx, tenantID, deviceID, profileID)
if err != nil {
return nil, err
}
cred, err := s.resolveNcmCredential(ctx, tenantID, deviceID, credID, ring)
if err != nil {
return nil, err
}
transport, port := jobTransport(prof.Transport, cred)
return &npv1.ConfigJob{
JobId: jobID,
Device: &npv1.DeviceTarget{
DeviceId: deviceID,
Name: devName,
Address: address,
},
Credential: cred,
Transport: transport,
Port: port,
Commands: prof.Commands,
PromptRegex: prof.PromptRegex,
EnableRequired: prof.EnableRequired,
ConfigType: "running",
Timeout: durationpb.New(5 * time.Minute),
MaxBytes: 16 << 20,
// Транскрипт пишемо завжди: він потрібен рівно тоді, коли збір
// не вдався, а вдруге відтворити ту саму сесію не вийде.
CaptureTranscript: true,
}, nil
}
type ncmProfile struct {
// ID потрібен, щоб підібраний профіль можна було закріпити за
// хостом, а не переграваати підбір під час кожного завдання.
ID string
Commands []string
PromptRegex string
EnableRequired bool
Transport string
// rawCommands — jsonb із БД до розбору.
rawCommands string
}
// jsonUnmarshalStrings розбирає масив рядків із jsonb.
func jsonUnmarshalStrings(raw string, out *[]string) error {
if raw == "" {
return nil
}
return json.Unmarshal([]byte(raw), out)
}
// resolveProfile шукає профіль: спершу заданий явно, потім за вендором.
//
// Автопідбір за вендором — не здогад, а єдиний спосіб не змушувати
// адміністратора вручну призначати профіль кожному з тисячі хостів.
// Явно заданий при цьому завжди виграє.
func (s *Store) resolveProfile(ctx context.Context, tenantID, deviceID string, explicit *string) (ncmProfile, error) {
var p ncmProfile
// Виробник хоста — єдине, за чим профіль підбирається сам. Читаємо
// його заздалегідь, щоб відмова могла сказати, чого саме бракує:
// «виробник не заданий» і «під цього виробника немає профілю» —
// різні проблеми з різними діями.
var vendor string
_ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT COALESCE(vendor,'') FROM inv.devices WHERE id = $1 AND tenant_id = $2
`, deviceID, tenantID).Scan(&vendor)
})
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if explicit != nil && *explicit != "" {
return tx.QueryRow(ctx, `
SELECT id::text, commands::text, COALESCE(prompt_regex,''),
enable_required, transport::text
FROM ncm.profiles WHERE id = $1
`, *explicit).Scan(&p.ID, &p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
}
// Спершу за sysObjectID: пристрій сам сказав, що він таке, і це
// точніше за поле «виробник», яке заповнює людина руками.
//
// Порядок переваги всередині: правило кабінету перед вбудованим,
// правило з уточненням за описом перед правилом лише за OID,
// довший префікс перед коротшим. Останнє важливе, бо виробник
// кодує в OID і серію: правило на конкретну серію має вигравати
// в загального правила виробника.
err := tx.QueryRow(ctx, `
SELECT pr.id::text, pr.commands::text, COALESCE(pr.prompt_regex,''),
pr.enable_required, pr.transport::text
FROM inv.devices d
JOIN inv.ident_rules aa
ON aa.enabled
AND (aa.tenant_id IS NULL OR aa.tenant_id = d.tenant_id)
AND (d.sys_object_id = aa.oid_prefix
OR d.sys_object_id LIKE aa.oid_prefix || '.%')
AND (aa.descr_regex IS NULL
OR COALESCE(d.sys_descr,'') ~ aa.descr_regex)
JOIN ncm.profiles pr ON pr.id = aa.profile_id
WHERE d.id = $1 AND d.sys_object_id IS NOT NULL
ORDER BY aa.tenant_id NULLS LAST,
(aa.descr_regex IS NULL),
length(aa.oid_prefix) DESC
LIMIT 1
`, deviceID).Scan(&p.ID, &p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
// Потім за виробником — як було. Ця гілка лишається для хостів
// без SNMP: у мережах повно заліза, яке віддає лише CLI.
return tx.QueryRow(ctx, `
SELECT pr.id::text, pr.commands::text, COALESCE(pr.prompt_regex,''),
pr.enable_required, pr.transport::text
FROM inv.devices d
JOIN ncm.profiles pr
ON lower(pr.vendor) = lower(d.vendor)
AND (pr.tenant_id IS NULL OR pr.tenant_id = d.tenant_id)
WHERE d.id = $1 AND d.vendor IS NOT NULL
-- Профіль тенанта важить більше за вбудований: клієнт міг
-- підправити команди під свою прошивку.
ORDER BY pr.tenant_id NULLS LAST, pr.key
LIMIT 1
`, deviceID).Scan(&p.ID, &p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
})
if errors.Is(err, pgx.ErrNoRows) {
switch {
case vendor == "":
return p, fmt.Errorf("%w: хост не впізнано за SNMP і виробник не заданий — "+
"або дайте хосту SNMP-доступ — він розпізнається сам, "+
"або оберіть профіль вручну у вкладці «Збір конфігів»", ErrNoProfile)
default:
return p, fmt.Errorf("%w: під виробника %q немає готового профілю — "+
"оберіть інший у вкладці «Збір конфігів» або створіть свій", ErrNoProfile, vendor)
}
}
if err != nil {
return p, err
}
if err := jsonUnmarshalStrings(p.rawCommands, &p.Commands); err != nil {
return p, fmt.Errorf("команди профілю: %w", err)
}
if len(p.Commands) == 0 {
return p, fmt.Errorf("%w: обраний профіль не містить жодної команди", ErrNoProfile)
}
return p, nil
}
// resolveNcmCredential бере доступ для CLI й розшифровує його.
func (s *Store) resolveNcmCredential(ctx context.Context, tenantID, deviceID string,
explicit *string, ring *crypto.Keyring) (*npv1.Credential, error) {
var (
id, proto, username string
port int
keyID, aad *string
nonce, ct, tag []byte
)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
q := `
SELECT c.id::text, c.proto::text, COALESCE(c.username,''), COALESCE(c.port,0),
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM inv.credentials c
LEFT JOIN core.secrets s ON s.id = c.secret_id
WHERE c.tenant_id = $1 AND c.id = $2
`
if explicit != nil && *explicit != "" {
return tx.QueryRow(ctx, q, tenantID, *explicit).
Scan(&id, &proto, &username, &port, &keyID, &nonce, &ct, &tag, &aad)
}
// Не задано явно — беремо прив'язаний до хоста доступ, придатний
// для CLI. SNMP-community сюди не годиться.
return tx.QueryRow(ctx, `
SELECT c.id::text, c.proto::text, COALESCE(c.username,''), COALESCE(c.port,0),
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM inv.device_credentials dc
JOIN inv.credentials c ON c.id = dc.credential_id
LEFT JOIN core.secrets s ON s.id = c.secret_id
WHERE dc.device_id = $1 AND c.tenant_id = $2
AND c.proto IN ('ssh','telnet')
ORDER BY dc.priority
LIMIT 1
`, deviceID, tenantID).
Scan(&id, &proto, &username, &port, &keyID, &nonce, &ct, &tag, &aad)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("для хоста не прив'язано доступу ssh або telnet")
}
if err != nil {
return nil, err
}
cred := &npv1.Credential{
CredentialId: id,
Username: username,
Port: uint32(port),
}
if proto == "telnet" {
cred.Transport = npv1.Transport_TRANSPORT_TELNET
} else {
cred.Transport = npv1.Transport_TRANSPORT_SSH
}
if keyID != nil && ring != nil {
plain, err := ring.Decrypt(&crypto.Secret{
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
}, derefStr(aad))
if err != nil {
return nil, fmt.Errorf("розшифровка доступу: %w", err)
}
cred.Secret = &npv1.Credential_Password{Password: string(plain)}
}
return cred, nil
}
// FinishConfigJob закриває завдання.
func (s *Store) FinishConfigJob(ctx context.Context, jobID, status, errMsg, transcript string) error {
_, err := s.bg.Exec(ctx, `
UPDATE ncm.jobs
SET status = $2::ncm.job_status,
finished_at = now(),
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - COALESCE(started_at, now())))::int * 1000),
error = NULLIF($3,''),
log = NULLIF($4,'')
WHERE id = $1
`, jobID, status, errMsg, transcript)
return err
}
// ReapStuckJobs повертає в чергу завдання, що зависли в 'running'.
//
// Зонд міг зникнути разом із завданням: без цього хост залишився б без
// бекапів назавжди, а причина була б видна лише в таблиці.
func (s *Store) ReapStuckJobs(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.bg.Exec(ctx, `
UPDATE ncm.jobs
SET status = 'failed', finished_at = now(),
error = COALESCE(error, 'зонд не відповів у відведений час')
WHERE status = 'running' AND started_at < now() - $1::interval
`, olderThan.String())
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// ConfigJobRow — рядок історії збору для UI.
type ConfigJobRow struct {
ID string `json:"id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
DurationMs int `json:"duration_ms"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Store) ListConfigJobs(ctx context.Context, tenantID, deviceID string, limit int) ([]ConfigJobRow, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
var out []ConfigJobRow
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, status::text, trigger::text,
started_at, finished_at, COALESCE(duration_ms,0),
COALESCE(error,''), created_at
FROM ncm.jobs
WHERE tenant_id = $1 AND device_id = $2
ORDER BY created_at DESC
LIMIT $3
`, tenantID, deviceID, limit)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var j ConfigJobRow
if err := rows.Scan(&j.ID, &j.Status, &j.Trigger, &j.StartedAt,
&j.FinishedAt, &j.DurationMs, &j.Error, &j.CreatedAt); err != nil {
return err
}
out = append(out, j)
}
return rows.Err()
})
return out, err
}
// jobTransport каже, чим набирати цей хост і на який порт.
//
// Транспорт вирішує ДОСТУП, а не профіль. Профіль описує модель заліза:
// які команди віддати й як упізнати запрошення. А чим до конкретної
// коробки достукатись — властивість самої коробки: та сама модель у
// клієнта може стояти з увімкненим SSH, а на сусідньому вузлі — зі
// старою прошивкою, де є лише telnet.
//
// Раніше вирішував профіль, і оскільки всі 147 вбудованих кажуть «ssh»,
// хост із telnet-доступом набирався по SSH і не збирався ніколи.
// Профільне поле лишилось підказкою «чим це залізо зазвичай беруть» і
// діє, тільки поки доступу немає.
func jobTransport(profileTransport string, cred *npv1.Credential) (npv1.Transport, uint32) {
transport := npv1.Transport_TRANSPORT_SSH
switch {
case cred != nil:
transport = cred.Transport
case profileTransport == "telnet":
transport = npv1.Transport_TRANSPORT_TELNET
}
port := uint32(22)
if transport == npv1.Transport_TRANSPORT_TELNET {
port = 23
}
// Порт із доступу перекриває типовий: залізо за NAT цілком може
// слухати SSH на 2222.
if cred != nil && cred.Port != 0 {
port = cred.Port
}
return transport, port
}
// ResolveProfile — публічна обгортка підбору профілю.
//
// Потрібна розпізнаванню: воно закріплює підібраний профіль за хостом,
// і робити це другим, схожим, але окремим запитом означало б завести
// два підбори, які розійдуться при першій же правці одного з них.
func (s *Store) ResolveProfile(ctx context.Context, tenantID, deviceID string, explicit *string) (ncmProfile, error) {
return s.resolveProfile(ctx, tenantID, deviceID, explicit)
}