Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
542 lines
26 KiB
Go
542 lines
26 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Повне видалення хоста — те, після якого від нього не лишається нічого.
|
||
//
|
||
// ЧОМУ ЦЕ З'ЯВИЛОСЬ ПОРУЧ ІЗ М'ЯКИМ, А НЕ ЗАМІСТЬ НЬОГО
|
||
//
|
||
// М'яке видалення (deleted_at = now()) завели з правильного міркування:
|
||
// жорстке зняло б за собою телеметрію й історію алертів, а їх тримають
|
||
// заради розбору аварій і заради вимог відповідності. Міркування
|
||
// лишається правильним — і саме тому архівне видалення нікуди не
|
||
// зникло.
|
||
//
|
||
// Хибним виявився висновок, що воно може бути ЄДИНИМ. Три факти проти:
|
||
//
|
||
// 1. «Видалити» в інтерфейсі означає видалити. Людина, яка натиснула
|
||
// кнопку й побачила, що хост зник зі списку, не має підстав
|
||
// здогадуватись, що його метрики далі займають місце, а гілка з
|
||
// конфігами далі їде на зовнішній Git.
|
||
// 2. Архів, до якого немає дороги, архівом не є. Переліку видалених
|
||
// хостів у продукті немає, відновлення теж немає: рядок із
|
||
// deleted_at недосяжний нізвідки, крім SQL. Тобто «зберігаємо для
|
||
// розбору» на практиці означає «займає місце й ніколи не
|
||
// знадобиться».
|
||
// 3. Половина прибраного гірша за нуль прибраного. На цьому стенді від
|
||
// двох м'яко видалених хостів лишились 7 рядів метрик і 3
|
||
// перевірки, які не належать жодному видимому хосту, — рівно ті
|
||
// «ряди без перевірки» й «чеки, що мовчать», за якими потім ходять
|
||
// із питанням «звідки це».
|
||
//
|
||
// Тому вибір робить людина у вікні видалення, і обидві дії названі
|
||
// своїми словами: «прибрати з переліку» й «видалити назавжди». Різниця
|
||
// незворотна, і ховати її за одним словом «видалити» не можна в жоден
|
||
// бік.
|
||
//
|
||
// ЩО САМЕ ПРИБИРАЄТЬСЯ
|
||
//
|
||
// Каскади бази роблять більшу частину: на inv.devices стоїть 21
|
||
// зовнішній ключ, і всі, крім topo.neighbors.resolved_device_id
|
||
// (SET NULL), — CASCADE. Одного DELETE досить, щоб зникли перевірки,
|
||
// алерти, мовчання, періоди SLA, доступи, членство в групах, теги,
|
||
// порти, цілі команд, результати відповідності, версії конфігів,
|
||
// розклад збору, завдання, відкати, лінки, вузли мап, сусіди, шаблони
|
||
// й ряди метрик.
|
||
//
|
||
// Каскад НЕ дістає трьох речей, і кожна з них тут прибирається руками:
|
||
//
|
||
// - Зашифровані тіла конфігів у core.secrets. ncm.configs посилається
|
||
// на них через body_secret_id з ON DELETE SET NULL, тож каскадне
|
||
// видалення версій лишило б шифротекст у базі назавжди й без жодного
|
||
// способу до нього дійти. Саме там лежать мегабайти. Розв'язано тим
|
||
// самим deleteConfigRows, що й ручне видалення версій.
|
||
// - Телеметрія в гіпертаблицях. TimescaleDB не дозволяє посилатись на
|
||
// гіпертаблицю зовнішнім ключем, тому ts.icmp_samples,
|
||
// ts.if_counters, ts.syslog, ts.snmp_traps, ts.device_status_history
|
||
// й alr.alerts_history тримають device_id БЕЗ каскаду, а
|
||
// alr.notifications і ts.link_status — alert_id та link_id, які
|
||
// каскадом зникають самі. Перевірено на живій схемі (21 ключ на
|
||
// inv.devices, жодного з цих шести серед них), а не з опису таблиць.
|
||
// - Гілка в Git — локальна й на дзеркалі. Черга видалень посилань,
|
||
// див. ncm_refqueue.go.
|
||
//
|
||
// ЩО ЛИШАЄТЬСЯ, І ЦЕ НАВМИСНО
|
||
//
|
||
// - Журнал аудиту (core.audit_log). Запис про видалення має пережити
|
||
// видалення, інакше його нема сенсу писати; тому в ньому лежать
|
||
// ІМ'Я й адреса, а не лише id, якого вже не існує.
|
||
// - Спільні об'єкти: доступи (inv.credentials), майданчики, групи,
|
||
// шаблони, профілі. Зникає членство хоста в них, а не вони самі.
|
||
// - Об'єкти Git. Знявши посилання, ми не стираємо коміти з диска —
|
||
// їх збере gc. Тіла конфігів прибираються там, де вони справді
|
||
// лежать: у core.secrets.
|
||
// - Матеріалізовані згортки метрик (ts.samples_5m і решта). Вони
|
||
// ключуються series_id, ряд якого вже немає, тож у продукті вони
|
||
// недосяжні; фізично зникнуть із вікном ретеншену згортки.
|
||
|
||
// PurgedDevice — хост, якого більше немає, і що саме з ним пішло.
|
||
//
|
||
// Ім'я й адреса тут не для краси: це те, що поїде в аудит і в
|
||
// відповідь. Після коміту жодним запитом їх уже не дістати.
|
||
type PurgedDevice struct {
|
||
DeviceID string `json:"device_id"`
|
||
Name string `json:"name"`
|
||
Address string `json:"address,omitempty"`
|
||
|
||
Configs int `json:"configs"`
|
||
FreedBytes int64 `json:"freed_bytes"`
|
||
Series int `json:"series"`
|
||
Samples int64 `json:"samples"`
|
||
Branches []string `json:"branches,omitempty"`
|
||
}
|
||
|
||
// PurgeFailure — хост, який видалити не вдалось.
|
||
//
|
||
// Окремим списком, а не помилкою всієї дії: видалення йде по одному
|
||
// хосту в своїй транзакції, і збій на тридцять сьомому не має скасувати
|
||
// тридцять шість уже зроблених. Скасувати їх усе одно неможливо —
|
||
// «відкотити» повне видалення нема з чого.
|
||
type PurgeFailure struct {
|
||
DeviceID string `json:"device_id"`
|
||
Name string `json:"name"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// PurgeResult — підсумок повного видалення.
|
||
type PurgeResult struct {
|
||
Purged []PurgedDevice `json:"purged"`
|
||
Failed []PurgeFailure `json:"failed,omitempty"`
|
||
|
||
Configs int `json:"configs"`
|
||
FreedBytes int64 `json:"freed_bytes"`
|
||
Series int `json:"series"`
|
||
Samples int64 `json:"samples"`
|
||
Branches int `json:"branches"`
|
||
}
|
||
|
||
// telemetryBatch — скільки рядків телеметрії прибирати за одну
|
||
// транзакцію.
|
||
//
|
||
// Не про швидкість. Хост із сотнею портів за місяць накопичує мільйони
|
||
// рядків у ts.if_counters, і один DELETE на всі тримав би блокування й
|
||
// ріс би в WAL стільки, скільки триває видалення. Партія розміром зі
|
||
// сто тисяч робить кожну транзакцію короткою, а перерваний посеред
|
||
// роботи процес лишає хост уже архівованим (див. порядок кроків нижче)
|
||
// — тобто повтор просто доробляє почате, а не починає спочатку.
|
||
const telemetryBatch = 100_000
|
||
|
||
// PurgeDevices видаляє хости назавжди.
|
||
//
|
||
// Стеля та сама, що й у решти масових дій (MaxBulkDevices): підтвердити
|
||
// наосліп можна що завгодно, але тут ціна помилки не «переналаштували
|
||
// не те», а «немає більше нічого».
|
||
//
|
||
// Кожен хост — окремо, і це головна відмінність від BulkUpdateDevices,
|
||
// де одна транзакція на весь набір. Там половина переведеної дільниці
|
||
// гірша за жодної переведеної, бо про неї ніхто не знає. Тут навпаки:
|
||
// «видалено 36 з 50» — зрозумілий стан, який доробляється повтором, а
|
||
// одна транзакція на п'ятдесят хостів означала б каскад на пів
|
||
// інвентарю й блокування таблиць, з яких диспетчер колектора саме зараз
|
||
// читає свій план.
|
||
func (s *Store) PurgeDevices(ctx context.Context, tenantID string, sc Scope,
|
||
ids []string) (*PurgeResult, error) {
|
||
|
||
if len(ids) == 0 {
|
||
return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid)
|
||
}
|
||
if len(ids) > MaxBulkDevices {
|
||
return nil, fmt.Errorf("%w: за раз можна видалити не більше %d хостів, обрано %d",
|
||
ErrInvalid, MaxBulkDevices, len(ids))
|
||
}
|
||
|
||
// Набір звужується ще раз, уже проти бази: перевірка прав в
|
||
// обробнику відповідає на питання «чи можна було», а цей запит — на
|
||
// «чи ще існує». deleted_at тут НЕ фільтрується навмисно: хост,
|
||
// раніше прибраний з переліку, — саме той, який найчастіше треба
|
||
// добити, і відмовити йому означало б лишити його недосяжним
|
||
// назавжди.
|
||
type target struct{ id, name, addr string }
|
||
var targets []target
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, name, COALESCE(host(address),'')
|
||
FROM inv.devices
|
||
WHERE tenant_id = $1 AND id = ANY($2::uuid[])
|
||
AND ($3::boolean OR id = ANY($4::uuid[]))
|
||
ORDER BY name
|
||
`, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var t target
|
||
if err := rows.Scan(&t.id, &t.name, &t.addr); err != nil {
|
||
return err
|
||
}
|
||
targets = append(targets, t)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(targets) == 0 {
|
||
return nil, fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис",
|
||
ErrInvalid)
|
||
}
|
||
|
||
res := &PurgeResult{}
|
||
for _, t := range targets {
|
||
if ctx.Err() != nil {
|
||
break
|
||
}
|
||
one, err := s.purgeOne(ctx, tenantID, t.id, t.name, t.addr)
|
||
if err != nil {
|
||
res.Failed = append(res.Failed, PurgeFailure{
|
||
DeviceID: t.id, Name: t.name, Error: err.Error(),
|
||
})
|
||
continue
|
||
}
|
||
res.Purged = append(res.Purged, *one)
|
||
res.Configs += one.Configs
|
||
res.FreedBytes += one.FreedBytes
|
||
res.Series += one.Series
|
||
res.Samples += one.Samples
|
||
res.Branches += len(one.Branches)
|
||
}
|
||
if len(res.Purged) == 0 && len(res.Failed) > 0 {
|
||
return res, fmt.Errorf("жоден хост видалити не вдалося: %s", res.Failed[0].Error)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// purgeOne — повне видалення одного хоста.
|
||
//
|
||
// Порядок кроків не довільний, і кожен наступний спирається на
|
||
// попередній.
|
||
//
|
||
// 1. Хост спершу АРХІВУЄТЬСЯ (deleted_at, enabled = false, чеки
|
||
// вимкнено) — тим самим кодом, що й звичайне архівне видалення.
|
||
// Це не формальність: доки хост живий, зонд і далі складає в нього
|
||
// нові виміри, і прибирання телеметрії ганялося б за власним
|
||
// хвостом. Заразом це і є точка, у якій перерваний процес лишає
|
||
// зрозумілий стан: хост уже зник з інтерфейсу, а повтор доробить
|
||
// решту.
|
||
// 2. Телеметрія без зовнішніх ключів — партіями, кожна своєю
|
||
// транзакцією. Найдовший крок, і саме тому він поза підсумковою
|
||
// транзакцією.
|
||
// 3. Одна підсумкова транзакція: відкати, тіла конфігів у core.secrets,
|
||
// сповіщення й стан лінків, сам рядок хоста (каскад забирає решту)
|
||
// і рядок черги на видалення гілки.
|
||
// 4. Локальна гілка — після коміту. До коміту її чіпати не можна:
|
||
// транзакція, що відкотилась після зняття посилання, лишила б
|
||
// історію конфігів недосяжною при цілих рядках у базі.
|
||
func (s *Store) purgeOne(ctx context.Context, tenantID, deviceID, name, addr string) (*PurgedDevice, error) {
|
||
out := &PurgedDevice{DeviceID: deviceID, Name: name, Address: addr}
|
||
|
||
// --- крок 1: припинити збір ---
|
||
if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices
|
||
SET deleted_at = COALESCE(deleted_at, now()), enabled = false,
|
||
archived_enabled = COALESCE(archived_enabled, enabled)
|
||
WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, deviceID); err != nil {
|
||
return err
|
||
}
|
||
// Той самий archived_off, що й в архівному видаленні. Хост
|
||
// зникне назавжди за кілька кроків, тож позначка тут ні на що
|
||
// не впливає — вона стоїть, бо крок 1 має лишатись ОДНІЄЮ дією
|
||
// архівування, а не її схожою копією: перерваний посеред роботи
|
||
// процес лишає хост саме заархівованим, і з цього стану його
|
||
// мусить бути видно й можна повернути.
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE core.checks
|
||
SET enabled = false, archived_off = true, updated_at = now()
|
||
WHERE tenant_id = $1 AND device_id = $2 AND enabled
|
||
`, tenantID, deviceID)
|
||
return err
|
||
}); err != nil {
|
||
return nil, fmt.Errorf("зупинка опитування: %w", err)
|
||
}
|
||
|
||
// --- крок 2: телеметрія ---
|
||
//
|
||
// Ряди метрик читаються ОДИН раз і далі використовуються як перелік:
|
||
// ts.samples не має ні tenant_id, ні device_id, і єдиний шлях від
|
||
// хоста до його вимірів — через ts.series. Після кроку 1 нові ряди
|
||
// не з'являються.
|
||
var seriesIDs []int64
|
||
if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id FROM ts.series WHERE tenant_id = $1 AND device_id = $2
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var id int64
|
||
if err := rows.Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
seriesIDs = append(seriesIDs, id)
|
||
}
|
||
return rows.Err()
|
||
}); err != nil {
|
||
return nil, fmt.Errorf("ряди метрик: %w", err)
|
||
}
|
||
out.Series = len(seriesIDs)
|
||
|
||
if len(seriesIDs) > 0 {
|
||
// Виміри прибираються ЯВНО, а не каскадом від ts.series.
|
||
//
|
||
// Не тому, що каскад не спрацював би. Спрацював би: перевірено
|
||
// на TimescaleDB 2.17.2 зі СТИСНЕНИМ шматком — DELETE рядів
|
||
// прибрав усі 2000 вимірів, які лежали стиснутими. Це варто
|
||
// було виміряти, бо припущення напрошувалось протилежне:
|
||
// зовнішній ключ оголошено на шматку, а стиснуті рядки лежать
|
||
// не в ньому.
|
||
//
|
||
// Причина в іншому: каскад робить усе однією операцією, тобто
|
||
// на хості з місячною історією тримає одну транзакцію на
|
||
// мільйони рядків. Явний прохід ділить це на партії, кожна зі
|
||
// своєю транзакцією (див. purgeTelemetry), і заразом рахує, що
|
||
// саме зникло — а це число потім читає людина у звіті й аудиті.
|
||
// Каскад не рахує нічого.
|
||
n, err := s.purgeTelemetry(ctx, tenantID,
|
||
`DELETE FROM ts.samples WHERE series_id = ANY($1::bigint[]) AND ts <= $2`,
|
||
`SELECT max(ts) FROM (SELECT ts FROM ts.samples
|
||
WHERE series_id = ANY($1::bigint[]) ORDER BY ts LIMIT $2) q`,
|
||
seriesIDs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("виміри метрик: %w", err)
|
||
}
|
||
out.Samples = n
|
||
}
|
||
|
||
// Решта телеметрії ходить по device_id — і саме її не бачить жоден
|
||
// каскад: гіпертаблиця не може бути ціллю зовнішнього ключа.
|
||
for _, tbl := range []string{
|
||
"ts.icmp_samples", "ts.if_counters", "ts.syslog",
|
||
"ts.snmp_traps", "ts.device_status_history", "alr.alerts_history",
|
||
} {
|
||
if _, err := s.purgeTelemetry(ctx, tenantID,
|
||
`DELETE FROM `+tbl+` WHERE tenant_id = $1 AND device_id = $3 AND ts <= $2`,
|
||
`SELECT max(ts) FROM (SELECT ts FROM `+tbl+`
|
||
WHERE tenant_id = $1 AND device_id = $3 ORDER BY ts LIMIT $2) q`,
|
||
nil, deviceID); err != nil {
|
||
return nil, fmt.Errorf("%s: %w", tbl, err)
|
||
}
|
||
}
|
||
|
||
// --- крок 3: підсумкова транзакція ---
|
||
var branches []string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Відкати перші: ncm.rollbacks.target_config_id оголошено
|
||
// RESTRICT, тобто версія конфігу, на яку посилається відкат, не
|
||
// видаляється, поки живий сам відкат. Каскад від хоста забрав
|
||
// би обидва, але тіла конфігів ми прибираємо ДО нього — і без
|
||
// цього рядка спіткнулись би об RESTRICT.
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM ncm.rollbacks WHERE tenant_id = $1 AND device_id = $2
|
||
`, tenantID, deviceID); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Гілки — усі, під якими хост колись комітився. Зазвичай одна,
|
||
// але перейменування могло не доїхати (RenameBranch не валить
|
||
// збір конфігу, а лише попереджає), і тоді їх дві.
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT DISTINCT branch FROM ncm.configs
|
||
WHERE tenant_id = $1 AND device_id = $2 AND branch <> ''
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
seen := map[string]bool{}
|
||
for rows.Next() {
|
||
var b string
|
||
if err := rows.Scan(&b); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
if !seen[b] {
|
||
seen[b] = true
|
||
branches = append(branches, b)
|
||
}
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
// Плюс те ім'я, яке гілка мала б ЗАРАЗ. Рядок ncm.configs може
|
||
// не встигнути про нього дізнатись: між перейменуванням хоста й
|
||
// наступним бекапом гілка вже переїхала, а в базі стоїть старе
|
||
// ім'я.
|
||
if b := DeviceBranch(deviceID, name, addr); !seen[b] {
|
||
branches = append(branches, b)
|
||
}
|
||
|
||
// Тіла конфігів. Той самий deleteConfigRows, що й у ручному
|
||
// видаленні версій: шифротекст у core.secrets ніяким каскадом
|
||
// не прибирається (посилання SET NULL), а лежать у ньому саме
|
||
// мегабайти.
|
||
var cfgIDs []string
|
||
crows, err := tx.Query(ctx, `
|
||
SELECT id::text FROM ncm.configs WHERE tenant_id = $1 AND device_id = $2
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for crows.Next() {
|
||
var id string
|
||
if err := crows.Scan(&id); err != nil {
|
||
crows.Close()
|
||
return err
|
||
}
|
||
cfgIDs = append(cfgIDs, id)
|
||
}
|
||
crows.Close()
|
||
if err := crows.Err(); err != nil {
|
||
return err
|
||
}
|
||
if len(cfgIDs) > 0 {
|
||
n, freed, err := deleteConfigRows(ctx, tx, tenantID, cfgIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out.Configs, out.FreedBytes = n, freed
|
||
}
|
||
|
||
// Сповіщення й стан лінків. Обидві таблиці — гіпертаблиці, обидві
|
||
// тримають чужий id (alert_id, link_id), і обидва їхні джерела
|
||
// зникнуть каскадом за мить. Прибрати їх ПІСЛЯ видалення хоста
|
||
// було б уже нічим: переліку не лишилось би.
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM alr.notifications
|
||
WHERE tenant_id = $1 AND alert_id IN (
|
||
SELECT id FROM alr.alerts WHERE tenant_id = $1 AND device_id = $2)
|
||
`, tenantID, deviceID); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM ts.link_status
|
||
WHERE tenant_id = $1 AND link_id IN (
|
||
SELECT id FROM topo.links
|
||
WHERE tenant_id = $1
|
||
AND (a_device_id = $2::uuid OR b_device_id = $2::uuid))
|
||
`, tenantID, deviceID); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Сам хост. Далі каскад забирає решту двадцяти зв'язків.
|
||
tag, err := tx.Exec(ctx, `
|
||
DELETE FROM inv.devices WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
|
||
// Черга на видалення гілок — у ТІЙ САМІЙ транзакції, що й
|
||
// видалення хоста. Це і є відповідь на «що буде, якщо дзеркало
|
||
// недоступне»: хост зникає зараз, а гілка на тому кінці —
|
||
// коли Forgejo відповість. Рядок черги переживає і перезапуск
|
||
// процесу, і тижневу відмову дзеркала.
|
||
return enqueueRefDeletes(ctx, tx, tenantID, deviceID, name, branches)
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out.Branches = branches
|
||
|
||
// --- крок 4: локальна гілка ---
|
||
//
|
||
// Помилка тут не скасовує видалення: хоста вже немає, а гілка
|
||
// лишається в черзі й буде прибрана тим самим тактом, що розгрібає
|
||
// дзеркало. Тому лише журнал.
|
||
if s.git != nil {
|
||
for _, b := range branches {
|
||
if _, gerr := s.git.DeleteBranch(RepoName(tenantID), b); gerr != nil {
|
||
slog.Warn("git: локальну гілку не прибрано, лишається в черзі",
|
||
"branch", b, "device", name, "error", gerr)
|
||
}
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// purgeTelemetry прибирає рядки гіпертаблиці партіями.
|
||
//
|
||
// Партія відбирається не за кількістю, а за ЧАСОМ: спершу шукається
|
||
// позначка часу N-го найстарішого рядка, потім видаляється все до неї
|
||
// включно. Прямий `DELETE ... LIMIT` у Postgres не існує, а обхід через
|
||
// ctid на гіпертаблиці не працює — рядки лежать у шматках, і ctid
|
||
// унікальний лише всередині шматка. Час же є первинним ключем усіх цих
|
||
// таблиць, тож відбір по ньому потрапляє точно в шматок і в індекс.
|
||
//
|
||
// Кожна партія — своя транзакція. Перерваний процес лишає рівно ті
|
||
// партії, що встигли, і повтор доробляє решту: хост на цей момент уже
|
||
// архівований, тож нові рядки не додаються.
|
||
//
|
||
// args — необов'язкові додаткові параметри запиту, які починаються з $3.
|
||
func (s *Store) purgeTelemetry(ctx context.Context, tenantID, delSQL, pickSQL string,
|
||
series []int64, args ...any) (int64, error) {
|
||
|
||
var total int64
|
||
for {
|
||
var deleted int64
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// $1 — tenant або перелік рядів, $2 — розмір партії.
|
||
pickArgs := make([]any, 0, 2+len(args))
|
||
if series != nil {
|
||
pickArgs = append(pickArgs, series, telemetryBatch)
|
||
} else {
|
||
pickArgs = append(pickArgs, tenantID, telemetryBatch)
|
||
}
|
||
pickArgs = append(pickArgs, args...)
|
||
|
||
var hi *time.Time
|
||
if err := tx.QueryRow(ctx, pickSQL, pickArgs...).Scan(&hi); err != nil {
|
||
return err
|
||
}
|
||
if hi == nil {
|
||
return nil
|
||
}
|
||
|
||
delArgs := make([]any, 0, 2+len(args))
|
||
if series != nil {
|
||
delArgs = append(delArgs, series, *hi)
|
||
} else {
|
||
delArgs = append(delArgs, tenantID, *hi)
|
||
}
|
||
delArgs = append(delArgs, args...)
|
||
|
||
tag, err := tx.Exec(ctx, delSQL, delArgs...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
deleted = tag.RowsAffected()
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return total, err
|
||
}
|
||
total += deleted
|
||
if deleted == 0 {
|
||
return total, nil
|
||
}
|
||
}
|
||
}
|