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

335 lines
16 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"
"errors"
"fmt"
"time"
"github.com/netpulse/netpulse/server/internal/gitstore"
"github.com/netpulse/netpulse/server/internal/store"
)
// Фоновий такт дзеркалення архіву конфігів на зовнішній Git.
//
// ГОЛОВНЕ РІШЕННЯ ЦЬОГО ФАЙЛУ — те, що його немає в шляху збору.
//
// Був простіший варіант: пушити одразу після коміту, у StoreConfig.
// Він на десять рядків коротший і має рівно один недолік — робить
// доступність чужого сервера умовою успішного бекапу. Збір конфігів
// тут — нічний розклад по сотнях пристроїв через живі сесії зондів; на
// кожному з них push додав би секунди мережевого очікування, а на
// недоступному Forgejo — таймаут. Тобто зламаний бекап там, де все
// залізо відповіло, конфіг зібрано й у базі він уже лежить.
//
// Дзеркало — копія. Копія не має права ламати оригінал.
//
// Звідси решта: власний такт, власне advisory-блокування, витримка
// повтору в БАЗІ (а не в пам'яті процесу), і жодного зворотного зв'язку
// в ncm.jobs.
//
// Живе в колекторі поруч із прибиральником (ncm_retention.go) і з тих
// самих причин: тут стоїть той, хто версії створює, тут уже є ключ
// шифрування, і це не процес, який обслуговує запити людини.
// mirrorLockKey — «npmr». Своє, не спільне з прибиральником і
// планувальником: перший повний push інсталяції з річною історією може
// тривати десятки хвилин, і зупиняти на цей час чистку чи розклад збору
// не має жодних підстав.
const mirrorLockKey = 0x6e70_6d72
// MirrorInterval — як часто дивитись, чи є що дзеркалити.
//
// Хвилина. Такт дешевий: якщо локальні посилання не змінились із
// останнього вдалого push, у мережу він не йде взагалі (див. нижче).
// Рідший такт означав би, що ручне «надіслати зараз» після зміни
// налаштувань чекає невідомо скільки.
const MirrorInterval = time.Minute
// mirrorVerifyEvery — як часто ходити на сервер, навіть коли локально
// нічого не змінилось.
//
// Відбиток посилань каже, чи змінилось у НАС. Він нічого не каже про
// те, чи ціле дзеркало: репозиторій на тому кінці могли видалити,
// перестворити порожнім або відкликати токен. Без цієї перевірки
// інсталяція, де конфіги стабільні тижнями, вважала б дзеркало живим
// рівно доти, доки воно не знадобиться.
const mirrorVerifyEvery = 6 * time.Hour
// mirrorAlertAfter — скільки невдач поспіль до алерту.
//
// Три. З витримкою повтору це приблизно вісім хвилин — досить, щоб
// пережити перезавантаження Forgejo, і замало, щоб відмова прожила
// непоміченою день.
const mirrorAlertAfter = 3
// MirrorGit тримає такт дзеркалення.
//
// Кілька екземплярів колектора безпечні: тік бере advisory-блокування,
// тож пушить рівно один. Два одночасні push у той самий репозиторій
// не зіпсували б віддалене дерево (без -f воно або перемотується, або
// відмовляє), але дали б два з'єднання й два суперечливі записи стану.
func (s *Service) MirrorGit(ctx context.Context) {
t := time.NewTicker(MirrorInterval)
defer t.Stop()
s.log.Info("дзеркалення конфігів у зовнішній Git запущено", "інтервал", MirrorInterval)
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.mirrorTick(ctx)
}
}
}
func (s *Service) mirrorTick(ctx context.Context) {
conn, err := s.store.WorkerPool().Acquire(ctx)
if err != nil {
s.log.Error("дзеркало: з'єднання", "err", err)
return
}
defer conn.Release()
var got bool
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
int64(mirrorLockKey)).Scan(&got); err != nil {
s.log.Error("дзеркало: блокування", "err", err)
return
}
if !got {
// Пушить інший інстанс — штатний стан.
return
}
defer func() {
_, _ = conn.Exec(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, int64(mirrorLockKey))
}()
// Спершу видалення гілок, потім звичайний push.
//
// Порядок не косметичний: шаблонний refspec дзеркалення (`*:*`)
// зниклі гілки не прибирає й ніколи не прибиратиме — див. чому в
// store/ncm_refqueue.go. Тобто це єдине місце, де видалений хост
// зникає з дзеркала, і робити його після push означало б лишати
// гілку живою на тому кінці ще на такт.
s.drainRefDeletes(ctx)
targets, err := s.store.MirrorTargets(ctx, s.ring)
if err != nil {
s.log.Error("дзеркало: перелік кабінетів", "err", err)
return
}
for _, t := range targets {
if ctx.Err() != nil {
return
}
s.mirrorOne(ctx, t)
}
}
func (s *Service) mirrorOne(ctx context.Context, t store.MirrorTarget) {
refs, hash, err := s.store.LocalMirrorState(t.TenantID)
switch {
case errors.Is(err, gitstore.ErrNoRepo), err == nil && refs == 0:
// Дзеркало ввімкнули до першого бекапу. Це не відмова й не
// успіх: позначити його вдалим означало б поставити на сторінці
// час «останньої синхронізації», якої не було.
return
case errors.Is(err, gitstore.ErrDisabled):
s.mirrorFailed(ctx, t, errors.New("версіювання в Git вимкнено на цьому сервері"))
return
case err != nil:
s.mirrorFailed(ctx, t, err)
return
}
// Нічого не змінилось і нещодавно перевіряли — у мережу не йдемо.
// Дзеркалення не має бути постійним трафіком на чужий сервер:
// архів конфігів міняється раз на добу, а такт тікає щохвилини.
if hash == t.StateHash && t.LastOkAt != nil && time.Since(*t.LastOkAt) < mirrorVerifyEvery {
return
}
stat, err := s.store.PushMirror(ctx, t.TenantID, t.Remote)
if err != nil {
s.mirrorFailed(ctx, t, err)
return
}
if err := s.store.MarkMirror(ctx, t.TenantID, t.RepoID, store.MirrorOutcome{
OK: true,
Refs: stat.Refs,
StateHash: stat.StateHash,
Duration: stat.Took,
}); err != nil {
s.log.Error("дзеркало: запис стану", "tenant", t.TenantID, "err", err)
}
if t.FailCount > 0 {
if err := s.store.ResolveMirrorAlert(ctx, t.TenantID, t.RepoID); err != nil {
s.log.Error("дзеркало: зняття алерту", "tenant", t.TenantID, "err", err)
}
}
if !stat.UpToDate {
s.log.Info("дзеркало оновлено", "tenant", t.TenantID,
"гілок", stat.Refs, "тривалість", stat.Took.Round(time.Millisecond))
}
}
// refDeleteBatch — скільки гілок розглядати за один такт.
//
// Хвилинний такт і сто гілок означають, що масове видалення двохсот
// хостів доїде до дзеркала за дві хвилини. Брати всі одразу немає
// сенсу: кожна гілка — окрема мережева операція, і черга, яка займає
// такт на десять хвилин, затримує сам push.
const refDeleteBatch = 100
// drainRefDeletes прибирає гілки видалених хостів.
//
// Два кроки на рядок, і другий може не відбутись роками, не заважаючи
// першому:
//
// 1. локальна гілка — на нашому ж диску, тож або зникає, або її вже
// немає; помилка тут означає зламаний репозиторій, і рядок лишається
// в черзі з поясненням;
// 2. гілка на дзеркалі — по мережі, з витримкою повтору в базі.
//
// Дзеркало не налаштоване — рядок знімається після першого кроку: на
// тому кінці цієї гілки не було ніколи.
func (s *Service) drainRefDeletes(ctx context.Context) {
tasks, err := s.store.PendingRefDeletes(ctx, s.ring, refDeleteBatch)
if err != nil {
s.log.Error("черга видалень гілок", "err", err)
return
}
for _, t := range tasks {
if ctx.Err() != nil {
return
}
if !t.LocalDone {
removed, err := s.store.DeleteLocalBranch(t.TenantID, t.Branch)
switch {
case errors.Is(err, gitstore.ErrDisabled):
// Версіювання вимкнено на цьому сервері — гілок немає
// ні тут, ні на дзеркалі. Рядок нема кому виконувати.
_ = s.store.FinishRefDelete(ctx, t.ID)
continue
case err != nil:
s.failRefDelete(ctx, t, err)
continue
}
if removed {
s.log.Info("гілку видаленого хоста прибрано локально",
"гілка", t.Branch, "хост", t.DeviceName)
}
if err := s.store.MarkRefDeleteLocalDone(ctx, t.ID); err != nil {
s.log.Error("черга видалень гілок: позначка", "err", err)
}
}
if !t.MirrorReady {
_ = s.store.FinishRefDelete(ctx, t.ID)
continue
}
if err := s.store.DeleteMirrorRefs(ctx, t.TenantID, t.Remote, []string{t.Branch}); err != nil {
s.failRefDelete(ctx, t, err)
continue
}
s.log.Info("гілку видаленого хоста прибрано на дзеркалі",
"гілка", t.Branch, "хост", t.DeviceName,
"url", gitstore.RedactURL(t.Remote.URL))
if err := s.store.FinishRefDelete(ctx, t.ID); err != nil {
s.log.Error("черга видалень гілок: зняття", "err", err)
}
}
}
func (s *Service) failRefDelete(ctx context.Context, t store.RefDeleteTask, cause error) {
wait := mirrorBackoff(t.Attempts + 1)
// Витримка та сама, що й у дзеркалення: недоступний сервер не має
// отримувати з'єднання щохвилини цілий тиждень. Окремого алерту тут
// немає навмисно — його вже піднімає сам push, і другий про той
// самий сервер лише розмив би перший.
s.log.Warn("гілку видаленого хоста прибрати не вдалось",
"гілка", t.Branch, "хост", t.DeviceName,
"спроб_поспіль", t.Attempts+1, "повторерез", wait, "err", cause)
if err := s.store.FailRefDelete(ctx, t.ID, cause, time.Now().Add(wait)); err != nil {
s.log.Error("черга видалень гілок: запис відмови", "err", err)
}
}
// mirrorFailed записує відмову, призначає наступну спробу й, коли
// відмов уже забагато, піднімає алерт.
func (s *Service) mirrorFailed(ctx context.Context, t store.MirrorTarget, cause error) {
fails := t.FailCount + 1
wait := mirrorBackoff(fails)
// Текст помилки вже очищений від секрету в gitstore (Scrub), але
// журнал отримує ще й адресу без логіна: у полі URL цілком може
// лежати https://токен@forgejo/…, вставлений із інструкції.
s.log.Warn("дзеркало: push не вдався",
"tenant", t.TenantID, "url", gitstore.RedactURL(t.Remote.URL),
"спроб_поспіль", fails, "повторерез", wait, "err", cause)
if err := s.store.MarkMirror(ctx, t.TenantID, t.RepoID, store.MirrorOutcome{
OK: false,
Err: cause.Error(),
NextAt: time.Now().Add(wait),
}); err != nil {
s.log.Error("дзеркало: запис стану", "tenant", t.TenantID, "err", err)
}
if fails < mirrorAlertAfter {
return
}
// Дві градації, і різниця між ними — не в кількості спроб, а в
// тому, скільки часу вже немає копії. Годину без дзеркала переживе
// будь-хто; доба без нього означає, що вчорашніх конфігів за
// периметром немає взагалі.
severity, since := "average", "жодного разу"
if t.LastOkAt == nil || time.Since(*t.LastOkAt) > 24*time.Hour {
severity = "high"
}
if t.LastOkAt != nil {
since = t.LastOkAt.Format("2006-01-02 15:04")
}
msg := fmt.Sprintf(
"Архів конфігів не їде на %s. Спроб поспіль: %d. Останній вдалий обмін: %s. Причина: %s",
gitstore.RedactURL(t.Remote.URL), fails, since, cause)
if err := s.store.RaiseMirrorAlert(ctx, t.TenantID, t.RepoID, severity,
"Дзеркало архіву конфігів не працює", msg); err != nil {
s.log.Error("дзеркало: алерт", "tenant", t.TenantID, "err", err)
}
}
// mirrorBackoff — витримка перед наступною спробою.
//
// Наростає й упирається в годину. Недоступний сервер не має отримувати
// з'єднання щохвилини цілий тиждень: це і марний трафік, і — коли він
// повернеться — сплеск від усіх інсталяцій одночасно. Стеля саме
// година, а не доба: копія, яка відновлюється через добу після
// полагодження мережі, спізнюється рівно на ту добу, за яку її й могли
// б утратити.
func mirrorBackoff(fails int) time.Duration {
switch {
case fails <= 1:
return time.Minute
case fails == 2:
return 2 * time.Minute
case fails == 3:
return 5 * time.Minute
case fails == 4:
return 15 * time.Minute
case fails == 5:
return 30 * time.Minute
default:
return time.Hour
}
}