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