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

209 lines
9.7 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"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/gitstore"
)
// Черга видалень посилань у Git.
//
// НАВІЩО ЧЕРГА, А НЕ ПРЯМИЙ ВИКЛИК
//
// Локальну гілку видалення хоста прибирає одразу — вона на тому ж
// диску. Гілка на дзеркалі живе на чужому сервері, і саме про нього в
// цьому продукті вже ухвалено рішення: дзеркалення не входить у шлях
// збору конфігів, бо копія не має права ламати оригінал
// (grpcapi/ncm_mirror.go). Видалення хоста підпадає під те саме
// правило, лише з іншого боку: недоступний Forgejo не має заважати
// видалити хост.
//
// Звідси й черга. Рядок лягає в ТУ САМУ транзакцію, що видаляє хост:
// або зникли обидва — і хост, і обіцянка прибрати його гілку, — або
// жоден. Далі рядок чекає скільки треба, а розгрібає його той самий
// такт, що й дзеркалення, під тим самим advisory-блокуванням: два
// процеси, які одночасно пушать у той самий репозиторій, дали б два
// суперечливі записи стану.
//
// ЧОМУ НЕ PRUNE
//
// Спокуса зробити push із --prune і не тримати жодної черги велика.
// Але prune зносить на тому кінці все, чого немає тут, — тобто в день,
// коли локальний репозиторій пошкодився, він знищив би дзеркало разом
// із ним. Дзеркало заводять рівно на цей день. Тому видалення адресне:
// у черзі лежить ім'я конкретної гілки, і надсилається видалення рівно
// її.
// RefDeleteTask — одна гілка, яку треба прибрати.
type RefDeleteTask struct {
ID string
TenantID string
Branch string
DeviceName string
Attempts int
LocalDone bool
// Дзеркало налаштоване. Коли ні, лишається прибрати саму лише
// локальну гілку — і рядок можна знімати з черги: на тому кінці
// цієї гілки не було ніколи.
MirrorReady bool
Remote gitstore.Remote
}
// enqueueRefDeletes кладе гілки в чергу.
//
// Викликається ВСЕРЕДИНІ транзакції видалення хоста — інакше втрачається
// єдина гарантія, заради якої черга існує.
//
// ON CONFLICT DO NOTHING: хост могли видалити, завести знову з тим самим
// іменем і адресою й видалити ще раз. Другий рядок означав би другу
// спробу видалити те, чого вже немає, і другу помилку в журналі.
func enqueueRefDeletes(ctx context.Context, tx pgx.Tx, tenantID, deviceID, deviceName string,
branches []string) error {
if len(branches) == 0 {
return nil
}
// repo_id береться підзапитом, а не окремим читанням: рядка
// ncm.repos може ще не бути (хост видаляють до першого бекапу), і
// NULL тут — робочий стан, а не пропуск.
for _, b := range branches {
if b == "" {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO ncm.ref_deletions
(tenant_id, repo_id, branch, device_id, device_name)
VALUES ($1,
(SELECT id FROM ncm.repos WHERE tenant_id = $1 ORDER BY created_at LIMIT 1),
$2, $3, $4)
ON CONFLICT (tenant_id, branch) DO NOTHING
`, tenantID, b, deviceID, deviceName); err != nil {
return err
}
}
return nil
}
// PendingRefDeletes — що вже час прибирати, по всіх кабінетах.
//
// Запит іде в пул, а не в тенантну транзакцію: такт колектора не
// належить жодному кабінету. Так само влаштовано MirrorTargets, і з тієї
// самої причини.
//
// Секрет дзеркала розшифровується тут і живе рівно стільки, скільки
// триває спроба.
func (s *Store) PendingRefDeletes(ctx context.Context, ring *crypto.Keyring, limit int) ([]RefDeleteTask, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.bg.Query(ctx, `
SELECT d.id::text, d.tenant_id::text, d.branch, d.device_name,
d.attempts, d.local_done,
COALESCE(r.mirror_enabled, false) AND r.remote_url IS NOT NULL,
COALESCE(r.remote_url,''), COALESCE(r.remote_auth,'none'),
COALESCE(r.remote_user,''), COALESCE(r.remote_host_key,''),
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM ncm.ref_deletions d
-- По repo_id, а не по tenant_id: кабінет може мати кілька
-- репозиторіїв, і join по кабінету розмножив би рядок черги на
-- кожен із них. Порожній repo_id означає, що на момент
-- видалення репозиторію ще не існувало, — тобто хост не
-- комітився й на дзеркалі його гілки не було ніколи.
LEFT JOIN ncm.repos r ON r.id = d.repo_id
LEFT JOIN core.secrets s ON s.id = r.remote_secret_id
JOIN core.tenants t ON t.id = d.tenant_id AND t.deleted_at IS NULL
WHERE d.next_attempt_at IS NULL OR d.next_attempt_at <= now()
ORDER BY d.created_at
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RefDeleteTask
for rows.Next() {
var t RefDeleteTask
var keyID, aad *string
var nonce, ct, tag []byte
if err := rows.Scan(&t.ID, &t.TenantID, &t.Branch, &t.DeviceName,
&t.Attempts, &t.LocalDone, &t.MirrorReady,
&t.Remote.URL, &t.Remote.Auth, &t.Remote.User, &t.Remote.HostKey,
&keyID, &nonce, &ct, &tag, &aad); err != nil {
return nil, err
}
if keyID != nil && ring != nil {
plain, err := ring.Decrypt(&crypto.Secret{
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
}, derefStr(aad))
if err == nil {
t.Remote.Secret = string(plain)
}
}
out = append(out, t)
}
return out, rows.Err()
}
// MarkRefDeleteLocalDone запам'ятовує, що локальної гілки вже немає.
//
// Окремо від зняття рядка з черги: локальна гілка зникає одразу, а
// віддалена може чекати добу непрацюючого дзеркала. Без цієї позначки
// такт щохвилини повторював би локальне видалення того, чого немає, — і
// на кожному повторі мав би вирішувати, чи не помилка це.
func (s *Store) MarkRefDeleteLocalDone(ctx context.Context, id string) error {
_, err := s.bg.Exec(ctx,
`UPDATE ncm.ref_deletions SET local_done = true WHERE id = $1`, id)
return err
}
// FinishRefDelete знімає рядок із черги: гілки більше немає ніде.
func (s *Store) FinishRefDelete(ctx context.Context, id string) error {
_, err := s.bg.Exec(ctx, `DELETE FROM ncm.ref_deletions WHERE id = $1`, id)
return err
}
// FailRefDelete записує невдачу й призначає наступну спробу.
//
// Витримка живе в БАЗІ, а не в пам'яті процесу, — те саме рішення й та
// сама причина, що й у дзеркаленні: колектор перезапускають, і витримка
// з пам'яті після кожного релізу починала б шквал спроб рівно тоді, коли
// ми щойно перезапустились.
func (s *Store) FailRefDelete(ctx context.Context, id string, cause error, next time.Time) error {
msg := ""
if cause != nil {
msg = cause.Error()
}
_, err := s.bg.Exec(ctx, `
UPDATE ncm.ref_deletions
SET attempts = attempts + 1, last_error = NULLIF($2,''), next_attempt_at = $3
WHERE id = $1
`, id, msg, next)
return err
}
// DeleteLocalBranch прибирає гілку в локальному репозиторії кабінету.
//
// Тонка обгортка з тієї ж причини, що й PushMirror: gitstore нічого не
// знає про базу, а store — про протокол Git. Єдине, що додається, — ім'я
// репозиторію, яке обидва боки мусять розуміти однаково.
func (s *Store) DeleteLocalBranch(tenantID, branch string) (bool, error) {
if s.git == nil {
return false, gitstore.ErrDisabled
}
return s.git.DeleteBranch(RepoName(tenantID), branch)
}
// DeleteMirrorRefs просить дзеркало прибрати названі гілки.
func (s *Store) DeleteMirrorRefs(ctx context.Context, tenantID string, r gitstore.Remote,
branches []string) error {
if s.git == nil {
return gitstore.ErrDisabled
}
return s.git.DeleteRemoteRefs(ctx, RepoName(tenantID), r, branches)
}