Netpulse_SasS/server/internal/store/ncm_report.go
byrsapty 6de3565bbe
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m56s
Відповідність: редагування правил, звіт і пʼять знахідок рецензії
Перший справжній прогін на живій мережі дав 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. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
   ручка робила протилежне до задуманого в мить найвищої довіри.
2026-08-28 13:40:40 +03:00

412 lines
15 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 (
"bufio"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Звіт про прогін файлом.
//
// Головне обмеження тут не формат, а розмір. Двісті хостів, на кожному
// `display current-configuration` — це десятки мегабайтів тексту, і
// зібрати їх у рядок, щоб потім віддати одним writeJSON, означає
// покласти всю цю купу в пам'ять сервера рівно тоді, коли на неї
// найменше запасу: звіти качають після великих прогонів.
//
// Тому потік. Рядки читаються курсором і одразу йдуть у відповідь, у
// пам'яті живе один хост. Ціною одного зайнятого з'єднання до бази на
// час завантаження — це дешевше за мегабайти, помножені на кількість
// одночасних операторів.
// Формати звіту. Два, і кожен відповідає на своє питання.
//
// txt читає людина: вивід кожної команди під її назвою, у тому порядку,
// у якому воно виконувалось. Це те, що вкладають у тікет і надсилають
// вендору.
//
// csv вантажать у щось інше — таблицю, скрипт, систему обліку. Один
// рядок на пару «хост + команда»: саме така форма потрібна, щоб
// відповісти на «покажи всі хости, де ця команда впала».
//
// JSON тут не третій формат, а те саме, що вже віддає GET
// /command-runs/{id}: заводити йому ще один шлях означало б мати два
// описи однієї структури.
const (
ReportFormatTxt = "txt"
ReportFormatCSV = "csv"
)
// Підписи станів. Ті самі, що бачить людина на сторінці: звіт і екран
// мають називати те саме тими самими словами, інакше їх не звести.
var reportTargetLabel = map[string]string{
"pending": "у черзі",
"queued": "відправляється",
"running": "виконується",
"success": "готово",
"failed": "помилка",
"canceled": "скасовано",
}
var reportRunLabel = map[string]string{
"running": "у роботі",
"done": "завершено",
"canceled": "зупинено",
}
// CommandRunHead читає шапку прогону без хостів.
//
// Потрібна двом: перевірці «а чи існує цей прогін» перед видачею
// посилання й самому звіту, який мусить знати, що писати в заголовок, ще
// до того, як піде перший хост. GetCommandRun сюди не годиться — він
// тягне всі результати, тобто рівно те, чого потоковий звіт і уникає.
func (s *Store) CommandRunHead(ctx context.Context, tenantID, runID string) (CommandRun, error) {
var out CommandRun
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return s.readRunHead(ctx, tx, tenantID, runID, &out)
})
return out, err
}
func (s *Store) readRunHead(ctx context.Context, tx pgx.Tx, tenantID, runID string,
out *CommandRun) error {
var cmdRaw, filterRaw string
err := tx.QueryRow(ctx, `
SELECT r.id::text, r.status::text, r.commands::text, r.filter::text,
r.concurrency, r.timeout_sec, r.total,
COALESCE(u.username, ''), r.created_at, r.finished_at
FROM ncm.command_runs r
LEFT JOIN core.users u ON u.id = r.created_by
WHERE r.id = $1 AND r.tenant_id = $2
`, runID, tenantID).Scan(&out.ID, &out.Status, &cmdRaw, &filterRaw,
&out.Concurrency, &out.TimeoutSec, &out.Total,
&out.CreatedBy, &out.CreatedAt, &out.FinishedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
_ = jsonUnmarshalStrings(cmdRaw, &out.Commands)
var f DeviceFilter
if json.Unmarshal([]byte(filterRaw), &f) == nil {
out.Filter = f
}
out.Counts = map[string]int{}
rows, err := tx.Query(ctx, `
SELECT status::text, count(*) FROM ncm.command_targets
WHERE run_id = $1 AND tenant_id = $2 GROUP BY status
`, runID, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var st string
var n int
if err := rows.Scan(&st, &n); err != nil {
return err
}
out.Counts[st] = n
}
return rows.Err()
}
// ReportFileName — ім'я файлу, під яким звіт ляже на диск.
//
// Дата й початок ідентифікатора прогону: у теці «Завантаження» лежить
// десяток таких файлів, і «report.txt» серед них не значить нічого.
func ReportFileName(run CommandRun, format string) string {
short := run.ID
if len(short) > 8 {
short = short[:8]
}
return fmt.Sprintf("netpulse-commands-%s-%s.%s",
run.CreatedAt.UTC().Format("2006-01-02"), short, format)
}
// StreamCommandRunReport пише звіт у потік.
//
// open викликається РІВНО ОДИН раз і рівно тоді, коли шапка прочитана, а
// перший хост ще ні. Це не химерність, а єдиний спосіб чесно віддати 404
// на неіснуючий прогін: коли перший байт тіла вже пішов, статус відповіді
// змінити не можна, і помилка перетворилась би на битий файл із кодом
// 200. Тому обробник виставляє заголовки саме в open — за фактом того,
// що прогін знайдено.
//
// Помилка ПІСЛЯ open уже нікуди, крім самого файлу, не подінеться: у
// нього дописується рядок про обрив. Мовчазно обрізаний звіт виглядає як
// повний, і людина зробить із нього висновок про хости, яких у ньому
// просто немає.
func (s *Store) StreamCommandRunReport(ctx context.Context, tenantID, runID, format string,
open func(CommandRun) (io.Writer, error)) error {
if format != ReportFormatTxt && format != ReportFormatCSV {
return fmt.Errorf("%w: невідомий формат звіту %q", ErrInvalid, format)
}
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var run CommandRun
if err := s.readRunHead(ctx, tx, tenantID, runID, &run); err != nil {
return err
}
dst, err := open(run)
if err != nil {
return err
}
bw := bufio.NewWriterSize(dst, 64<<10)
rows, err := tx.Query(ctx, `
SELECT d.name, COALESCE(host(d.address),''),
t.status::text, t.outcomes::text, COALESCE(t.error,''),
t.started_at, t.finished_at, COALESCE(t.duration_ms, 0)
FROM ncm.command_targets t
JOIN inv.devices d ON d.id = t.device_id
WHERE t.run_id = $1 AND t.tenant_id = $2
ORDER BY d.name
`, runID, tenantID)
if err != nil {
return err
}
defer rows.Close()
if format == ReportFormatCSV {
err = writeCSVReport(bw, run, rows)
} else {
err = writeTextReport(bw, run, rows)
}
if err != nil {
// Слід обриву — у самому файлі. Помилка все одно повертається
// нагору для логів, але людина побачить її там, де відкриє
// звіт.
fmt.Fprintf(bw, "\n\n*** ЗВІТ ОБІРВАНО: %v ***\n", err)
_ = bw.Flush()
return err
}
return bw.Flush()
})
}
// reportTarget — один хост, зчитаний із курсора.
type reportTarget struct {
name, address, status, errMsg string
outcomes []CommandOutcome
started, finished *time.Time
durationMs int
}
func scanReportTarget(rows pgx.Rows) (reportTarget, error) {
var t reportTarget
var outcomesRaw string
if err := rows.Scan(&t.name, &t.address, &t.status, &outcomesRaw, &t.errMsg,
&t.started, &t.finished, &t.durationMs); err != nil {
return t, err
}
_ = json.Unmarshal([]byte(outcomesRaw), &t.outcomes)
return t, nil
}
// ---------------------------------------------------------------------
// Текстовий звіт
// ---------------------------------------------------------------------
func writeTextReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error {
const rule = "======================================================================"
const thin = "----------------------------------------------------------------------"
fmt.Fprintf(w, "NetPulse — звіт про масове виконання команд\n%s\n", rule)
fmt.Fprintf(w, "Прогін : %s\n", run.ID)
fmt.Fprintf(w, "Стан : %s\n", orRaw(reportRunLabel[run.Status], run.Status))
if run.CreatedBy != "" {
fmt.Fprintf(w, "Запустив : %s\n", run.CreatedBy)
}
fmt.Fprintf(w, "Створено : %s\n", reportTime(&run.CreatedAt))
if run.FinishedAt != nil {
fmt.Fprintf(w, "Завершено : %s\n", reportTime(run.FinishedAt))
}
fmt.Fprintf(w, "Хостів : %d (%s)\n", run.Total, countsSummary(run.Counts))
fmt.Fprintf(w, "Паралельно : %d\n", run.Concurrency)
fmt.Fprintf(w, "Таймаут : %d с\n", run.TimeoutSec)
fmt.Fprintf(w, "\nКоманди:\n")
for _, c := range run.Commands {
fmt.Fprintf(w, " %s\n", c)
}
n := 0
for rows.Next() {
t, err := scanReportTarget(rows)
if err != nil {
return err
}
n++
fmt.Fprintf(w, "\n%s\n[%d/%d] %s", rule, n, run.Total, t.name)
if t.address != "" {
fmt.Fprintf(w, " %s", t.address)
}
fmt.Fprintf(w, "\n%s", orRaw(reportTargetLabel[t.status], t.status))
if t.durationMs > 0 {
fmt.Fprintf(w, " · %.1f с", float64(t.durationMs)/1000)
}
if t.started != nil {
fmt.Fprintf(w, " · %s", reportTime(t.started))
}
fmt.Fprintf(w, "\n")
if t.errMsg != "" {
fmt.Fprintf(w, "ПОМИЛКА: %s\n", t.errMsg)
}
if len(t.outcomes) == 0 {
fmt.Fprintf(w, "%s\n(виводу немає)\n", thin)
continue
}
for _, o := range t.outcomes {
fmt.Fprintf(w, "%s\n", thin)
// Підготовчі команди профілю позначені окремо: їх не набирала
// людина, і без позначки вона шукатиме свою команду серед
// чужих — так само, як на екрані.
if o.Prep {
fmt.Fprintf(w, "· %s (підготовча команда профілю)\n", o.Command)
} else {
fmt.Fprintf(w, "$ %s\n", o.Command)
}
if o.Output != "" {
fmt.Fprintf(w, "%s\n", strings.TrimRight(o.Output, "\r\n"))
}
if o.Error != "" {
fmt.Fprintf(w, "!! %s\n", o.Error)
}
}
// Скидання після кожного хоста: у пам'яті буфера живе один хост,
// а людина бачить, що файл іде, а не висить.
if err := w.Flush(); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
fmt.Fprintf(w, "\n%s\nКінець звіту. Хостів у звіті: %d\n", rule, n)
return nil
}
// ---------------------------------------------------------------------
// CSV
// ---------------------------------------------------------------------
// writeCSVReport — один рядок на пару «хост + команда».
//
// Хост без жодного виводу (не під'єднались, скасували) теж отримує свій
// рядок із порожньою командою. Інакше з таблиці зникли б рівно ті хости,
// заради яких її найчастіше й відкривають.
func writeCSVReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error {
// BOM. Excel без нього читає UTF-8 як однобайтове кодування, і звіт
// із кирилицею відкривається кракозябрами — а відкриють його саме в
// Excel.
if _, err := w.WriteString("\ufeff"); err != nil {
return err
}
// Через safeCSV: у клітинку `output` їде СИРИЙ вивід команди з
// пристрою, а в `command` — те, що набрала людина. Клітинка, яка
// починається з `=`, для Excel не текст, а формула, і виконає її та
// сама людина, яка відкрила звіт. Одне місце екранування на всі три
// наші csv — у csv_safe.go.
cw := newSafeCSV(csv.NewWriter(w))
if err := cw.Write([]string{
"run_id", "host", "address", "status", "duration_ms",
"started_at", "finished_at", "command", "prep", "output",
"command_error", "host_error",
}); err != nil {
return err
}
for rows.Next() {
t, err := scanReportTarget(rows)
if err != nil {
return err
}
base := []string{
run.ID, t.name, t.address,
orRaw(reportTargetLabel[t.status], t.status),
strconv.Itoa(t.durationMs),
reportTime(t.started), reportTime(t.finished),
}
if len(t.outcomes) == 0 {
if err := cw.Write(append(append([]string{}, base...),
"", "", "", "", t.errMsg)); err != nil {
return err
}
}
for _, o := range t.outcomes {
prep := ""
if o.Prep {
prep = "1"
}
if err := cw.Write(append(append([]string{}, base...),
o.Command, prep, o.Output, o.Error, t.errMsg)); err != nil {
return err
}
}
cw.Flush()
if err := cw.Error(); err != nil {
return err
}
if err := w.Flush(); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
cw.Flush()
return cw.Error()
}
// ---------------------------------------------------------------------
// reportTime — час у RFC3339.
//
// Не «красиво», а однозначно: звіт читають у тікеті через тиждень і в
// іншому часовому поясі, і «12:03» там не означає нічого.
func reportTime(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func orRaw(label, raw string) string {
if label == "" {
return raw
}
return label
}
// countsSummary — «готово 5, помилка 1» у порядку важливості.
func countsSummary(counts map[string]int) string {
order := []string{"success", "failed", "canceled", "running", "queued", "pending"}
parts := make([]string, 0, len(order))
for _, k := range order {
if n := counts[k]; n > 0 {
parts = append(parts, fmt.Sprintf("%s %d", orRaw(reportTargetLabel[k], k), n))
}
}
if len(parts) == 0 {
return "порожньо"
}
return strings.Join(parts, ", ")
}