Netpulse_SasS/server/internal/grpcapi/ncm_dispatch.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

161 lines
7.1 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 grpcapi
import (
"context"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// DispatchConfigJobs роздає завдання збору конфігу живим сесіям.
//
// Опитування таблиці, а не сповіщення: чергу наповнює інший процес
// (REST), і LISTEN/NOTIFY тут дав би доставку «здебільшого» — воно не
// переживає перезапуск слухача. Ціна опитування — один дешевий запит за
// індексом раз на кілька секунд, і лише тоді, коли хоч один зонд
// на зв'язку.
func (s *Service) DispatchConfigJobs(ctx context.Context, every time.Duration) {
if every <= 0 {
every = 5 * time.Second
}
t := time.NewTicker(every)
defer t.Stop()
// Зависле завдання повертається у відмову за десять хвилин: зонд міг
// зникнути разом із ним, і без цього хост лишився б без бекапів
// назавжди.
reap := time.NewTicker(2 * time.Minute)
defer reap.Stop()
s.log.Info("диспетчер збору конфігів запущено", "інтервал", every)
for {
select {
case <-ctx.Done():
return
case <-reap.C:
if n, err := s.store.ReapStuckJobs(ctx, 10*time.Minute); err != nil {
s.log.Warn("прибирання завислих завдань", "err", err)
} else if n > 0 {
s.log.Warn("завдання збору зависли й позначені як невдалі", "рядків", n)
}
s.reapCommandRuns(ctx)
// Відкат прибирається в тому ж такті, але його «зависло»
// означає інше, ніж у решти: зонд міг зникнути ПОСЕРЕД
// заливки, і на пристрої лишився стан, якого не описує
// жоден із двох конфігів. Тому там не відмова, а
// контрольний збір — див. store.ReapStuckRollbacks.
s.reapRollbacks(ctx)
case <-t.C:
// Закриття доведених до кінця прогонів — на швидкому такті
// й ДО перевірки, чи є живі зонди. Прогін, у якому останній
// хост щойно відзвітував (або жоден і не мав зонда),
// інакше висів би «у роботі» до прибиральника, а сторінка
// весь цей час перечитувала б його щодві секунди.
s.settleCommandRuns(ctx)
// Підсумок контрольних зборів після відкатів — теж на
// швидкому такті й теж до перевірки живих зондів: намір,
// у якого перевірка вже зібралась, інакше висів би
// «перевіряємо» до наступного прибиральника, а сторінка
// весь цей час його перечитувала б.
s.settleRollbacks(ctx)
online := s.onlineAgentIDs()
if len(online) == 0 {
continue
}
// Прохання «розпізнати зараз» їде тим самим тактом:
// це той самий шлях REST → база → жива сесія, і власний
// таймер задля кількох рядків був би зайвою деталлю.
s.dispatchIdentify(ctx, online)
// Масове виконання команд — так само: та сама черга в базі,
// та сама жива сесія. Власний такт додав би ще один
// інтервал, який довелося б тримати узгодженим із цим.
s.dispatchCommandJobs(ctx, online)
// Відкат — тим самим тактом і тією ж живою сесією. Власної
// черги йому не потрібно: у .proto для нього є окреме
// повідомлення, а шлях «база → сесія» той самий.
s.dispatchRollbacks(ctx, online)
jobs, err := s.store.ClaimConfigJobs(ctx, online, 16, s.ring)
if err != nil {
s.log.Error("вибірка завдань збору", "err", err)
continue
}
for _, j := range jobs {
ok := s.PushToAgent(j.AgentID, &npv1.ControlDown{
Payload: &npv1.ControlDown_ConfigJob{ConfigJob: j.Job},
})
if !ok {
// Сесія обірвалась між вибіркою й відправкою.
// Повертаємо в чергу, а не втрачаємо: наступний тік
// віддасть завдання, щойно зонд повернеться.
_ = s.store.FinishConfigJob(ctx, j.JobID, "failed",
"зонд відключився до надсилання завдання", "")
continue
}
s.log.Info("завдання збору надіслано",
"job_id", j.JobID, "agent", j.AgentID,
"device", j.Job.GetDevice().GetName())
}
}
}
}
// onlineAgentIDs — хто зараз на зв'язку.
func (s *Service) onlineAgentIDs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.sessions))
for id := range s.sessions {
out = append(out, id)
}
return out
}
// dispatchIdentify роздає прохання розпізнати хост.
//
// Зонд не виконує все, що прилетіло: DiscoveryRequest лише зрушує
// задачі, які вже є в його розкладі. Тому чек розпізнавання створюється
// раніше — у RequestIdentify, — а сюди доходить саме поштовх.
func (s *Service) dispatchIdentify(ctx context.Context, online []string) {
reqs, err := s.store.ClaimIdentifyRequests(ctx, online, 64)
if err != nil {
s.log.Warn("вибірка прохань розпізнати", "err", err)
return
}
if len(reqs) == 0 {
return
}
// Спершу план, потім поштовх. RequestIdentify міг щойно завести
// чек, а зонд зрушує лише те, що вже має в розкладі: без цього
// рядка кнопка мовчки нічого не робила б саме там, де вона
// найпотрібніша — на щойно заведеному хості.
s.syncPlansTick(ctx)
for _, r := range reqs {
ok := s.PushToAgent(r.AgentID, &npv1.ControlDown{
Payload: &npv1.ControlDown_DiscoveryRequest{
DiscoveryRequest: &npv1.DiscoveryRequest{
RunId: "identify:" + r.DeviceID,
DeviceIds: []string{r.DeviceID},
},
},
})
if !ok {
s.log.Warn("зонд відключився до надсилання прохання розпізнати",
"agent", r.AgentID, "device", r.DeviceID)
continue
}
s.log.Info("розпізнавання зрушено вручну",
"agent", r.AgentID, "device", r.DeviceID)
}
}