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

487 lines
23 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 (
"fmt"
"math"
"sort"
"time"
)
// Розрахунок доступності за період: арифметика без бази.
//
// ЧОМУ ЦЕ ОКРЕМИЙ ФАЙЛ БЕЗ ЖОДНОГО ЗАПИТУ
//
// Усе, що робить звіт SLA спірним, — це не SQL, а домовленості: що таке
// «недоступний», куди подіти вікно обслуговування, як рахувати хост,
// заведений посеред кварталу. Перевіряти домовленості на живій базі
// означає перевіряти їх утричі дорожче й на порядок рідше — тобто не
// перевіряти. Тому весь спір винесено в чисті функції, а база лишається
// тим, чим має бути: постачальником рядків.
//
// ЩО ТАКЕ «НЕДОСТУПНИЙ» — ЧОТИРИ РІШЕННЯ, ЯКІ ТУТ ПРИЙНЯТО
//
// 1. Час, про який немає ЖОДНОГО виміру, не є ані доступністю, ані
// недоступністю. Він рахується окремо (UnknownSec) і не додається
// нікуди. Це головне рішення файлу.
//
// Обидві альтернативи гірші, і кожна по-своєму. Порахувати мовчання
// як «працювало» означає, що місяць із мертвим зондом дає 100% — а
// саме такий звіт і показують, не перевіряючи, бо він гарний.
// Порахувати мовчання як «лежало» означає, що годинна перерва в
// роботі САМОГО МОНІТОРИНГУ стає аварією в мережі клієнта, за яку
// хтось платить.
//
// Тому третій варіант: сказати вголос, скільки саме ми знаємо.
// CoveragePct — це частка періоду, підперта виміром, і поки вона
// нижча за поріг цілі, вердикт не виноситься взагалі: період
// позначається як «недостатньо даних». Зелений висновок із діри в
// даних тут неможливий за побудовою.
//
// 2. Вікно обслуговування з exclude_from_sla зупиняє годинник. Не
// «зараховується як доступність» — саме зупиняє: ці секунди виходять
// і з чисельника, і зі знаменника. Різниця видна на межі: двогодинні
// роботи, зараховані як uptime, ПОКРАЩИЛИ б місяць, у якому була
// аварія. Робота, про яку домовились заздалегідь, не має права
// покращувати звіт — лише не псувати його.
//
// 3. Зонд offline — це випадок 1, і він вирішується сам. Вимірів немає,
// годинних відер немає, час падає в UnknownSec. Окремої обробки не
// потрібно, і саме тому джерелом узято ts.icmp_1h, а не історію
// станів: історія станів пише рядок лише при ЗМІНІ, тож мовчання
// зонда вона зображує як незмінне «up».
//
// 4. Хост, вимкнений людиною (inv.devices.enabled = false), теж падає в
// UnknownSec — і це компроміс, який треба назвати вголос. Правильно
// було б зупинити годинник, як на обслуговуванні. Зробити цього не
// можна: історії поля enabled у продукті немає, тобто відповіді на
// питання «коли саме його вимкнули» не існує ніде. Вигадати її
// означало б підмішати здогад у документ для аудитора. Тому вимкнений
// хост псує ПОКРИТТЯ, а не доступність, і це видно в звіті числом.
// SLAPeriodKind — типи періодів, які вміє різати цей файл.
//
// Місяць і квартал — те, що просять на аудиті. Доба й тиждень коштували
// рівно один рядок у switch і потрібні для іншого: на них видно, що
// розрахунок узагалі працює, не чекаючи місяця.
const (
SLAPeriodDaily = "daily"
SLAPeriodWeekly = "weekly"
SLAPeriodMonthly = "monthly"
SLAPeriodQuarterly = "quarterly"
)
// slaBucketSec — довжина одного відра ts.icmp_1h.
//
// Година, і це стеля точності всього звіту: аварія на п'ять хвилин
// всередині години видна як частка втрачених спроб, а не як п'ять
// хвилин. Для «99.9% за квартал» (це 2 години 12 хвилин допустимого
// простою) такої точності досить; для «99.99%» — уже ні, і про це
// сказано людині на самій сторінці.
const slaBucketSec = 3600
// SLASpan — напіввідкритий відрізок часу [From, To).
//
// Напіввідкритий навмисно: сусідні періоди мають стикатися без щілини й
// без нахлесту, інакше секунда опівночі 1 липня потрапила б і в другий
// квартал, і в третій.
type SLASpan struct {
From time.Time
To time.Time
}
// SLAHourBucket — один рядок ts.icmp_1h, зведений до того, що потрібно
// доступності.
//
// Samples — скільки спроб зробили за годину, Down — скільки з них не
// відповіли. Саме ця пара й відрізняє «не відповідав» від «не питали»:
// відра, якого немає, немає, а не «нуль втрат».
type SLAHourBucket struct {
Start time.Time
Samples int64
Down int64
}
// SLAInput — усе, що потрібно, щоб порахувати один хост за один період.
type SLAInput struct {
// Межі періоду в абсолютному часі. Ріже їх SLAPeriodBounds у поясі
// цілі; далі пояс не потрібен — уся арифметика йде в секундах
// абсолютного часу. Наслідок, який варто розуміти: доба переходу на
// літній час має 23 або 25 годин, і місяць із нею довший чи коротший
// сам собою, без жодної окремої гілки.
From time.Time
To time.Time
// Коли хост з'явився і, якщо вже архівований, коли зник. Період
// звужується до їх перетину: хост, заведений 20 травня, не має
// «недоступності» за 119 травня — його не було.
CreatedAt time.Time
DeletedAt *time.Time
// Вікна обслуговування, які вже відібрано селектором. Можуть
// перетинатися — тут вони зводяться в непересічні.
Maintenance []SLASpan
// Годинні відра, що потрапляють у період. Порядок не важливий.
Buckets []SLAHourBucket
// Проти чого міряємо. Обидва числа беруться з цілі й кладуться в
// закритий період знімком: ціль живе далі й може змінитись.
TargetPct float64
MinCoveragePct float64
}
// SLAOutcome — розкладений період.
//
// Чотири часові величини взаємно виключні й у сумі дають ClockSec рівно.
// Це не педантизм: звіт, у якому частини не сходяться з цілим, читач
// перевіряє один раз, не сходить — і далі не вірить жодному числу.
type SLAOutcome struct {
ClockSec int64 `json:"clock_sec"`
MaintenanceSec int64 `json:"maintenance_sec"`
UpSec int64 `json:"up_sec"`
DownSec int64 `json:"downtime_sec"`
UnknownSec int64 `json:"unknown_sec"`
// UptimePct — з ВИМІРЯНОГО часу, а не з періоду. Знаменник —
// UpSec+DownSec; мовчання в нього не входить (див. рішення 1).
UptimePct float64 `json:"uptime_pct"`
// CoveragePct — яку частку годинника ми взагалі бачили.
CoveragePct float64 `json:"coverage_pct"`
// Incidents — скільки окремих смуг недоступності. Точність — година:
// це кількість непересічних серій годин, у яких була хоч одна
// невдала спроба. Дві аварії всередині однієї години рахуються як
// одна; годину без даних посеред аварії серія переживає, бо
// відсутність виміру не є доказом відновлення.
Incidents int `json:"incidents"`
// Insufficient — даних замало для вердикту. Тоді Breached завжди
// false, і це НЕ означає «виконано»: означає «не знаємо».
Insufficient bool `json:"insufficient"`
Breached bool `json:"breached"`
}
// ComputeSLA розкладає період одного хоста.
func ComputeSLA(in SLAInput) SLAOutcome {
var out SLAOutcome
// 1. Годинник: період, звужений життям хоста.
live, ok := slaClip(SLASpan{From: in.From, To: in.To}, slaLifetime(in))
if !ok {
// Хоста в цьому періоді не існувало. Нулі скрізь, вердикту
// немає. Показати тут «0% доступності» означало б винайти
// аварію з нічого.
out.Insufficient = true
return out
}
clock := slaSeconds(live)
out.ClockSec = int64(math.Round(clock))
// 2. Обслуговування: об'єднуємо перед відніманням.
//
// Об'єднання — не оптимізація. Два вікна 02:0004:00 і 03:0005:00
// дають три години зупиненого годинника, а сума тривалостей дала б
// чотири — і доступність вийшла б кращою, ніж є, тихо й на кожному
// звіті.
maint := slaMerge(slaClipAll(in.Maintenance, live))
billable := slaSubtract(live, maint)
billableF := slaSecondsAll(billable)
// 3. Відра, накладені на оплачуваний час.
var upF, downF, observedF float64
buckets := append([]SLAHourBucket(nil), in.Buckets...)
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Start.Before(buckets[j].Start) })
for _, b := range buckets {
if b.Samples <= 0 {
// Відро без жодної спроби — це те саме мовчання, лише
// записане рядком. Рахувати його як «втрат не було» —
// найтихіший спосіб зіпсувати звіт.
continue
}
ov := slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable)
if ov <= 0 {
continue
}
observedF += ov
// Втрати всередині години розподіляються рівномірно. Це
// припущення, і воно єдине можливе: у відрі немає позначки, коли
// саме впало. Похибка обмежена годиною й не накопичується —
// сума часток дає ту саму суму спроб.
d := ov * float64(b.Down) / float64(b.Samples)
downF += d
upF += ov - d
}
if observedF > billableF {
// Може статись на межі округлення чи якщо відро частково
// накрило вікно обслуговування. Виміряного часу не буває більше
// за оплачуваний.
observedF = billableF
}
// 4. Округлення так, щоб частини сходились із цілим.
//
// Спершу дві виміряні величини, далі мовчання добирається різницею,
// а обслуговування — залишком годинника. Порядок саме такий, бо
// підганяти під ціле треба найменш точну величину, а не ту, на яку
// дивиться клієнт.
out.UpSec = int64(math.Round(upF))
out.DownSec = int64(math.Round(downF))
billableSec := int64(math.Round(billableF))
if out.UpSec+out.DownSec > billableSec {
out.DownSec = billableSec - out.UpSec
if out.DownSec < 0 {
out.DownSec = 0
out.UpSec = billableSec
}
}
out.UnknownSec = billableSec - out.UpSec - out.DownSec
if out.UnknownSec < 0 {
out.UnknownSec = 0
}
out.MaintenanceSec = out.ClockSec - out.UpSec - out.DownSec - out.UnknownSec
if out.MaintenanceSec < 0 {
out.MaintenanceSec = 0
}
// 5. Висновки.
measured := out.UpSec + out.DownSec
if measured > 0 {
out.UptimePct = slaRound3(100 * float64(out.UpSec) / float64(measured))
}
if billableSec > 0 {
out.CoveragePct = slaRound3(100 * float64(measured) / float64(billableSec))
}
out.Incidents = slaIncidents(buckets, billable)
// Вердикт виноситься лише при достатньому покритті. Три способи
// його не мати: хоста не було, весь період — обслуговування, або
// вимірів менше за поріг цілі. У всіх трьох випадках єдина чесна
// відповідь — «не знаємо», і вона НЕ дорівнює «виконано».
out.Insufficient = billableSec == 0 || measured == 0 ||
out.CoveragePct < in.MinCoveragePct
if !out.Insufficient {
out.Breached = out.UptimePct < in.TargetPct
}
return out
}
// slaIncidents рахує смуги недоступності.
//
// Смуга починається на годині з невдалими спробами й закінчується на
// першій ПОВНІСТЮ вдалій годині. Година без даних смугу не закриває:
// відсутність виміру — не доказ того, що хост піднявся, і закривати нею
// аварію означало б розбивати одну добову аварію на десяток «інцидентів»
// щоразу, коли мовчав зонд.
func slaIncidents(buckets []SLAHourBucket, billable []SLASpan) int {
n := 0
inRun := false
for _, b := range buckets {
if b.Samples <= 0 {
continue
}
if slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable) <= 0 {
continue
}
if b.Down > 0 {
if !inRun {
n++
inRun = true
}
continue
}
inRun = false
}
return n
}
// slaLifetime — відрізок, у якому хост існував.
func slaLifetime(in SLAInput) SLASpan {
s := SLASpan{From: in.CreatedAt, To: in.To}
if in.DeletedAt != nil && in.DeletedAt.Before(in.To) {
s.To = *in.DeletedAt
}
// Хост без дати створення (теоретично неможливо, практично —
// відновлення з чужого дампа) не має звужувати період: краще
// порахувати весь, ніж мовчки нічого.
if s.From.IsZero() {
s.From = in.From
}
return s
}
// ---------------------------------------------------------------------
// Арифметика відрізків
// ---------------------------------------------------------------------
func slaSeconds(s SLASpan) float64 {
d := s.To.Sub(s.From).Seconds()
if d < 0 {
return 0
}
return d
}
func slaSecondsAll(list []SLASpan) float64 {
var t float64
for _, s := range list {
t += slaSeconds(s)
}
return t
}
// slaClip — перетин двох відрізків. ok=false, якщо перетину немає.
func slaClip(a, b SLASpan) (SLASpan, bool) {
out := SLASpan{From: a.From, To: a.To}
if b.From.After(out.From) {
out.From = b.From
}
if b.To.Before(out.To) {
out.To = b.To
}
if !out.From.Before(out.To) {
return SLASpan{}, false
}
return out, true
}
func slaClipAll(list []SLASpan, within SLASpan) []SLASpan {
out := make([]SLASpan, 0, len(list))
for _, s := range list {
if c, ok := slaClip(s, within); ok {
out = append(out, c)
}
}
return out
}
// slaMerge зводить перекриття в непересічні відрізки.
func slaMerge(list []SLASpan) []SLASpan {
if len(list) == 0 {
return nil
}
cp := append([]SLASpan(nil), list...)
sort.Slice(cp, func(i, j int) bool { return cp[i].From.Before(cp[j].From) })
out := []SLASpan{cp[0]}
for _, s := range cp[1:] {
last := &out[len(out)-1]
// Стик (s.From == last.To) теж об'єднуємо: два суміжні вікна —
// одна перерва, і показувати їх окремо немає кому.
if !s.From.After(last.To) {
if s.To.After(last.To) {
last.To = s.To
}
continue
}
out = append(out, s)
}
return out
}
// slaSubtract віднімає непересічні cut від base.
func slaSubtract(base SLASpan, cut []SLASpan) []SLASpan {
out := []SLASpan{base}
for _, c := range cut {
next := make([]SLASpan, 0, len(out)+1)
for _, s := range out {
ovl, ok := slaClip(s, c)
if !ok {
next = append(next, s)
continue
}
if s.From.Before(ovl.From) {
next = append(next, SLASpan{From: s.From, To: ovl.From})
}
if ovl.To.Before(s.To) {
next = append(next, SLASpan{From: ovl.To, To: s.To})
}
}
out = next
}
return out
}
func slaOverlapAll(s SLASpan, list []SLASpan) float64 {
var t float64
for _, b := range list {
if c, ok := slaClip(s, b); ok {
t += slaSeconds(c)
}
}
return t
}
func slaRound3(v float64) float64 {
return math.Round(v*1000) / 1000
}
// ---------------------------------------------------------------------
// Календарні межі
// ---------------------------------------------------------------------
// SLAPeriodBounds — межі періоду, у який потрапляє anchor.
//
// Ріже в поясі loc, а не в UTC. «Квартал» для клієнта в Києві — це три
// календарні місяці за київським календарем; зсунуті на дві-три години
// межі здаються дрібницею рівно доти, доки в цих годинах не станеться
// аварія, яка потрапить не в той квартал.
func SLAPeriodBounds(kind string, anchor time.Time, loc *time.Location) (SLASpan, error) {
if loc == nil {
loc = time.UTC
}
t := anchor.In(loc)
y, m, d := t.Date()
midnight := func(yy int, mm time.Month, dd int) time.Time {
return time.Date(yy, mm, dd, 0, 0, 0, 0, loc)
}
switch kind {
case SLAPeriodDaily:
f := midnight(y, m, d)
return SLASpan{From: f, To: f.AddDate(0, 0, 1)}, nil
case SLAPeriodWeekly:
// Понеділок — перший день тижня. time.Weekday рахує від неділі,
// тож зсув, а не віднімання: інакше кожна неділя опинялась би в
// наступному тижні.
shift := (int(t.Weekday()) + 6) % 7
f := midnight(y, m, d).AddDate(0, 0, -shift)
return SLASpan{From: f, To: f.AddDate(0, 0, 7)}, nil
case SLAPeriodMonthly:
f := midnight(y, m, 1)
return SLASpan{From: f, To: f.AddDate(0, 1, 0)}, nil
case SLAPeriodQuarterly:
f := midnight(y, time.Month((int(m)-1)/3*3+1), 1)
return SLASpan{From: f, To: f.AddDate(0, 3, 0)}, nil
}
return SLASpan{}, fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, kind)
}
// SLAPrevPeriod — межі періоду, що передує тому, у якому now.
//
// Потрібне рівно для закриття: закривати можна лише те, що вже
// скінчилось.
func SLAPrevPeriod(kind string, now time.Time, loc *time.Location) (SLASpan, error) {
cur, err := SLAPeriodBounds(kind, now, loc)
if err != nil {
return SLASpan{}, err
}
// Відступ усередину попереднього періоду, а не віднімання довжини:
// довжина місяця й кварталу різна, а секунда перед початком поточного
// гарантовано належить попередньому.
return SLAPeriodBounds(kind, cur.From.Add(-time.Second), loc)
}
// SLASettleLag — скільки чекати після кінця періоду перед закриттям.
//
// Шість годин. Причина в 0005: ts.icmp_1h рахується з ts.icmp_5m
// політикою з end_offset 1 година й розкладом раз на годину, а сама
// ts.icmp_5m — з відставанням у 5 хвилин. Тобто останні години періоду
// матеріалізуються не раніше ніж за дві години після його кінця.
//
// Шість замість двох — це запас на зупинений на ніч сервер і на
// відкладені фонові задачі TimescaleDB. Ціна запасу — звіт за минулий
// місяць стає остаточним об 06:00 першого числа, а не опівночі. Ціна
// його відсутності — закритий назавжди період із недорахованим хвостом,
// і виправити його можна лише руками.
const SLASettleLag = 6 * time.Hour