Netpulse_SasS/server/internal/store/sla_compute.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

538 lines
20 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"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Живий розрахунок і закриття періоду.
//
// ЧОМУ ЗАКРИТИЙ ПЕРІОД НЕ ПЕРЕРАХОВУЮТЬ
//
// Звіт про доступність — це документ, який показують назовні: клієнту за
// договором, аудитору на перевірці. У такого документа є одна властивість,
// без якої він не документ: два роздруки того самого кварталу, зроблені в
// різні дні, мають збігатися.
//
// Розрахунок «на льоту» цієї властивості не має й мати не може. Під ним
// лежать дані зі строком зберігання, вікна обслуговування, які хтось
// може дозаповнити заднім числом, перелік хостів, який змінюється щодня,
// і сама ціль, яку можна відредагувати. Кожна з цих речей рухається
// незалежно від нас, і жодна не питає дозволу.
//
// Тому щойно період скінчився й згортки під ним устоялись (SLASettleLag),
// він рахується РАЗ і лягає в core.sla_periods разом зі знімками умов:
// обіцяний відсоток, поріг покриття, пояс, ім'я хоста. Далі це вже не
// дані, а висновок, і читається він як висновок. Перерахувати можна —
// але лише свідомо, і кожен перерахунок видно в revision самого рядка.
// slaBackfillPeriods — скільки закритих періодів назад добирає фоновий
// такт.
//
// Три. Не «усі»: інсталяція, яку підняли після року простою, інакше
// порахувала б дванадцять місяців по даних, яких за той рік уже немає, і
// закрила б їх назавжди порожніми. Три періоди назад покривають звичайну
// причину пропуску — сервер стояв вихідні — і не дотягуються туди, де
// закривати вже нема чого.
const slaBackfillPeriods = 3
// slaDevice — хост і межі його життя.
type slaDevice struct {
ID string
Name string
CreatedAt time.Time
DeletedAt *time.Time
}
// slaSelectDevices — хости цілі, які ІСНУВАЛИ хоч частину періоду.
//
// М'яко видалені сюди входять навмисно. Хост, який прибрали з переліку
// 20 травня, у травні працював, і звіт за травень без нього показав би
// кращу картину, ніж була, — просто тому, що найгірший учасник зник зі
// списку разом із причиною, через яку його прибрали.
func (s *Store) slaSelectDevices(ctx context.Context, tx pgx.Tx, tenantID string,
sel Selector, span SLASpan) ([]slaDevice, error) {
a := &args{}
pTenant := a.add(tenantID)
pFrom := a.add(span.From)
pTo := a.add(span.To)
where, err := s.selectorSQL(sel, a, "d.id")
if err != nil {
return nil, err
}
q := fmt.Sprintf(`
SELECT d.id::text, d.name, d.created_at, d.deleted_at
FROM inv.devices d
WHERE d.tenant_id = %s
AND d.created_at < %s
AND (d.deleted_at IS NULL OR d.deleted_at > %s)
%s
ORDER BY d.name
`, pTenant, pTo, pFrom, where)
rows, err := tx.Query(ctx, q, a.vals...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []slaDevice
for rows.Next() {
var d slaDevice
if err := rows.Scan(&d.ID, &d.Name, &d.CreatedAt, &d.DeletedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// slaBuckets — годинні відра всіх хостів за період, одним запитом.
//
// Один запит, а не по хосту: квартал на 500 хостів — це близько мільйона
// відер, і мільйон запитів по одному ряду перетворив би звіт на
// півгодинну операцію. Предикат tenant_id тут ОБОВ'ЯЗКОВИЙ і не
// дублюється RLS: 0011 не вмикає політик на гіпертаблицях і безперервних
// агрегатах, тобто другого рубежу для ts.icmp_1h не існує.
func (s *Store) slaBuckets(ctx context.Context, tx pgx.Tx, tenantID string,
deviceIDs []string, span SLASpan) (map[string][]SLAHourBucket, error) {
out := map[string][]SLAHourBucket{}
if len(deviceIDs) == 0 {
return out, nil
}
rows, err := tx.Query(ctx, `
SELECT device_id::text, bucket, samples, down_samples
FROM ts.icmp_1h
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[])
AND bucket >= $3 AND bucket < $4
ORDER BY device_id, bucket
`, tenantID, deviceIDs, span.From, span.To)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id string
var b SLAHourBucket
if err := rows.Scan(&id, &b.Start, &b.Samples, &b.Down); err != nil {
return nil, err
}
out[id] = append(out[id], b)
}
return out, rows.Err()
}
// slaMaintenance — вікна обслуговування, розкладені по хостах.
//
// Селектор вікна розбирається тим самим selectorSQL, що й придушення
// алертів. Розійтись їм не можна: вікно, під яким алерт придушено, а SLA
// зіпсовано, — це найгірший із можливих станів, бо про нього дізнаються
// не тоді, коли він стався, а через квартал, з чужої претензії.
func (s *Store) slaMaintenance(ctx context.Context, tx pgx.Tx, tenantID string,
devices []slaDevice, span SLASpan) (map[string][]SLASpan, bool, error) {
out := map[string][]SLASpan{}
rows, err := tx.Query(ctx, `
SELECT COALESCE(selector::text, '{}'), lower(period), upper(period),
(rrule IS NOT NULL)
FROM alr.maintenance_windows
WHERE tenant_id = $1 AND exclude_from_sla
AND period && tstzrange($2, $3, '[)')
`, tenantID, span.From, span.To)
if err != nil {
return nil, false, err
}
type window struct {
sel Selector
empty bool
from, to time.Time
}
var windows []window
hasRRule := false
for rows.Next() {
var raw string
var from, to *time.Time
var rr bool
if err := rows.Scan(&raw, &from, &to, &rr); err != nil {
rows.Close()
return nil, false, err
}
var w window
if err := json.Unmarshal([]byte(raw), &w.sel); err != nil {
// Вікно з нерозбірливим селектором ігнорувати не можна: воно
// б мовчки перетворилось на простій. Трактуємо як «весь
// кабінет» — бік помилки, що не псує звіт клієнту.
w.sel = Selector{}
}
// Вікно без селектора накриває весь кабінет: так виглядає
// «вимикаємо все на 30 хвилин». Та сама умова, що й у
// LoadSuppression.
w.empty = len(w.sel.DeviceIDs) == 0 && len(w.sel.GroupIDs) == 0 &&
len(w.sel.SiteIDs) == 0 && len(w.sel.Tags) == 0 &&
len(w.sel.Kinds) == 0 && len(w.sel.Vendors) == 0 &&
len(w.sel.TemplateIDs) == 0
// Незамкнений діапазон ('[2026-01-01,)') означає «і далі»;
// обрізаємо періодом, а не відкидаємо вікно.
w.from, w.to = span.From, span.To
if from != nil && from.After(w.from) {
w.from = *from
}
if to != nil && to.Before(w.to) {
w.to = *to
}
if rr {
hasRRule = true
}
windows = append(windows, w)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, false, err
}
if len(windows) == 0 {
return out, false, nil
}
// Порожній селектор накриває всіх — резолвити нічого.
inSet := map[string]bool{}
for _, d := range devices {
inSet[d.ID] = true
}
for _, w := range windows {
if w.empty {
for _, d := range devices {
out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to})
}
continue
}
ids, err := s.slaSelectDevices(ctx, tx, tenantID, w.sel, span)
if err != nil {
return nil, false, err
}
for _, d := range ids {
if !inSet[d.ID] {
continue
}
out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to})
}
}
return out, hasRRule, nil
}
// slaComputeRows — живий розрахунок цілі за період.
func (s *Store) slaComputeRows(ctx context.Context, tx pgx.Tx, tenantID string,
t SLATarget, span SLASpan) ([]SLARow, []string, error) {
devices, err := s.slaSelectDevices(ctx, tx, tenantID, t.Selector, span)
if err != nil {
return nil, nil, err
}
ids := make([]string, 0, len(devices))
for _, d := range devices {
ids = append(ids, d.ID)
}
buckets, err := s.slaBuckets(ctx, tx, tenantID, ids, span)
if err != nil {
return nil, nil, err
}
maint, hasRRule, err := s.slaMaintenance(ctx, tx, tenantID, devices, span)
if err != nil {
return nil, nil, err
}
var warn []string
if hasRRule {
warn = append(warn, SLAWarnRRule)
}
rows := make([]SLARow, 0, len(devices))
for _, d := range devices {
r := SLARow{DeviceID: d.ID, DeviceName: d.Name, Warnings: []string{}}
r.SLAOutcome = ComputeSLA(SLAInput{
From: span.From,
To: span.To,
CreatedAt: d.CreatedAt,
DeletedAt: d.DeletedAt,
Maintenance: maint[d.ID],
Buckets: buckets[d.ID],
TargetPct: t.TargetPct,
MinCoveragePct: t.MinCoveragePct,
})
if hasRRule && len(maint[d.ID]) > 0 {
r.Warnings = append(r.Warnings, SLAWarnRRule)
}
rows = append(rows, r)
}
return rows, warn, nil
}
// ---------------------------------------------------------------------
// Закриття
// ---------------------------------------------------------------------
// ErrSLANotSettled — період ще не можна закривати.
//
// Окрема помилка, а не загальний ErrInvalid: клієнт має показати не
// «некоректний запит», а причину — «зачекайте, згортки під періодом ще
// рахуються». Ці два тексти ведуть людину в різні боки.
var ErrSLANotSettled = fmt.Errorf("%w: період ще не завершився або згортки під ним не встоялись", ErrInvalid)
// CloseSLAPeriod рахує період один раз і зберігає як факт.
//
// Ідемпотентний: повторний виклик на вже закритому періоді нічого не
// переписує й повертає збережене. Саме через це фоновий такт може
// сміливо ходити по тих самих періодах щогодини.
//
// force переписує закрите — і це єдиний шлях це зробити. Він піднімає
// revision, тобто лишає слід у самому звіті: два роздруки того самого
// кварталу з різними числами інакше неможливо розрізнити.
func (s *Store) CloseSLAPeriod(ctx context.Context, tenantID, targetID string,
anchor time.Time, force bool) (SLAReport, error) {
var rep SLAReport
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
t, err := s.slaTarget(ctx, tx, tenantID, targetID)
if err != nil {
return err
}
rep, err = s.slaCloseTx(ctx, tx, tenantID, t, anchor, force)
return err
})
return rep, err
}
func (s *Store) slaCloseTx(ctx context.Context, tx pgx.Tx, tenantID string,
t SLATarget, anchor time.Time, force bool) (SLAReport, error) {
loc, tzOK := t.Location()
span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc)
if err != nil {
return SLAReport{}, err
}
// Раніше строку не закриваємо НІКОЛИ, навіть на вимогу.
//
// force дозволяє переписати вже пораховане, а не порахувати
// недорахованим. Різниця вирішальна: закритий на добу раніше
// квартал недорахував би останні години назавжди, і жодна кнопка
// цього вже не помітила б.
if time.Now().Before(span.To.Add(SLASettleLag)) {
return SLAReport{}, ErrSLANotSettled
}
stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc)
if err != nil {
return SLAReport{}, err
}
if stored.Closed && !force {
rep, err := s.slaReportTx(ctx, tx, tenantID, t, anchor)
return rep, err
}
rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span)
if err != nil {
return SLAReport{}, err
}
if !tzOK {
warn = slaAppendUnique(warn, SLAWarnUnknownTZ)
}
if len(t.BusinessHours) > 0 {
warn = slaAppendUnique(warn, SLAWarnBusinessHours)
}
horizon, err := s.slaHorizon(ctx, tx, tenantID)
if err != nil {
return SLAReport{}, err
}
if slaBeyondHorizon(horizon, span.From) {
// Найважливіше попередження з усіх: частина періоду не має даних
// не тому, що їх не збирали, а тому, що їх уже видалили. Воно
// вмерзає в рядок разом із числом — інакше через рік звіт
// виглядав би як звичайна недостатність покриття.
warn = slaAppendUnique(warn, SLAWarnBeyondHorizon)
}
revision := 1
if stored.Closed {
revision = stored.Revision + 1
// Дозвіл на правку закритого — рівно на цю транзакцію.
if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil {
return SLAReport{}, err
}
}
from := span.From.In(loc).Format("2006-01-02")
to := span.To.In(loc).Format("2006-01-02")
// Повне перезаписування, а не UPSERT по хостах: за час між
// розрахунками хост міг випасти з селектора, і залишений рядок
// показував би в звіті когось, кого ціль уже не стосується.
if _, err := tx.Exec(ctx, `
DELETE FROM core.sla_periods
WHERE tenant_id = $1 AND sla_target_id = $2
AND period = daterange($3::date, $4::date)
`, tenantID, t.ID, from, to); err != nil {
return SLAReport{}, err
}
for _, r := range rows {
w := slaAppendUnique(append([]string(nil), r.Warnings...), warn...)
raw, _ := json.Marshal(w)
if _, err := tx.Exec(ctx, `
INSERT INTO core.sla_periods
(tenant_id, sla_target_id, device_id, device_name, period,
uptime_pct, downtime_sec, maintenance_sec, up_sec, unknown_sec,
clock_sec, coverage_pct, incidents, breached,
target_pct, min_coverage_pct, tz, source, closed, revision,
warnings, computed_at)
VALUES ($1, $2, $3, $4, daterange($5::date, $6::date),
$7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, 'icmp_1h', true, $19, $20::jsonb, now())
`, tenantID, t.ID, r.DeviceID, r.DeviceName, from, to,
r.UptimePct, r.DownSec, r.MaintenanceSec, r.UpSec, r.UnknownSec,
r.ClockSec, r.CoveragePct, r.Incidents, r.Breached,
t.TargetPct, t.MinCoveragePct, t.TZ, revision, string(raw)); err != nil {
return SLAReport{}, err
}
}
now := time.Now()
return SLAReport{
Target: t,
From: span.From,
To: span.To,
PeriodFrom: from,
PeriodTo: to,
Closed: true,
Closable: true,
ComputedAt: &now,
Revision: revision,
Rows: rows,
Horizon: horizon,
Warnings: warn,
Totals: slaTotals(rows, t.TargetPct, t.MinCoveragePct),
}, nil
}
// SLAClosed — скільки періодів закрив прохід і за якими цілями.
type SLAClosed struct {
TenantID string
TargetID string
Name string
Period string
}
// CloseDueSLAPeriods закриває все, що вже можна закрити.
//
// Фоновий такт, а не «порахуємо, коли відкриють сторінку». Причина
// проста: періоду, який ніхто не відкрив вчасно, дані під ним не
// дочекаються. Квартал, уперше відкритий через півроку, закрився б
// назавжди по тому, що від нього лишилось, — і саме цей рядок відрізняє
// звіт, який можна показати, від звіту, який можна лише пояснити.
func (s *Store) CloseDueSLAPeriods(ctx context.Context) ([]SLAClosed, error) {
tenants, err := s.TenantIDs(ctx)
if err != nil {
return nil, err
}
var done []SLAClosed
for _, tenantID := range tenants {
targets, err := s.slaEnabledTargets(ctx, tenantID)
if err != nil {
return done, err
}
for _, t := range targets {
loc, _ := t.Location()
// Від найстарішого до найсвіжішого: якщо на середині щось
// упаде, уже закрите лишиться закритим, а наступний тік
// продовжить з того ж місця.
for back := slaBackfillPeriods; back >= 1; back-- {
span, err := slaPeriodBack(t.PeriodKind, time.Now(), loc, back)
if err != nil {
break
}
if time.Now().Before(span.To.Add(SLASettleLag)) {
continue
}
closed, err := s.slaCloseIfMissing(ctx, tenantID, t, span)
if err != nil {
return done, err
}
if closed {
done = append(done, SLAClosed{
TenantID: tenantID, TargetID: t.ID, Name: t.Name,
Period: span.From.In(loc).Format("2006-01-02"),
})
}
}
}
}
return done, nil
}
// slaCloseIfMissing — окрема транзакція на кожен період.
//
// Не одна на все: закриття кварталу на 500 хостів — це 500 вставок, і
// тримати їх в одній транзакції з усіма іншими цілями означало б, що
// збій на останній скасовує роботу за всі попередні.
func (s *Store) slaCloseIfMissing(ctx context.Context, tenantID string, t SLATarget, span SLASpan) (bool, error) {
loc, _ := t.Location()
closed := false
err := s.InTenantTxBG(ctx, tenantID, func(tx pgx.Tx) error {
var n int
if err := tx.QueryRow(ctx, `
SELECT count(*)::int FROM core.sla_periods
WHERE tenant_id = $1 AND sla_target_id = $2
AND period = daterange($3::date, $4::date) AND closed
`, tenantID, t.ID, span.From.In(loc).Format("2006-01-02"),
span.To.In(loc).Format("2006-01-02")).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
// anchor — будь-яка мить усередині періоду; беремо початок.
if _, err := s.slaCloseTx(ctx, tx, tenantID, t, span.From, false); err != nil {
return err
}
closed = true
return nil
})
return closed, err
}
func (s *Store) slaEnabledTargets(ctx context.Context, tenantID string) ([]SLATarget, error) {
all, err := s.SLATargets(ctx, tenantID)
if err != nil {
return nil, err
}
out := all[:0]
for _, t := range all {
if t.Enabled {
out = append(out, t)
}
}
return out, nil
}
// slaPeriodBack — межі періоду, що передує поточному на back кроків.
func slaPeriodBack(kind string, now time.Time, loc *time.Location, back int) (SLASpan, error) {
span, err := SLAPeriodBounds(kind, now, loc)
if err != nil {
return SLASpan{}, err
}
for i := 0; i < back; i++ {
// Секунда перед початком періоду завжди належить попередньому —
// незалежно від того, скільки в ньому діб. Віднімання фіксованої
// довжини тут дало б збій на лютому й на переході часу.
span, err = SLAPeriodBounds(kind, span.From.Add(-time.Second), loc)
if err != nil {
return SLASpan{}, err
}
}
return span, nil
}