Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
310 lines
15 KiB
Go
310 lines
15 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Повернення хоста з архіву.
|
||
//
|
||
// Третя дія над хостом поруч із двома наявними: архівне видалення
|
||
// (BulkDeleteDevices) прибирає з переліків, повне (PurgeDevices) стирає
|
||
// назавжди, це — повертає. Досі її не було взагалі, і архівне видалення
|
||
// через це працювало як пастка: хост не видалений, місце займає, ряди
|
||
// метрик лишає, а дістатись до нього не можна нізвідки, крім SQL.
|
||
//
|
||
// ЧОМУ ЦЕ НЕ `deleted_at = NULL`
|
||
//
|
||
// Архівування вимикає хост і всі його увімкнені перевірки. Знявши саме
|
||
// лише deleted_at, ми повернули б у перелік хост, який мовчить: він на
|
||
// місці, графіки порожні, причину не видно ніде. Тому повернення знімає
|
||
// рівно те, що поставило архівування, — і спирається на дві колонки з
|
||
// 0062, у які архівування записує, що саме воно вимкнуло.
|
||
//
|
||
// ТРИ РЕЧІ, ЯКІ МОГЛИ ЗМІНИТИСЬ ЗА ЧАС В АРХІВІ
|
||
//
|
||
// Архів — не сейф: поки хост лежав прибраним, у кабінеті працювали далі.
|
||
// Дві унікальні умови бази стосуються саме живих рядків і мовчки
|
||
// зіткнулися б із поверненням:
|
||
//
|
||
// - devices_tenant_name_uniq (tenant_id, lower(name)) WHERE deleted_at
|
||
// IS NULL. За місяць в архіві хост із такою ж назвою могли завести
|
||
// заново. Ім'я — це те, чим хост називають уголос, і мовчки додати
|
||
// до нього « (2)» означало б повернути не той хост, який просили.
|
||
// Тому такий випадок — відмова саме по цьому хосту, зі словами, що
|
||
// робити далі; решта набору повертається.
|
||
// - devices_self_agent_uniq (self_agent_id) WHERE deleted_at IS NULL.
|
||
// Прив'язку «цей хост і є машина зонда» за цей час міг забрати інший
|
||
// живий хост. Тут навпаки: хост повертається, а прив'язка з нього
|
||
// знімається — і про це прямо сказано у відповіді. Різниця з іменем
|
||
// не в примсі: ім'я незамінне, а прив'язку людина ставить назад
|
||
// двома кліками, і відмовити через неї означало б лишити хост в
|
||
// архіві через налаштування, яке й так треба переглянути.
|
||
//
|
||
// Третє — сам зонд-опитувач (agent_id). Його чіпати не треба: якщо зонд
|
||
// видалили, ключ уже поставив NULL (ON DELETE SET NULL з 0003), і хост
|
||
// повернеться без опитувача. Вигадувати йому зонд ми не маємо права, а
|
||
// порожній зонд видно в переліку хостів окремою колонкою.
|
||
|
||
// RestoredDevice — хост, який повернувся, і в якому саме стані.
|
||
//
|
||
// Ці три поля — не звіт заради звіту. «Повернули» без них означає лише
|
||
// «рядок знову видно»: чи опитується хост, чи ввімкнені його перевірки
|
||
// й чи лишилась прив'язка — рівно ті питання, які людина поставить
|
||
// наступними, і відповіді на них є тільки тут.
|
||
type RestoredDevice struct {
|
||
DeviceID string `json:"device_id"`
|
||
Name string `json:"name"`
|
||
// Enabled — чи повернувся хост увімкненим. false означає одне з
|
||
// двох: або його вимкнули ще до архівування, або заархівували до
|
||
// появи 0062, і тоді відповіді просто немає.
|
||
Enabled bool `json:"enabled"`
|
||
// Checks — скільки перевірок увімкнено назад.
|
||
Checks int `json:"checks"`
|
||
// SelfAgentDropped — прив'язку «хост = машина зонда» знято, бо ту
|
||
// саму машину вже оголосив своєю інший живий хост.
|
||
SelfAgentDropped bool `json:"self_agent_dropped,omitempty"`
|
||
}
|
||
|
||
// RestoreFailure — хост, якого повернути не вдалось, і чому.
|
||
type RestoreFailure struct {
|
||
DeviceID string `json:"device_id"`
|
||
Name string `json:"name"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// RestoreResult — підсумок повернення.
|
||
type RestoreResult struct {
|
||
Restored []RestoredDevice `json:"restored"`
|
||
Failed []RestoreFailure `json:"failed,omitempty"`
|
||
// Checks — усього ввімкнено перевірок по всьому набору.
|
||
Checks int `json:"checks"`
|
||
}
|
||
|
||
// RestoreDevices повертає заархівовані хости в роботу.
|
||
//
|
||
// Стеля та сама, що й у решти масових дій: повернення саме по собі
|
||
// оборотне (хост завжди можна заархівувати знову), але воно вмикає
|
||
// опитування, а сорок хостів, які разом пішли в план колектора, — це
|
||
// вже навантаження, про яке людина мусить думати свідомо.
|
||
//
|
||
// Ліміт тарифу тут не перевіряється, і це не пропуск: тригер
|
||
// bill.assert_device_limit стоїть на INSERT, тобто рахує ПОЯВУ нових
|
||
// хостів. Повернутий хост не новий — він уже був у цьому кабінеті й уже
|
||
// був порахований, поки його не прибрали.
|
||
func (s *Store) RestoreDevices(ctx context.Context, tenantID string, sc Scope,
|
||
ids []string) (*RestoreResult, 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))
|
||
}
|
||
|
||
res := &RestoreResult{Restored: []RestoredDevice{}}
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Набір звужується проти бази ще раз, як і в решті масових дій:
|
||
// перевірка прав в обробнику відповідає на «чи можна було», а
|
||
// цей запит — на «чи хост ще в архіві». Заразом він відповідає
|
||
// на обидва питання про зіткнення — імені й прив'язки, — щоб не
|
||
// ловити їх помилкою унікального індексу вже після UPDATE.
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT d.id::text, d.name, COALESCE(d.archived_enabled, false),
|
||
COALESCE(d.self_agent_id::text, ''),
|
||
EXISTS (SELECT 1 FROM inv.devices o
|
||
WHERE o.tenant_id = d.tenant_id
|
||
AND o.deleted_at IS NULL
|
||
AND o.self_agent_id = d.self_agent_id),
|
||
EXISTS (SELECT 1 FROM inv.devices n
|
||
WHERE n.tenant_id = d.tenant_id
|
||
AND n.deleted_at IS NULL
|
||
AND lower(n.name) = lower(d.name))
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = $1 AND d.id = ANY($2::uuid[])
|
||
AND d.deleted_at IS NOT NULL
|
||
AND ($3::boolean OR d.id = ANY($4::uuid[]))
|
||
ORDER BY d.name
|
||
`, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var cands []restoreCandidate
|
||
for rows.Next() {
|
||
var c restoreCandidate
|
||
if err := rows.Scan(&c.id, &c.name, &c.enabled, &c.selfAgent,
|
||
&c.selfTaken, &c.nameTaken); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
cands = append(cands, c)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
if len(cands) == 0 {
|
||
return fmt.Errorf("%w: жоден з обраних хостів не в архіві "+
|
||
"або не доступний вам на запис", ErrInvalid)
|
||
}
|
||
|
||
plan := planRestore(cands)
|
||
res.Restored = plan.restored
|
||
res.Failed = plan.failed
|
||
back, unbind := plan.back, plan.unbind
|
||
if len(back) == 0 {
|
||
return nil
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices
|
||
SET deleted_at = NULL,
|
||
enabled = COALESCE(archived_enabled, false),
|
||
archived_enabled = NULL,
|
||
-- Стан обнуляється навмисно. Хост лежав в архіві й не
|
||
-- опитувався: те, що записано в status, — це те, як він
|
||
-- виглядав перед видаленням, і показати тритижневої
|
||
-- давнини «недоступний» як поточний стан означало б
|
||
-- збрехати ще до першого опитування.
|
||
status = 'unknown'::inv.device_status,
|
||
status_changed_at = now(),
|
||
self_agent_id = CASE WHEN id = ANY($3::uuid[])
|
||
THEN NULL ELSE self_agent_id END,
|
||
self_machine_id = CASE WHEN id = ANY($3::uuid[])
|
||
THEN NULL ELSE self_machine_id END
|
||
WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NOT NULL
|
||
`, tenantID, nonNilIDs(back), nonNilIDs(unbind)); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Вмикаються ЛИШЕ ті перевірки, які вимкнуло архівування.
|
||
//
|
||
// next_run_at зсувається на зараз, а не лишається тим, що було
|
||
// до архівування: інакше кожна повернута перевірка одразу
|
||
// виглядає як така, що запізнилась на три тижні, — і сторінка
|
||
// «Черги» чесно показує затор, якого немає.
|
||
crows, err := tx.Query(ctx, `
|
||
UPDATE core.checks
|
||
SET enabled = true, archived_off = false,
|
||
next_run_at = now(), updated_at = now()
|
||
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) AND archived_off
|
||
RETURNING device_id::text
|
||
`, tenantID, nonNilIDs(back))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
counts := map[string]int{}
|
||
for crows.Next() {
|
||
var id string
|
||
if err := crows.Scan(&id); err != nil {
|
||
crows.Close()
|
||
return err
|
||
}
|
||
counts[id]++
|
||
}
|
||
crows.Close()
|
||
if err := crows.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
for i := range res.Restored {
|
||
n := counts[res.Restored[i].DeviceID]
|
||
res.Restored[i].Checks = n
|
||
res.Checks += n
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(res.Restored) == 0 {
|
||
// Сюди доходить лише набір, у якому кожен хост уперся в зайняту
|
||
// назву. Помилкою, а не порожнім успіхом: людина натиснула
|
||
// «повернути» й мусить дізнатись, чому нічого не повернулось.
|
||
return res, fmt.Errorf("%w: %s", ErrInvalid, res.Failed[0].Error)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Рішення про набір
|
||
// ---------------------------------------------------------------------
|
||
|
||
// restoreCandidate — прибраний хост таким, яким його бачить база перед
|
||
// поверненням: разом із відповіддю на обидва питання про зіткнення.
|
||
type restoreCandidate struct {
|
||
id, name string
|
||
enabled bool
|
||
selfAgent string
|
||
// selfTaken — ту саму машину зонда вже оголосив своєю живий хост.
|
||
selfTaken bool
|
||
// nameTaken — ім'я вже носить живий хост.
|
||
nameTaken bool
|
||
}
|
||
|
||
// restorePlan — що саме робити з набором.
|
||
type restorePlan struct {
|
||
back []string
|
||
unbind []string
|
||
restored []RestoredDevice
|
||
failed []RestoreFailure
|
||
}
|
||
|
||
// planRestore розкладає набір на «повернути», «повернути без прив'язки»
|
||
// і «відмовити».
|
||
//
|
||
// Винесено з транзакції окремою чистою функцією не заради краси. Уся
|
||
// нетривіальність повернення саме тут, і перевірити її інакше нічим:
|
||
// зіткнення імен і прив'язок у базі відтворюється лише повним стендом
|
||
// із двома хостами, майданчиком і зондом, а помилка тут коштує
|
||
// унікального індексу посеред UPDATE — тобто відмови всьому набору
|
||
// замість одного хоста.
|
||
//
|
||
// Сусід по набору важить не менше за живий хост. Два прибрані хости
|
||
// могли мати однакове ім'я або вказувати на ту саму машину зонда: поки
|
||
// обидва лежали в архіві, часткові унікальні індекси їх не бачили
|
||
// (обидва — `WHERE deleted_at IS NULL`), а після повернення побачать
|
||
// одразу. Тому зайняте накопичується під час обходу, а порядок обходу —
|
||
// той, у якому набір прийшов із бази (за іменем): перший претендент
|
||
// лишає собі, решта отримує відмову або зняту прив'язку.
|
||
func planRestore(cands []restoreCandidate) restorePlan {
|
||
var p restorePlan
|
||
p.restored = []RestoredDevice{}
|
||
|
||
agents := map[string]bool{}
|
||
names := map[string]bool{}
|
||
|
||
for _, c := range cands {
|
||
// Порівняння без регістру — те саме, що в devices_tenant_name_uniq
|
||
// (lower(name)). Інше порівняння тут означало б, що код вважає
|
||
// набір справним, а база — ні.
|
||
key := strings.ToLower(c.name)
|
||
if c.nameTaken || names[key] {
|
||
p.failed = append(p.failed, RestoreFailure{
|
||
DeviceID: c.id, Name: c.name,
|
||
Error: "у переліку вже є хост із такою назвою — " +
|
||
"перейменуйте його або цей і поверніть ще раз",
|
||
})
|
||
continue
|
||
}
|
||
names[key] = true
|
||
|
||
r := RestoredDevice{DeviceID: c.id, Name: c.name, Enabled: c.enabled}
|
||
if c.selfAgent != "" {
|
||
if c.selfTaken || agents[c.selfAgent] {
|
||
r.SelfAgentDropped = true
|
||
p.unbind = append(p.unbind, c.id)
|
||
} else {
|
||
agents[c.selfAgent] = true
|
||
}
|
||
}
|
||
p.back = append(p.back, c.id)
|
||
p.restored = append(p.restored, r)
|
||
}
|
||
return p
|
||
}
|