Netpulse_SasS/server/internal/httpapi/templates.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
2026-08-27 17:32:49 +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 httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
list, err := s.store.ListTemplates(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "шаблони", err)
return
}
if list == nil {
list = []store.Template{}
}
writeJSON(w, http.StatusOK, map[string]any{"templates": list})
}
func (s *Server) handleGetTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
t, err := s.store.GetTemplate(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "шаблон", err)
return
}
writeJSON(w, http.StatusOK, t)
}
// handleSaveTemplate створює новий шаблон (POST) або замінює наявний (PUT).
//
// Елементи приходять повним списком: форма редагує шаблон як єдиний
// документ, і часткові оновлення тут лише додали б способів отримати
// стан, якого людина не просила.
func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Vendor string `json:"vendor"`
// Фільтр складу портів для snmp.if. Приходить тим самим
// документом, що й решта шаблону: у формі це сусідня вкладка.
IfFilter *store.InterfaceFilter `json:"if_filter"`
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled *bool `json:"enabled"`
Params json.RawMessage `json:"params"`
} `json:"items"`
// Прототипи їдуть тим самим документом, що й елементи: у формі
// це сусідня вкладка, і окрема ручка означала б, що шаблон
// можна зберегти наполовину — з новими метриками й старим
// правилом виявлення рядків.
Prototypes []store.TemplatePrototype `json:"prototypes"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
in.Key = strings.TrimSpace(in.Key)
in.Name = strings.TrimSpace(in.Name)
if in.Key == "" || in.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "ключ і назва обов'язкові")
return
}
// Зразок перевіряємо тут, а не там, де він застосується: криву
// дужку показують тому, хто її щойно надрукував, а не через добу
// в журналі сервера рядком «фільтр не компілюється».
if in.IfFilter != nil && !in.IfFilter.IsEmpty() {
if err := store.ValidateInterfaceFilter(*in.IfFilter); err != nil {
writeError(w, http.StatusBadRequest, "bad_filter", "фільтр портів: "+err.Error())
return
}
}
// Прототипи перевіряємо тут із тих самих міркувань, що й фільтр
// портів: криву дужку в зразку показують тому, хто її щойно
// надрукував. Прототип без OID виявлення мовчки не знайшов би
// жодного рядка — тобто виглядав би збереженим і не робив нічого.
for i := range in.Prototypes {
pr := &in.Prototypes[i]
pr.Key = strings.TrimSpace(pr.Key)
pr.Name = strings.TrimSpace(pr.Name)
if pr.Key == "" {
pr.Key = slugify(pr.Name)
}
if pr.Key == "" {
pr.Key = fmt.Sprintf("proto-%d", i+1)
}
if strings.TrimSpace(pr.DiscoverOID) == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"прототип «"+pr.Name+"» без OID виявлення рядків")
return
}
if pr.RowFilter != nil && !pr.RowFilter.IsEmpty() {
if err := store.ValidateRowFilter(*pr.RowFilter); err != nil {
writeError(w, http.StatusBadRequest, "bad_filter",
"фільтр рядків прототипу «"+pr.Name+"»: "+err.Error())
return
}
}
for _, m := range pr.Metrics {
// Метрика без OID або без ключа не збирає нічого й нікуди:
// зберегти її означало б показати прототип, який виглядає
// робочим. Те саме правило, що для елементів snmp.get.
if strings.TrimSpace(m.OID) == "" || strings.TrimSpace(m.MetricKey) == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"метрика прототипу «"+pr.Name+"» потребує OID і ключ метрики")
return
}
}
}
t := store.Template{
ID: r.PathValue("id"),
Key: in.Key,
Name: in.Name,
Description: strings.TrimSpace(in.Description),
Vendor: strings.TrimSpace(in.Vendor),
IfFilter: in.IfFilter,
Prototypes: in.Prototypes,
}
for _, i := range in.Items {
checkType := i.CheckType
if checkType == "" {
checkType = "snmp.get"
}
oid := strings.TrimSpace(i.OID)
metric := strings.TrimSpace(i.MetricKey)
// OID і ключ метрики потрібні лише там, де перевірка справді
// адресується OID-ом. У пінга немає ані того, ані іншого — його
// метрики визначає сам тип перевірки.
if checkType == "snmp.get" {
if oid == "" || metric == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"метрика SNMP потребує OID і ключ метрики")
return
}
// Крапка на початку — те, як OID пишуть у документації
// вендорів і як його чекає gosnmp. Дописати її самим
// дешевше, ніж відмовляти людині через символ, який нічого
// не означає.
if !strings.HasPrefix(oid, ".") {
oid = "." + oid
}
}
if i.IntervalSec < 5 || i.IntervalSec > 86400 {
if i.IntervalSec != 0 {
writeError(w, http.StatusBadRequest, "bad_interval",
"інтервал задається в межах від 5 секунд до доби")
return
}
}
enabled := true
if i.Enabled != nil {
enabled = *i.Enabled
}
// Ключ елемента виводимо самі, якщо його не прислали: він
// потрібен базі, але жодного рішення не несе, і вимагати його
// від клієнта означало б 500 замість збереження.
key := strings.TrimSpace(i.Key)
if key == "" {
key = slugify(metric)
}
if key == "" {
key = slugify(strings.TrimSpace(i.Name))
}
if key == "" {
key = fmt.Sprintf("item-%d", len(t.Items)+1)
}
t.Items = append(t.Items, store.TemplateItem{
Key: key,
Name: strings.TrimSpace(i.Name),
CheckType: checkType,
OID: oid,
MetricKey: metric,
Unit: strings.TrimSpace(i.Unit),
Scale: i.Scale,
IntervalSec: i.IntervalSec,
Enabled: enabled,
Params: i.Params,
})
}
id, err := s.store.SaveTemplate(r.Context(), p.TenantID, t)
if err != nil {
if errors.Is(err, store.ErrBuiltin) {
writeError(w, http.StatusForbidden, "builtin",
"вбудований шаблон не редагується — зробіть копію")
return
}
// Зайнятий ключ — помилка людини, а не сервера. «Внутрішня
// помилка» у відповідь на цілком зрозумілу ситуацію лишає
// людину гадати, що зламалось, замість виправити одне поле.
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate_key",
"шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ")
return
}
// Фільтр, який зануляє склад портів на живому хості, — це не
// «внутрішня помилка», а рішення, яке треба переграти. Шаблон
// при цьому лишився таким, яким був: перевірка стоїть усередині
// тієї самої транзакції.
if errors.Is(err, store.ErrInterfaceFilterEmpty) {
writeError(w, http.StatusConflict, "filter_empty", err.Error())
return
}
s.writeStoreError(w, "збереження шаблону", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
}
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
err := s.store.DeleteTemplate(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
if errors.Is(err, store.ErrBuiltin) {
writeError(w, http.StatusForbidden, "builtin", "вбудований шаблон не видаляється")
return
}
s.writeStoreError(w, "видалення шаблону", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Прив'язка до хоста
// ---------------------------------------------------------------------
func (s *Server) handleGetDeviceTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
ids, err := s.store.DeviceTemplateIDs(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "шаблони хоста", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
}
func (s *Server) handleSetDeviceTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
var in struct {
TemplateIDs []string `json:"template_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, in.TemplateIDs); err != nil {
s.writeStoreError(w, "прив'язка шаблонів", err)
return
}
ids, err := s.store.DeviceTemplateIDs(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "шаблони хоста", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
}
// ---------------------------------------------------------------------
// Обмін
// ---------------------------------------------------------------------
// handleExportTemplates віддає документ як файл.
//
// `?ids=a,b` — вибрані, без параметра — усі. Content-Disposition
// обов'язковий: без нього браузер покаже JSON у вкладці, а людина
// прийшла по файл.
func (s *Server) handleExportTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
var ids []string
for _, v := range strings.Split(r.URL.Query().Get("ids"), ",") {
if v = strings.TrimSpace(v); v != "" {
ids = append(ids, v)
}
}
doc, err := s.store.ExportTemplates(r.Context(), p.TenantID, ids)
if err != nil {
s.writeStoreError(w, "експорт шаблонів", err)
return
}
name := fmt.Sprintf("netpulse-templates-%s.json", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleImportTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
store.TemplateDoc
// Типово вимкнено: мовчазне перезаписування — найшвидший спосіб
// втратити локальні правки.
UpdateExisting bool `json:"update_existing"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json",
"не вдалося прочитати документ: "+err.Error())
return
}
if len(in.Templates) == 0 {
writeError(w, http.StatusBadRequest, "empty", "у документі немає жодного шаблону")
return
}
res, err := s.store.ImportTemplates(r.Context(), p.TenantID, in.TemplateDoc, in.UpdateExisting)
if err != nil {
writeError(w, http.StatusBadRequest, "import_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// Графіки
// ---------------------------------------------------------------------
var validGraphKinds = map[string]bool{
"line": true, "area": true, "stacked": true,
"bar": true, "gauge": true, "stat": true,
}
func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Graphs []store.TemplateGraph `json:"graphs"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for i := range in.Graphs {
g := &in.Graphs[i]
g.Name = strings.TrimSpace(g.Name)
g.Key = strings.TrimSpace(g.Key)
if g.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "графік без назви")
return
}
if g.Key == "" {
g.Key = slugify(g.Name)
}
if g.Key == "" {
g.Key = fmt.Sprintf("graph-%d", i+1)
}
if g.Kind == "" {
g.Kind = "line"
}
if !validGraphKinds[g.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид графіка: "+g.Kind)
return
}
if len(g.MetricKeys) == 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"графік «"+g.Name+"» не містить жодної метрики")
return
}
}
err := s.store.SaveTemplateGraphs(r.Context(), p.TenantID, r.PathValue("id"), in.Graphs)
if err != nil {
if errors.Is(err, store.ErrBuiltin) {
writeError(w, http.StatusForbidden, "builtin",
"графіки вбудованого шаблону не редагуються — зробіть копію")
return
}
s.writeStoreError(w, "графіки шаблону", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSaveTemplateTriggers — тригери шаблону цілим набором.
//
// Окремим запитом, як і графіки: три вкладки форми зберігаються
// незалежно, і правка порога не має вимагати надіслати назад усі
// перевірки шаблону разом із нею.
func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Triggers []store.TemplateTrigger `json:"triggers"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for i := range in.Triggers {
t := &in.Triggers[i]
t.Name = strings.TrimSpace(t.Name)
t.Key = strings.TrimSpace(t.Key)
if t.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "тригер без назви")
return
}
if t.Key == "" {
t.Key = slugify(t.Name)
}
if t.Key == "" {
t.Key = fmt.Sprintf("trigger-%d", i+1)
}
if t.Source == "" {
t.Source = "metric"
}
// Відмова стосується лише ввімкнених тригерів.
//
// Шаблон — документ, який редагують цілком: якби нереалізоване
// джерело блокувало збереження, людина з одним старим тригером
// на трапи не могла б виправити жоден інший. Вимкнений тригер
// нічого не обіцяє — він не розгортається в правило взагалі.
if reason := store.UnsupportedSourceReason(t.Source); reason != "" && t.Enabled {
writeError(w, http.StatusBadRequest, "bad_source",
"тригер «"+t.Name+"»: "+reason)
return
}
if t.Severity == "" {
t.Severity = "warning"
}
if !validSeverities[t.Severity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома важливість: "+t.Severity)
return
}
if len(t.Condition) == 0 || string(t.Condition) == "{}" {
writeError(w, http.StatusBadRequest, "bad_request",
"тригер «"+t.Name+"» без умови")
return
}
if t.Enabled {
if err := store.ValidateRuleCondition(t.Source, t.Condition); err != nil {
writeError(w, http.StatusBadRequest, "bad_condition",
"тригер «"+t.Name+"»: "+err.Error())
return
}
}
if t.ForSeconds < 0 || t.ForSeconds > 86400 {
writeError(w, http.StatusBadRequest, "bad_request",
"тригер «"+t.Name+"»: витримка поза межами 0…86400 с")
return
}
}
err := s.store.SaveTemplateTriggers(r.Context(), p.TenantID, r.PathValue("id"), in.Triggers)
if err != nil {
if errors.Is(err, store.ErrBuiltin) {
writeError(w, http.StatusForbidden, "builtin",
"тригери вбудованого шаблону не редагуються — зробіть копію")
return
}
s.writeStoreError(w, "тригери шаблону", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDeviceGraphs — графіки, які дають шаблони цього хоста.
func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
list, err := s.store.DeviceGraphs(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "графіки хоста", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"graphs": list})
}
func (s *Server) handleCloneTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
id, err := s.store.CloneTemplate(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "клонування шаблону", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}