Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.
РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.
Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.
ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:
1. Перше збереження будь-якого вбудованого правила стирало результати.
Селектор порівнювався в базі, але порівнювались різні представлення
одного значення: міграція кладе {}, Go марширує сім ключів із null.
Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
файл відкриває аудитор. Одне місце екранування на всі три звіти:
дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
рахувались як чинні. Три заслони: фільтр у списку, прибирання при
прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
254 lines
9.7 KiB
Go
254 lines
9.7 KiB
Go
package store
|
||
|
||
import (
|
||
"encoding/csv"
|
||
"fmt"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Вивантаження звіту SLA.
|
||
//
|
||
// ЧОМУ CSV ЗБИРАЄ СЕРВЕР, А НЕ БРАУЗЕР
|
||
//
|
||
// Спокуса зібрати його в TypeScript велика: рядки вже на екрані,
|
||
// лишається склеїти. Але саме цей файл кладуть у папку до договору, і
|
||
// саме його потім звіряють із тим, що показує сторінка. Дві реалізації
|
||
// одного округлення розійдуться на третьому знаку — і розбіжність
|
||
// побачить не той, хто її зробив, а клієнт.
|
||
//
|
||
// ЧОМУ КРАПКА, А НЕ КОМА, У ДРОБАХ
|
||
//
|
||
// Український Excel читає крапку як текст, і 99.912 стає рядком.
|
||
// Спокусливо писати кому — і тоді той самий файл ламається в усьому
|
||
// іншому: у csv кома вже є роздільником полів, у Python, jq, Grafana й
|
||
// будь-якому імпортері кома в числі — це помилка розбору.
|
||
//
|
||
// Тому крапка, а різницю бере на себе перший рядок файла: заголовок
|
||
// sep=; змушує Excel читати роздільником крапку з комою, і тоді числа з
|
||
// крапкою вже нікуди не з'їжджають. Це не наша вигадка, а домовленість
|
||
// самого Excel; решта світу цей рядок пропускає як коментар.
|
||
|
||
// SLAReportCSV пише звіт у потік.
|
||
//
|
||
// Один рядок на хост плюс підсумковий. Підсумок саме в тому ж файлі, а
|
||
// не окремим: людина, яка відкрила вивантаження, шукає в ньому головне
|
||
// число, і змушувати її додавати стовпчик руками означає віддати їй
|
||
// шанс помилитись у тому, заради чого файл і робився.
|
||
func SLAReportCSV(w io.Writer, rep SLAReport) error {
|
||
// BOM: без нього український Excel читає UTF-8 як cp1251, і всі
|
||
// назви хостів перетворюються на кракозябри. Решта інструментів BOM
|
||
// пропускає.
|
||
if _, err := io.WriteString(w, "\ufeff"); err != nil {
|
||
return err
|
||
}
|
||
if _, err := io.WriteString(w, "sep=;\r\n"); err != nil {
|
||
return err
|
||
}
|
||
|
||
raw := csv.NewWriter(w)
|
||
raw.Comma = ';'
|
||
// CRLF — те, чого чекає Excel. Для решти світу різниці немає.
|
||
raw.UseCRLF = true
|
||
// Через safeCSV: назва цілі й назви хостів приходять від людини, а
|
||
// клітинка, що починається з `=`, для Excel не текст, а формула.
|
||
// Пояснення повністю — у csv_safe.go; тут навмисно немає власного
|
||
// екранування, бо третій його примірник розійшовся б із двома
|
||
// іншими.
|
||
cw := newSafeCSV(raw)
|
||
|
||
// Шапка з умовами звіту. Вона й є те, що робить файл документом:
|
||
// без неї «99.912» не відповідає на питання «за що і проти чого».
|
||
head := [][]string{
|
||
{"Ціль", rep.Target.Name},
|
||
{"Період", rep.PeriodFrom + " — " + rep.PeriodTo},
|
||
{"Тип періоду", slaPeriodLabel(rep.Target.PeriodKind)},
|
||
{"Часовий пояс", rep.Target.TZ},
|
||
{"Ціль доступності, %", slaNum(rep.Target.TargetPct)},
|
||
{"Поріг покриття, %", slaNum(rep.Target.MinCoveragePct)},
|
||
{"Джерело даних", rep.Horizon.Relation},
|
||
{"Стан звіту", slaStateLabel(rep)},
|
||
}
|
||
if rep.ComputedAt != nil {
|
||
head = append(head, []string{"Розраховано", rep.ComputedAt.Format(time.RFC3339)})
|
||
}
|
||
if rep.Revision > 1 {
|
||
head = append(head, []string{"Перерахунків", strconv.Itoa(rep.Revision)})
|
||
}
|
||
// Строк зберігання джерела — у шапці, а не в примітці. Це та
|
||
// властивість, через яку звіт узагалі може змінитись, і читач має
|
||
// побачити її поруч із числами.
|
||
head = append(head, []string{"Строк зберігання джерела", slaKeepLabel(rep.Horizon.KeepDays)})
|
||
for _, code := range rep.Warnings {
|
||
head = append(head, []string{"Застереження", SLAWarningLabel(code)})
|
||
}
|
||
for _, row := range head {
|
||
if err := cw.Write(row); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := cw.Write(nil); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := cw.Write([]string{
|
||
"Хост",
|
||
"Доступність, %",
|
||
"Покриття, %",
|
||
"Вердикт",
|
||
"Простій, с",
|
||
"Доступно, с",
|
||
"Немає даних, с",
|
||
"Обслуговування, с",
|
||
"Тривалість періоду, с",
|
||
"Смуг недоступності",
|
||
"Застереження",
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, r := range rep.Rows {
|
||
if err := cw.Write(slaCSVRow(r.DeviceName, r.SLAOutcome, r.Warnings)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := cw.Write(slaCSVRow("РАЗОМ", rep.Totals, nil)); err != nil {
|
||
return err
|
||
}
|
||
|
||
cw.Flush()
|
||
return cw.Error()
|
||
}
|
||
|
||
func slaCSVRow(name string, o SLAOutcome, warnings []string) []string {
|
||
// Доступність порожня, а не «0», коли її не виміряли. Нуль у цій
|
||
// клітинці читається як «лежало весь період» — рівно навпаки до
|
||
// того, що сталось насправді.
|
||
uptime := ""
|
||
if !o.Insufficient {
|
||
uptime = slaNum(o.UptimePct)
|
||
}
|
||
labels := make([]string, 0, len(warnings))
|
||
for _, c := range warnings {
|
||
labels = append(labels, SLAWarningLabel(c))
|
||
}
|
||
return []string{
|
||
name,
|
||
uptime,
|
||
slaNum(o.CoveragePct),
|
||
slaVerdict(o),
|
||
strconv.FormatInt(o.DownSec, 10),
|
||
strconv.FormatInt(o.UpSec, 10),
|
||
strconv.FormatInt(o.UnknownSec, 10),
|
||
strconv.FormatInt(o.MaintenanceSec, 10),
|
||
strconv.FormatInt(o.ClockSec, 10),
|
||
strconv.Itoa(o.Incidents),
|
||
strings.Join(labels, "; "),
|
||
}
|
||
}
|
||
|
||
// slaVerdict — три стани, а не два.
|
||
//
|
||
// «Недостатньо даних» мусить бути окремим словом. Звести його до
|
||
// «виконано» означає збрехати, до «порушено» — звинуватити мережу в
|
||
// тому, що зламався моніторинг.
|
||
func slaVerdict(o SLAOutcome) string {
|
||
switch {
|
||
case o.Insufficient:
|
||
return "недостатньо даних"
|
||
case o.Breached:
|
||
return "порушено"
|
||
default:
|
||
return "виконано"
|
||
}
|
||
}
|
||
|
||
// SLAVerdict — той самий висновок для інших форматів.
|
||
func SLAVerdict(o SLAOutcome) string { return slaVerdict(o) }
|
||
|
||
func slaStateLabel(rep SLAReport) string {
|
||
if rep.Closed {
|
||
return "закритий період (перерахунку не підлягає)"
|
||
}
|
||
if rep.Closable {
|
||
return "попередній розрахунок; період можна закрити"
|
||
}
|
||
return "попередній розрахунок; період ще триває"
|
||
}
|
||
|
||
func slaKeepLabel(days *int) string {
|
||
if days == nil {
|
||
return "без строку"
|
||
}
|
||
return strconv.Itoa(*days) + " діб"
|
||
}
|
||
|
||
func slaPeriodLabel(kind string) string {
|
||
switch kind {
|
||
case SLAPeriodDaily:
|
||
return "доба"
|
||
case SLAPeriodWeekly:
|
||
return "тиждень"
|
||
case SLAPeriodMonthly:
|
||
return "місяць"
|
||
case SLAPeriodQuarterly:
|
||
return "квартал"
|
||
}
|
||
return kind
|
||
}
|
||
|
||
// SLAPeriodLabel — назва типу періоду для інтерфейсу й журналу.
|
||
func SLAPeriodLabel(kind string) string { return slaPeriodLabel(kind) }
|
||
|
||
// slaNum — число з трьома знаками й крапкою, без хвостових нулів.
|
||
//
|
||
// Без хвостових нулів навмисно: «100» і «100.000» в одному стовпчику
|
||
// читаються як різні за точністю виміри, хоч це те саме число.
|
||
func slaNum(v float64) string {
|
||
s := strconv.FormatFloat(v, 'f', 3, 64)
|
||
s = strings.TrimRight(s, "0")
|
||
s = strings.TrimSuffix(s, ".")
|
||
if s == "" || s == "-" {
|
||
return "0"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// SLAReportFileName — ім'я файла вивантаження.
|
||
//
|
||
// Назва цілі в імені навмисно: у папці «Договори» лежатиме десяток таких
|
||
// файлів, і «sla.csv» серед них не означає нічого.
|
||
func SLAReportFileName(rep SLAReport) string {
|
||
name := slaSafeName(rep.Target.Name)
|
||
return fmt.Sprintf("sla-%s-%s.csv", name, rep.PeriodFrom)
|
||
}
|
||
|
||
func slaSafeName(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||
b.WriteRune(r)
|
||
case r == '-' || r == '_':
|
||
b.WriteRune(r)
|
||
default:
|
||
// Кирилиця й пробіли зводяться до дефіса: ім'я файла їде в
|
||
// заголовок Content-Disposition, а він у частині проксі й
|
||
// старих браузерів не переживає ані не-ASCII, ані пробілу.
|
||
b.WriteRune('-')
|
||
}
|
||
}
|
||
out := strings.Trim(b.String(), "-")
|
||
for strings.Contains(out, "--") {
|
||
out = strings.ReplaceAll(out, "--", "-")
|
||
}
|
||
if out == "" {
|
||
return "report"
|
||
}
|
||
if len(out) > 40 {
|
||
out = strings.Trim(out[:40], "-")
|
||
}
|
||
return out
|
||
}
|