Added alert rules

This commit is contained in:
Magnus Root 2026-02-25 14:47:07 +03:00
parent 5dfc842b5e
commit dbe38ec816
7 changed files with 1036 additions and 51 deletions

746
alerts.go Normal file
View file

@ -0,0 +1,746 @@
package main
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
)
// AlertRulesTemplate — структура шаблона алертов из alert_rules_template.json
type AlertRulesTemplate struct {
Version string `json:"version"`
Description string `json:"description"`
Defaults AlertDefaults `json:"defaults"`
RPSAlert map[string]interface{} `json:"rps_alert"`
StaticAlerts []map[string]interface{} `json:"static_alerts"` // Алерты без привязки к БД
}
type AlertDefaults struct {
NoDataState string `json:"no_data_state"`
ExecErrState string `json:"exec_err_state"`
Receiver string `json:"receiver"`
Folder string `json:"folder"`
Group string `json:"group"`
GroupInterval string `json:"group_interval"`
}
// generateAlertUID генерирует детерминированный UID алерта из имени клиента и типа.
// Одинаковый UID при повторном запуске = Grafana обновит существующий алерт, не создаст новый.
func generateAlertUID(clientTitle, alertType string) string {
hash := md5.Sum([]byte(clientTitle + ":" + alertType))
return fmt.Sprintf("%x", hash[:8])
}
// buildRPSAlertRule строит JSON правила алерта для одного клиента по шаблону
func buildRPSAlertRule(client ClientData, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
datasourceUID := config.AlertsDatasourceUID
// SID-запрос: один SID или несколько через OR
sids := make([]string, 0, len(client.Domains))
for _, d := range client.Domains {
sids = append(sids, d.SID)
}
// Убираем дубликаты и сортируем для детерминированности
sidMap := make(map[string]bool)
for _, s := range sids {
sidMap[s] = true
}
uniqueSIDs := make([]string, 0, len(sidMap))
for s := range sidMap {
uniqueSIDs = append(uniqueSIDs, s)
}
sort.Strings(uniqueSIDs)
sidParts := make([]string, len(uniqueSIDs))
for i, s := range uniqueSIDs {
sidParts[i] = "SID:" + s
}
sidQuery := strings.Join(sidParts, " OR ")
// Получаем настройки из шаблона
defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := orDefault(config.AlertsGroup, defaults.Group)
timeRangeFrom := int64(1800)
if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok {
timeRangeFrom = int64(v)
}
summaryTemplate := "Превышен порог в {rps_limit} RPS в {client_title}"
if v, ok := tmpl.RPSAlert["annotation_summary"].(string); ok {
summaryTemplate = v
}
summary := strings.NewReplacer(
"{rps_limit}", fmt.Sprintf("%d", client.RPSLimit),
"{client_title}", client.ClientTitle,
).Replace(summaryTemplate)
uid := generateAlertUID(client.ClientTitle, "rps")
dataRefID := "A"
rpsRefID := "RPS " + client.ClientTitle
reduceRefID := "Текущий RPS"
thresholdRefID := "Превышение"
rule := map[string]interface{}{
"uid": uid,
"title": fmt.Sprintf("RPS %s", client.ClientTitle),
"condition": thresholdRefID,
"noDataState": defaults.NoDataState,
"execErrState": defaults.ExecErrState,
"isPaused": false,
"folderUID": "", // будет заполнен при отправке
"ruleGroup": group,
"annotations": map[string]string{
"summary": summary,
},
"notification_settings": map[string]string{
"receiver": receiver,
},
"data": []interface{}{
// Шаг 1: count-запрос по SID
map[string]interface{}{
"refId": dataRefID,
"queryType": "lucene",
"relativeTimeRange": map[string]interface{}{
"from": timeRangeFrom,
"to": 0,
},
"datasourceUid": datasourceUID,
"model": map[string]interface{}{
"alias": fmt.Sprintf("RPS %s", client.ClientTitle),
"bucketAggs": []interface{}{
map[string]interface{}{
"field": "@timestamp",
"id": "2",
"settings": map[string]interface{}{
"interval": "1m",
"min_doc_count": "0",
"trimEdges": "0",
},
"type": "date_histogram",
},
},
"datasource": map[string]interface{}{
"type": "grafana-opensearch-datasource",
"uid": datasourceUID,
},
"format": "table",
"instant": false,
"intervalMs": 1000,
"luceneQueryType": "Metric",
"maxDataPoints": 43200,
"metrics": []interface{}{
map[string]interface{}{"hide": false, "id": "1", "type": "count"},
},
"query": sidQuery,
"queryType": "lucene",
"range": true,
"refId": dataRefID,
"timeField": "@timestamp",
},
},
// Шаг 2: math $A / 60 → RPS
map[string]interface{}{
"refId": rpsRefID,
"relativeTimeRange": map[string]interface{}{
"from": timeRangeFrom,
"to": 0,
},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"datasource": map[string]interface{}{
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": fmt.Sprintf("$%s / 60", dataRefID),
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": rpsRefID,
"type": "math",
"window": "",
},
},
// Шаг 3: reduce median
map[string]interface{}{
"refId": reduceRefID,
"relativeTimeRange": map[string]interface{}{
"from": 0,
"to": 0,
},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"evaluator": map[string]interface{}{"params": []int{0, 0}, "type": "gt"},
"operator": map[string]interface{}{"type": "and"},
"query": map[string]interface{}{"params": []string{}},
"reducer": map[string]interface{}{"params": []string{}, "type": "avg"},
"type": "query",
},
},
"datasource": map[string]interface{}{
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": rpsRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"reducer": "median",
"refId": reduceRefID,
"settings": map[string]interface{}{"mode": ""},
"type": "reduce",
},
},
// Шаг 4: threshold > rps_limit
map[string]interface{}{
"refId": thresholdRefID,
"relativeTimeRange": map[string]interface{}{
"from": 0,
"to": 0,
},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"evaluator": map[string]interface{}{
"params": []interface{}{client.RPSLimit, 0},
"type": "gt",
},
"operator": map[string]interface{}{"type": "and"},
"query": map[string]interface{}{"params": []string{}},
"reducer": map[string]interface{}{"params": []string{}, "type": "avg"},
"type": "query",
},
},
"datasource": map[string]interface{}{
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": reduceRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": thresholdRefID,
"type": "threshold",
},
},
},
}
return rule
}
// alertTask описывает одно правило алерта для отправки
type alertTask struct {
rule map[string]interface{}
label string // для логирования
}
// parallelUpsert отправляет список алертов параллельно с ограничением concurrency.
// dryRun=true только логирует без отправки.
// Возвращает количество успешных и неуспешных отправок.
func parallelUpsert(tasks []alertTask, config Config, dryRun bool, concurrency int) (sent, failed int) {
sem := make(chan struct{}, concurrency)
var mu sync.Mutex
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
sem <- struct{}{} // захватить слот
go func(t alertTask) {
defer wg.Done()
defer func() { <-sem }() // освободить слот
if dryRun {
data, _ := json.MarshalIndent(t.rule, "", " ")
log.Printf("DRY RUN: Alert rule '%s':\n%s", t.label, string(data))
mu.Lock()
sent++
mu.Unlock()
return
}
if err := upsertAlertRule(t.rule, config); err != nil {
log.Printf("Warning: failed to upsert alert '%s': %v", t.label, err)
mu.Lock()
failed++
mu.Unlock()
} else {
log.Printf("Alert upserted: %s", t.label)
mu.Lock()
sent++
mu.Unlock()
}
}(task)
}
wg.Wait()
return
}
// generateAndSendAlerts генерирует и отправляет RPS-алерты для клиентов с rps_limit > 0
func generateAndSendAlerts(clients map[string]ClientData, tmpl AlertRulesTemplate, config Config, dryRun bool) error {
// Собираем клиентов с лимитом, сортируем для детерминированности
type clientWithLimit struct {
title string
client ClientData
}
var targets []clientWithLimit
for _, client := range clients {
if client.RPSLimit > 0 {
targets = append(targets, clientWithLimit{client.ClientTitle, client})
}
}
if len(targets) == 0 {
log.Printf("Alerts: no clients with rps_limit set, skipping")
return nil
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].title < targets[j].title
})
log.Printf("Alerts: generating RPS rules for %d clients", len(targets))
// Получить UID папки для алертов
folderUID := ""
if !dryRun {
uid, err := ensureAlertFolder(config)
if err != nil {
return fmt.Errorf("failed to ensure alerts folder: %w", err)
}
folderUID = uid
}
// Собираем все динамические задачи: RPS + 4xx + 5xx
var tasks []alertTask
for _, t := range targets {
if t.client.RPSLimit > 0 {
rule := buildRPSAlertRule(t.client, tmpl, config)
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("RPS %s (limit: %d)", t.client.ClientTitle, t.client.RPSLimit),
})
}
if t.client.Limit4xx > 0 {
rule := buildErrorRateAlertRule(t.client, 4, t.client.Limit4xx, tmpl, config)
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("4xx %s (limit: %d%%)", t.client.ClientTitle, t.client.Limit4xx),
})
}
if t.client.Limit5xx > 0 {
rule := buildErrorRateAlertRule(t.client, 5, t.client.Limit5xx, tmpl, config)
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("5xx %s (limit: %d%%)", t.client.ClientTitle, t.client.Limit5xx),
})
}
}
log.Printf("Alerts: sending %d dynamic rules (concurrency: 5)", len(tasks))
sent, failed := parallelUpsert(tasks, config, dryRun, 5)
log.Printf("Alerts: sent=%d, failed=%d", sent, failed)
if failed > 0 {
return fmt.Errorf("%d alert rules failed to upsert", failed)
}
// Статические алерты — отправляются всегда вместе с динамическими
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil {
log.Printf("Warning: static alerts error: %v", err)
}
return nil
}
// buildErrorRateAlertRule строит алерт на процент 4xx или 5xx ответов для клиента.
// Структура: два count-запроса (total + errors) → reduce → math (%) → threshold
func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
datasourceUID := config.AlertsDatasourceUID
defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := orDefault(config.AlertsGroup, defaults.Group)
// SID-запрос
sids := make(map[string]bool)
for _, d := range client.Domains {
sids[d.SID] = true
}
unique := make([]string, 0, len(sids))
for s := range sids {
unique = append(unique, s)
}
sort.Strings(unique)
parts := make([]string, len(unique))
for i, s := range unique {
parts[i] = "SID:" + s
}
sidQuery := strings.Join(parts, " OR ")
codeRange := "[400 TO 499]"
codeName := "4xx"
if errCode == 5 {
codeRange = "[500 TO 599]"
codeName = "5xx"
}
uid := generateAlertUID(client.ClientTitle, codeName)
totalRefID := "Total"
errRefID := "Errors"
totalReduceID := "TotalReduce"
errReduceID := "ErrorsReduce"
percentRefID := "ErrorPercent"
thresholdRefID := "Превышение"
summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct)
relFrom := int64(900)
return map[string]interface{}{
"uid": uid,
"title": fmt.Sprintf("%s %s", codeName, client.ClientTitle),
"condition": thresholdRefID,
"noDataState": defaults.NoDataState,
"execErrState": defaults.ExecErrState,
"isPaused": false,
"folderUID": "",
"ruleGroup": group,
"for": "1m",
"annotations": map[string]string{"summary": summary},
"notification_settings": map[string]string{"receiver": receiver},
"data": []interface{}{
// Total запрос
map[string]interface{}{
"refId": totalRefID,
"queryType": "lucene",
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
"datasourceUid": datasourceUID,
"model": map[string]interface{}{
"alias": "Total",
"bucketAggs": []interface{}{map[string]interface{}{"field": "@timestamp", "id": "2", "settings": map[string]interface{}{"interval": "1m"}, "type": "date_histogram"}},
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
"format": "table",
"instant": false,
"intervalMs": 1000,
"luceneQueryType": "Metric",
"maxDataPoints": 43200,
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
"query": fmt.Sprintf("response_status_code:[0 TO 599] AND (%s)", sidQuery),
"queryType": "lucene",
"range": true,
"refId": totalRefID,
"timeField": "@timestamp",
},
},
// Error запрос
map[string]interface{}{
"refId": errRefID,
"queryType": "lucene",
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
"datasourceUid": datasourceUID,
"model": map[string]interface{}{
"alias": codeName,
"bucketAggs": []interface{}{map[string]interface{}{"field": "@timestamp", "id": "2", "settings": map[string]interface{}{"interval": "1m"}, "type": "date_histogram"}},
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
"format": "table",
"instant": false,
"intervalMs": 1000,
"luceneQueryType": "Metric",
"maxDataPoints": 43200,
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
"query": fmt.Sprintf("response_status_code:%s AND (%s)", codeRange, sidQuery),
"queryType": "lucene",
"range": true,
"refId": errRefID,
"timeField": "@timestamp",
},
},
// Reduce total
map[string]interface{}{
"refId": totalReduceID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": totalRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"reducer": "last",
"refId": totalReduceID,
"type": "reduce",
},
},
// Reduce errors
map[string]interface{}{
"refId": errReduceID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": errRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"reducer": "last",
"refId": errReduceID,
"type": "reduce",
},
},
// Math: процент ошибок
map[string]interface{}{
"refId": percentRefID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": fmt.Sprintf("($%s / $%s) * 100", errReduceID, totalReduceID),
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": percentRefID,
"type": "math",
},
},
// Threshold
map[string]interface{}{
"refId": thresholdRefID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"evaluator": map[string]interface{}{"params": []interface{}{limitPct, 0}, "type": "gt"},
"operator": map[string]interface{}{"type": "and"},
"query": map[string]interface{}{"params": []string{}},
"reducer": map[string]interface{}{"params": []string{}, "type": "avg"},
"type": "query",
},
},
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": percentRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": thresholdRefID,
"type": "threshold",
},
},
},
}
}
// sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД)
func sendStaticAlerts(tmpl AlertRulesTemplate, config Config, dryRun bool) error {
if len(tmpl.StaticAlerts) == 0 {
return nil
}
defaults := tmpl.Defaults
folderUID := ""
if !dryRun {
uid, err := ensureAlertFolder(config)
if err != nil {
return fmt.Errorf("failed to ensure alerts folder for static alerts: %w", err)
}
folderUID = uid
}
group := orDefault(config.AlertsGroup, defaults.Group)
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
log.Printf("Alerts: sending %d static alert rules (concurrency: 5)", len(tmpl.StaticAlerts))
var tasks []alertTask
for _, alertDef := range tmpl.StaticAlerts {
rule := deepCopyMap(alertDef)
// Применяем defaults если не заданы в шаблоне
if _, ok := rule["noDataState"]; !ok {
rule["noDataState"] = defaults.NoDataState
}
if _, ok := rule["execErrState"]; !ok {
rule["execErrState"] = defaults.ExecErrState
}
rule["folderUID"] = folderUID
rule["ruleGroup"] = group
if _, ok := rule["notification_settings"]; !ok {
rule["notification_settings"] = map[string]string{"receiver": receiver}
}
title, _ := rule["title"].(string)
tasks = append(tasks, alertTask{rule: rule, label: title})
}
sent, failed := parallelUpsert(tasks, config, dryRun, 5)
log.Printf("Static alerts: sent=%d, failed=%d", sent, failed)
return nil
}
// deepCopyMap делает глубокую копию map через JSON
func deepCopyMap(src map[string]interface{}) map[string]interface{} {
data, _ := json.Marshal(src)
var dst map[string]interface{}
json.Unmarshal(data, &dst)
return dst
}
// upsertAlertRule создаёт или обновляет правило алерта по UID
func upsertAlertRule(rule map[string]interface{}, config Config) error {
uid, _ := rule["uid"].(string)
baseURL := strings.TrimRight(config.GrafanaURL, "/")
// Проверяем существует ли алерт с таким UID
existing, err := getAlertRule(uid, config)
if err == nil && existing != nil {
// Обновляем — PUT /api/v1/provisioning/alert-rules/{uid}
// Сохраняем version из существующего для оптимистичной блокировки
if v, ok := existing["version"]; ok {
rule["version"] = v
}
return putAlertRule(uid, rule, baseURL, config.GrafanaAPIKey)
}
// Создаём — POST /api/v1/provisioning/alert-rules
return postAlertRule(rule, baseURL, config.GrafanaAPIKey)
}
func getAlertRule(uid string, config Config) (map[string]interface{}, error) {
url := fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s",
strings.TrimRight(config.GrafanaURL, "/"), uid)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("not found")
}
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
func postAlertRule(rule map[string]interface{}, baseURL, apiKey string) error {
return sendAlertRequest("POST",
baseURL+"/api/v1/provisioning/alert-rules",
rule, apiKey)
}
func putAlertRule(uid string, rule map[string]interface{}, baseURL, apiKey string) error {
return sendAlertRequest("PUT",
fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s", baseURL, uid),
rule, apiKey)
}
func sendAlertRequest(method, url string, payload map[string]interface{}, apiKey string) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal error: %w", err)
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(data))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Disable-Provenance", "true") // разрешить редактирование в UI
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ensureAlertFolder возвращает UID папки для алертов, создаёт если нет
func ensureAlertFolder(config Config) (string, error) {
folder := orDefault(config.AlertsFolder, DefaultAlertsFolder)
if folder == "" {
return "", nil
}
baseURL := strings.TrimRight(config.GrafanaURL, "/")
// Ищем существующую папку
req, _ := http.NewRequest("GET", baseURL+"/api/folders", nil)
req.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var folders []map[string]interface{}
if err := json.Unmarshal(body, &folders); err != nil {
return "", err
}
for _, f := range folders {
if title, _ := f["title"].(string); title == folder {
if uid, _ := f["uid"].(string); uid != "" {
return uid, nil
}
}
}
// Создаём папку
payload := map[string]interface{}{"title": folder}
data, _ := json.Marshal(payload)
req, _ = http.NewRequest("POST", baseURL+"/api/folders", bytes.NewBuffer(data))
req.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ = io.ReadAll(resp.Body)
var created map[string]interface{}
if err := json.Unmarshal(body, &created); err != nil {
return "", err
}
if uid, _ := created["uid"].(string); uid != "" {
log.Printf("Created alerts folder: %s (uid: %s)", folder, uid)
return uid, nil
}
return "", fmt.Errorf("failed to get folder UID")
}
// orDefault возвращает val если не пустой, иначе def
func orDefault(val, def string) string {
if val != "" {
return val
}
return def
}

View file

@ -5,13 +5,13 @@ const (
// Рекомендуется задавать через .env файл или переменные окружения // Рекомендуется задавать через .env файл или переменные окружения
DefaultDBUser = "" // Задать через DB_USER в .env DefaultDBUser = "" // Задать через DB_USER в .env
DefaultDBPassword = "" // Задать через DB_PASSWORD в .env DefaultDBPassword = "" // Задать через DB_PASSWORD в .env
DefaultDBName = "test_info" DefaultDBName = "waf_info"
DefaultDBPort = 5432 DefaultDBPort = 5432
DefaultPrimaryDBHost = "10.10.10.8" DefaultPrimaryDBHost = "10.100.10.8"
DefaultSecondaryDBHost = "10.10.10.5" DefaultSecondaryDBHost = "10.100.13.5"
// Git repository settings // Git repository settings
DefaultTemplatesRepo = "" DefaultTemplatesRepo = "https://svc-git.cirex.ru/cloudstack/grafana_gen_template.git"
DefaultTemplatesBranch = "master" DefaultTemplatesBranch = "master"
DefaultTemplatesPath = "/etc/grafana_gen/templates" // Системный путь DefaultTemplatesPath = "/etc/grafana_gen/templates" // Системный путь
DefaultStateFile = "/var/lib/grafana_gen/state.json" // Системный путь для state DefaultStateFile = "/var/lib/grafana_gen/state.json" // Системный путь для state
@ -59,6 +59,12 @@ const (
// Можно переопределить под ваши нужды: // Можно переопределить под ваши нужды:
// DefaultGrafanaFolder = "WAF - PTAF" // DefaultGrafanaFolder = "WAF - PTAF"
// Alerts settings
DefaultAlertsFolder = "WAF - PTAF"
DefaultAlertsReceiver = "Telegram PTAF Grafana"
DefaultAlertsGroup = "PTAF Grafana"
DefaultAlertsDatasource = "af84zsvlp9blsa" // UID datasource OpenSearch
// DefaultDashboardTitle - название единого дашборда со всеми клиентами // DefaultDashboardTitle - название единого дашборда со всеми клиентами
// Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes // Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes
DefaultDashboardTitle = "PT AF Nodes" DefaultDashboardTitle = "PT AF Nodes"
@ -70,29 +76,36 @@ const (
) )
type Config struct { type Config struct {
DBHost string DBHost string
DBPort int DBPort int
DBUser string DBUser string
DBPassword string DBPassword string
DBName string DBName string
GrafanaURL string GrafanaURL string
GrafanaAPIKey string GrafanaAPIKey string
GrafanaFolder string // Папка в Grafana для дашбордов GrafanaFolder string // Папка в Grafana для дашбордов
DashboardTitle string // Название единого дашборда DashboardTitle string // Название единого дашборда
UseManualInfo bool DryRun bool
DryRun bool TemplatesRepo string
TemplatesRepo string TemplatesBranch string
TemplatesBranch string TemplatesPath string
TemplatesPath string SkipGitPull bool
SkipGitPull bool StateFile string
StateFile string ForceRun bool
ForceRun bool GitToken string // GitLab token для приватного репозитория
GitToken string // GitLab token для приватного репозитория AlertsFolder string // Папка в Grafana для алертов
AlertsReceiver string // Receiver (канал уведомлений) для алертов
AlertsGroup string // Имя группы алертов в Grafana
AlertsDatasourceUID string // UID datasource для алертов OpenSearch
} }
type ClientData struct { type ClientData struct {
ClientTitle string ClientTitle string
Domains []DomainInfo Domains []DomainInfo
RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД)
RPSCommercialLimit int // коммерческий лимит, при NULL берётся 100
Limit4xx int // порог 4xx ответов в % (0 = алерт не создавать)
Limit5xx int // порог 5xx ответов в % (0 = алерт не создавать)
} }
type DomainInfo struct { type DomainInfo struct {
@ -113,12 +126,15 @@ type DashboardTemplate struct {
QueryTemplates map[string]interface{} `json:"query_templates"` QueryTemplates map[string]interface{} `json:"query_templates"`
Panels map[string]interface{} `json:"panels"` Panels map[string]interface{} `json:"panels"`
Queries map[string]interface{} `json:"queries"` Queries map[string]interface{} `json:"queries"`
Templating map[string]interface{} `json:"templating"` // Переменные дашборда Grafana
} }
type LayoutConfig struct { type LayoutConfig struct {
TenantPanels []PanelLayout `json:"tenant_panels"` TenantPanels []PanelLayout `json:"tenant_panels"`
DomainPanels []PanelLayout `json:"domain_panels"` DomainPanels []PanelLayout `json:"domain_panels"`
UseCollapsedRows bool `json:"use_collapsed_rows"` UseCollapsedRows bool `json:"use_collapsed_rows"`
OverviewPanels []PanelLayout `json:"overview_panels"` // Панели обзора перед клиентскими строками
OverviewRowTitle string `json:"overview_row_title"` // Заголовок collapsed row обзора
} }
type PanelLayout struct { type PanelLayout struct {

View file

@ -26,24 +26,29 @@ func connectDB(config Config) (*sql.DB, error) {
return db, nil return db, nil
} }
func fetchClientsData(db *sql.DB, useManual bool) (map[string]ClientData, error) { func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
tableName := "sp_info"
if useManual {
tableName = "manual_info"
}
// aliases это JSON массив в БД, конвертируем в текст через ::text // aliases это JSON массив в БД, конвертируем в текст через ::text
query := fmt.Sprintf(` // rps_limit берётся из client_info — NULL означает "алерт не нужен"
// Данные всегда берутся из обеих таблиц: sp_info + manual_info (UNION)
query := `
SELECT DISTINCT SELECT DISTINCT
COALESCE(a.client_title, 'Unknown') as client_title, COALESCE(a.client_title, 'Unknown') as client_title,
s.sid, s.sid,
s.domain_name, s.domain_name,
COALESCE(s.aliases::text, 'null') as aliases COALESCE(s.aliases::text, 'null') as aliases,
FROM %s s COALESCE(MAX(ci.rps_limit) OVER (PARTITION BY a.client_title), 0) as rps_limit,
COALESCE(MAX(ci.rps_commercial_limit) OVER (PARTITION BY a.client_title), 100) as rps_commercial_limit,
MAX(ci.four_hundred) OVER (PARTITION BY a.client_title) as limit_4xx,
MAX(ci.five_hundred) OVER (PARTITION BY a.client_title) as limit_5xx
FROM (
SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL
UNION
SELECT sid, domain_name, aliases FROM manual_info WHERE sid IS NOT NULL
) s
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
WHERE s.sid IS NOT NULL LEFT JOIN client_info ci ON a.client_title = ci.client_title
ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid
`, tableName) `
rows, err := db.Query(query) rows, err := db.Query(query)
if err != nil { if err != nil {
@ -55,8 +60,10 @@ func fetchClientsData(db *sql.DB, useManual bool) (map[string]ClientData, error)
for rows.Next() { for rows.Next() {
var clientTitle, sid, domainName, aliasesJSON string var clientTitle, sid, domainName, aliasesJSON string
var rpsLimit, rpsCommercialLimit int
var limit4xx, limit5xx sql.NullInt64
if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON); err != nil { if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON, &rpsLimit, &rpsCommercialLimit, &limit4xx, &limit5xx); err != nil {
return nil, fmt.Errorf("scan failed: %w", err) return nil, fmt.Errorf("scan failed: %w", err)
} }
@ -65,8 +72,12 @@ func fetchClientsData(db *sql.DB, useManual bool) (map[string]ClientData, error)
if _, exists := clients[clientTitle]; !exists { if _, exists := clients[clientTitle]; !exists {
clients[clientTitle] = ClientData{ clients[clientTitle] = ClientData{
ClientTitle: clientTitle, ClientTitle: clientTitle,
Domains: []DomainInfo{}, Domains: []DomainInfo{},
RPSLimit: rpsLimit,
RPSCommercialLimit: rpsCommercialLimit,
Limit4xx: nullIntToInt(limit4xx),
Limit5xx: nullIntToInt(limit5xx),
} }
} }
@ -82,6 +93,14 @@ func fetchClientsData(db *sql.DB, useManual bool) (map[string]ClientData, error)
return clients, rows.Err() return clients, rows.Err()
} }
// nullIntToInt конвертирует sql.NullInt64 в int (0 если NULL)
func nullIntToInt(n sql.NullInt64) int {
if n.Valid {
return int(n.Int64)
}
return 0
}
// parseAliasesJSON конвертирует JSON массив aliases в строку // parseAliasesJSON конвертирует JSON массив aliases в строку
// Input: ["domain1.com", "domain2.com"] или null // Input: ["domain1.com", "domain2.com"] или null
// Output: "domain1.com, domain2.com" или "" // Output: "domain1.com, domain2.com" или ""

47
main.go
View file

@ -48,6 +48,13 @@ func main() {
log.Printf("Loaded template version: %s", template.Version) log.Printf("Loaded template version: %s", template.Version)
// Load alert rules template
alertTmpl, err := loadAlertTemplate(config.TemplatesPath)
if err != nil {
log.Printf("Warning: Failed to load alert template: %v", err)
log.Printf("Alert rules will not be generated")
}
// Connect to database // Connect to database
db, err := connectDB(config) db, err := connectDB(config)
if err != nil { if err != nil {
@ -56,7 +63,7 @@ func main() {
defer db.Close() defer db.Close()
// Fetch clients data // Fetch clients data
clients, err := fetchClientsData(db, config.UseManualInfo) clients, err := fetchClientsData(db)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch clients data: %v", err) log.Fatalf("Failed to fetch clients data: %v", err)
} }
@ -87,10 +94,14 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// Хэш шаблона алертов — считается независимо от дашборда
alertTemplateHash := hashAlertTemplate(config.TemplatesPath)
alertsChanged := stateManager.AlertsNeedUpdate(validClients) || stateManager.AlertTemplateChanged(alertTemplateHash)
// Check for changes // Check for changes
hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients) hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients)
if !hasChanges && !config.ForceRun { if !hasChanges && !alertsChanged && !config.ForceRun {
log.Printf("=== No Changes Detected ===") log.Printf("=== No Changes Detected ===")
log.Printf("No changes in templates or database since last run.") log.Printf("No changes in templates or database since last run.")
log.Printf("Skipping dashboard generation.") log.Printf("Skipping dashboard generation.")
@ -103,6 +114,9 @@ func main() {
log.Printf("=== Changes Detected ===") log.Printf("=== Changes Detected ===")
log.Printf("%s", changeReason) log.Printf("%s", changeReason)
log.Printf("") log.Printf("")
} else if alertsChanged {
log.Printf("=== Alert Rules Changed ===")
log.Printf("")
} }
if config.ForceRun { if config.ForceRun {
@ -117,6 +131,10 @@ func main() {
if config.DryRun { if config.DryRun {
logDryRun(config.DashboardTitle, dashboard) logDryRun(config.DashboardTitle, dashboard)
if alertTmpl != nil {
log.Printf("DRY RUN: Generating alert rules preview...")
_ = generateAndSendAlerts(validClients, *alertTmpl, config, true)
}
log.Printf("=== Summary ===") log.Printf("=== Summary ===")
log.Printf("Clients: %d", len(validClients)) log.Printf("Clients: %d", len(validClients))
log.Printf("Skipped: %d", skippedCount) log.Printf("Skipped: %d", skippedCount)
@ -136,6 +154,20 @@ func main() {
log.Printf("Successfully created/updated dashboard: %s", config.DashboardTitle) log.Printf("Successfully created/updated dashboard: %s", config.DashboardTitle)
// Generate and send alert rules
if alertTmpl != nil {
if alertsChanged || config.ForceRun {
if err := generateAndSendAlerts(validClients, *alertTmpl, config, false); err != nil {
log.Printf("Warning: alerts generation failed: %v", err)
} else {
stateManager.UpdateAlertsHash(validClients)
stateManager.UpdateAlertTemplateHash(alertTemplateHash)
}
} else {
log.Printf("Alerts: no changes in rps_limit, skipping")
}
}
// Summary // Summary
log.Printf("=== Summary ===") log.Printf("=== Summary ===")
log.Printf("Dashboard: %s", config.DashboardTitle) log.Printf("Dashboard: %s", config.DashboardTitle)
@ -179,7 +211,6 @@ func parseFlags() Config {
flag.StringVar(&config.DBUser, "db-user", dbUser, "PostgreSQL user") flag.StringVar(&config.DBUser, "db-user", dbUser, "PostgreSQL user")
flag.StringVar(&config.DBPassword, "db-password", dbPassword, "PostgreSQL password") flag.StringVar(&config.DBPassword, "db-password", dbPassword, "PostgreSQL password")
flag.StringVar(&config.DBName, "db-name", DefaultDBName, "PostgreSQL database name") flag.StringVar(&config.DBName, "db-name", DefaultDBName, "PostgreSQL database name")
flag.BoolVar(&config.UseManualInfo, "use-manual", false, "Use manual_info table instead of sp_info")
// Grafana flags // Grafana flags
var grafanaURLFlag, grafanaAPIKeyFlag string var grafanaURLFlag, grafanaAPIKeyFlag string
@ -196,6 +227,16 @@ func parseFlags() Config {
flag.StringVar(&config.TemplatesPath, "templates-path", DefaultTemplatesPath, "Local path for templates") flag.StringVar(&config.TemplatesPath, "templates-path", DefaultTemplatesPath, "Local path for templates")
flag.BoolVar(&config.SkipGitPull, "skip-git-pull", false, "Skip updating templates from Git") flag.BoolVar(&config.SkipGitPull, "skip-git-pull", false, "Skip updating templates from Git")
// Alerts flags
alertsFolder := getEnvOrDefault("ALERTS_FOLDER", DefaultAlertsFolder)
alertsReceiver := getEnvOrDefault("ALERTS_RECEIVER", DefaultAlertsReceiver)
alertsGroup := getEnvOrDefault("ALERTS_GROUP", DefaultAlertsGroup)
alertsDatasource := getEnvOrDefault("ALERTS_DATASOURCE_UID", DefaultAlertsDatasource)
flag.StringVar(&config.AlertsFolder, "alerts-folder", alertsFolder, "Grafana folder for alert rules")
flag.StringVar(&config.AlertsReceiver, "alerts-receiver", alertsReceiver, "Grafana notification receiver for alerts")
flag.StringVar(&config.AlertsGroup, "alerts-group", alertsGroup, "Grafana alert rule group name")
flag.StringVar(&config.AlertsDatasourceUID, "alerts-datasource-uid", alertsDatasource, "OpenSearch datasource UID for alerts")
// Other flags // Other flags
flag.BoolVar(&config.DryRun, "dry-run", false, "Generate JSON but don't send to Grafana") flag.BoolVar(&config.DryRun, "dry-run", false, "Generate JSON but don't send to Grafana")
flag.StringVar(&config.StateFile, "state-file", DefaultStateFile, "Path to state file") flag.StringVar(&config.StateFile, "state-file", DefaultStateFile, "Path to state file")

View file

@ -19,6 +19,7 @@ var queryModeRegistry = map[string]targetBuilder{
"multi_sum_expression": buildMultiSumExpressionTargets, "multi_sum_expression": buildMultiSumExpressionTargets,
"sum_expression": buildSumExpressionTargets, "sum_expression": buildSumExpressionTargets,
"bucket_logs": buildBucketLogsTargets, "bucket_logs": buildBucketLogsTargets,
"static": buildStaticTargets,
} }
// conditionEvaluator — тип функции-вычислителя условия отображения панели. // conditionEvaluator — тип функции-вычислителя условия отображения панели.
@ -73,6 +74,7 @@ func buildPanel(
queryConfigRaw, _ := panelMap["query_config"].(map[string]interface{}) queryConfigRaw, _ := panelMap["query_config"].(map[string]interface{})
panel := copyPanelBase(panelMap, id, yPos, xPos, width) panel := copyPanelBase(panelMap, id, yPos, xPos, width)
applyRPSThreshold(panel, panelKey, client.RPSLimit, client.RPSCommercialLimit)
sidQuery := buildSIDQuery(domain.SID) sidQuery := buildSIDQuery(domain.SID)
panel["targets"] = builder(queryConfigRaw, sidQuery, datasourceType, datasourceUID, template, domain, client) panel["targets"] = builder(queryConfigRaw, sidQuery, datasourceType, datasourceUID, template, domain, client)
@ -85,6 +87,55 @@ func buildPanel(
} }
// evaluateCondition проверяет условие отображения панели через реестр // evaluateCondition проверяет условие отображения панели через реестр
// applyRPSThreshold подставляет rps_limit и rps_commercial_limit клиента в threshold панели rps.
// Красная линия — rps_limit (если 0, берётся из шаблона).
// Синяя линия — rps_commercial_limit (если 0, ставится 100).
func applyRPSThreshold(panel map[string]interface{}, panelKey string, rpsLimit, rpsCommercialLimit int) {
if panelKey != "rps" {
return
}
// Ничего менять не нужно если оба лимита не заданы
if rpsLimit == 0 && rpsCommercialLimit == 0 {
return
}
fc, ok := panel["fieldConfig"].(map[string]interface{})
if !ok {
return
}
defaults, ok := fc["defaults"].(map[string]interface{})
if !ok {
return
}
thresholds, ok := defaults["thresholds"].(map[string]interface{})
if !ok {
return
}
// Коммерческий лимит: если не задан — 100
commercial := rpsCommercialLimit
if commercial == 0 {
commercial = 100
}
steps := []interface{}{
map[string]interface{}{"color": "green", "value": nil},
map[string]interface{}{"color": "blue", "value": commercial},
}
if rpsLimit > 0 {
steps = append(steps, map[string]interface{}{"color": "dark-red", "value": rpsLimit})
}
thresholds["steps"] = steps
// Включаем отображение линий если ещё не задано
custom, ok := defaults["custom"].(map[string]interface{})
if ok {
if _, hasStyle := custom["thresholdsStyle"]; !hasStyle {
custom["thresholdsStyle"] = map[string]interface{}{"mode": "line"}
}
}
}
func evaluateCondition(condition *PanelCondition, client ClientData, domain *DomainInfo) bool { func evaluateCondition(condition *PanelCondition, client ClientData, domain *DomainInfo) bool {
if condition == nil { if condition == nil {
return true return true
@ -295,6 +346,29 @@ func buildSumExpressionTargets(cfg map[string]interface{}, sidQuery, datasourceT
} }
} }
// buildStaticTargets: панель без подстановки SID из БД.
// targets берутся из query_config.targets как есть — для глобальных/обзорных панелей.
// Поддерживает произвольные bucket_aggs из шаблона.
func buildStaticTargets(cfg map[string]interface{}, _ string, datasourceType, datasourceUID string, _ *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
targetsRaw, ok := cfg["targets"].([]interface{})
if !ok || len(targetsRaw) == 0 {
return []interface{}{}
}
// Копируем targets как есть, только проставляем datasource
targets := []interface{}{}
for _, t := range targetsRaw {
target := deepCopy(t).(map[string]interface{})
if _, hasDatasource := target["datasource"]; !hasDatasource {
target["datasource"] = map[string]interface{}{
"type": datasourceType,
"uid": datasourceUID,
}
}
targets = append(targets, target)
}
return targets
}
func buildBucketLogsTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, _ *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} { func buildBucketLogsTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, _ *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} {
query := strings.ReplaceAll(getString(cfg, "base_query"), "{sid}", sidQuery) query := strings.ReplaceAll(getString(cfg, "base_query"), "{sid}", sidQuery)
return []interface{}{ return []interface{}{

View file

@ -11,14 +11,16 @@ import (
) )
type State struct { type State struct {
LastRun time.Time `json:"last_run"` LastRun time.Time `json:"last_run"`
TemplateCommit string `json:"template_commit"` TemplateCommit string `json:"template_commit"`
TemplateVersion string `json:"template_version"` TemplateVersion string `json:"template_version"`
ClientsHash string `json:"clients_hash"` ClientsHash string `json:"clients_hash"`
ClientCount int `json:"client_count"` ClientCount int `json:"client_count"`
DomainCount int `json:"domain_count"` DomainCount int `json:"domain_count"`
SIDs []string `json:"sids"` SIDs []string `json:"sids"`
LastChangeReason string `json:"last_change_reason,omitempty"` LastChangeReason string `json:"last_change_reason,omitempty"`
AlertsHash string `json:"alerts_hash,omitempty"` // Хэш лимитов алертов из БД
AlertTemplateHash string `json:"alert_template_hash,omitempty"` // Хэш файла alert_rules_template.json
} }
type StateManager struct { type StateManager struct {
@ -151,6 +153,7 @@ func calculateClientsHash(clients map[string]ClientData) string {
// Create a deterministic representation // Create a deterministic representation
type hashData struct { type hashData struct {
ClientTitle string ClientTitle string
RPSLimit int
Domains []struct { Domains []struct {
SID string SID string
DomainName string DomainName string
@ -160,7 +163,7 @@ func calculateClientsHash(clients map[string]ClientData) string {
var data []hashData var data []hashData
for _, client := range clients { for _, client := range clients {
hd := hashData{ClientTitle: client.ClientTitle} hd := hashData{ClientTitle: client.ClientTitle, RPSLimit: client.RPSLimit}
for _, domain := range client.Domains { for _, domain := range client.Domains {
hd.Domains = append(hd.Domains, struct { hd.Domains = append(hd.Domains, struct {
SID string SID string
@ -190,6 +193,65 @@ func calculateClientsHash(clients map[string]ClientData) string {
return fmt.Sprintf("%x", hash) return fmt.Sprintf("%x", hash)
} }
// calculateAlertsHash считает хэш только от данных нужных для алертов (client_title + rps_limit)
func calculateAlertsHash(clients map[string]ClientData) string {
type alertData struct {
ClientTitle string
RPSLimit int
Limit4xx int
Limit5xx int
}
var data []alertData
for _, client := range clients {
if client.RPSLimit > 0 || client.Limit4xx > 0 || client.Limit5xx > 0 {
data = append(data, alertData{
ClientTitle: client.ClientTitle,
RPSLimit: client.RPSLimit,
Limit4xx: client.Limit4xx,
Limit5xx: client.Limit5xx,
})
}
}
sort.Slice(data, func(i, j int) bool {
return data[i].ClientTitle < data[j].ClientTitle
})
jsonData, _ := json.Marshal(data)
hash := sha256.Sum256(jsonData)
return fmt.Sprintf("%x", hash)
}
// AlertTemplateChanged проверяет изменился ли файл alert_rules_template.json
func (sm *StateManager) AlertTemplateChanged(templateHash string) bool {
if sm.state == nil {
return true
}
return sm.state.AlertTemplateHash != templateHash
}
// UpdateAlertTemplateHash сохраняет хэш шаблона алертов
func (sm *StateManager) UpdateAlertTemplateHash(templateHash string) {
if sm.state == nil {
sm.state = &State{}
}
sm.state.AlertTemplateHash = templateHash
}
// AlertsNeedUpdate проверяет изменился ли хэш алертов
func (sm *StateManager) AlertsNeedUpdate(clients map[string]ClientData) bool {
if sm.state == nil {
return true
}
return sm.state.AlertsHash != calculateAlertsHash(clients)
}
// UpdateAlertsHash сохраняет хэш алертов в state
func (sm *StateManager) UpdateAlertsHash(clients map[string]ClientData) {
if sm.state == nil {
sm.state = &State{}
}
sm.state.AlertsHash = calculateAlertsHash(clients)
}
func extractSIDs(clients map[string]ClientData) []string { func extractSIDs(clients map[string]ClientData) []string {
sidMap := make(map[string]bool) sidMap := make(map[string]bool)
for _, client := range clients { for _, client := range clients {

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -23,6 +24,32 @@ func loadTemplate(templatesPath string) (*DashboardTemplate, error) {
return &template, nil return &template, nil
} }
func loadAlertTemplate(templatesPath string) (*AlertRulesTemplate, error) {
templateFile := filepath.Join(templatesPath, "alert_rules_template.json")
data, err := os.ReadFile(templateFile)
if err != nil {
return nil, fmt.Errorf("failed to read alert template file: %w", err)
}
var tmpl AlertRulesTemplate
if err := json.Unmarshal(data, &tmpl); err != nil {
return nil, fmt.Errorf("failed to parse alert template JSON: %w", err)
}
return &tmpl, nil
}
// hashAlertTemplate считает SHA256 от содержимого alert_rules_template.json
func hashAlertTemplate(templatesPath string) string {
data, err := os.ReadFile(filepath.Join(templatesPath, "alert_rules_template.json"))
if err != nil {
return ""
}
hash := sha256.Sum256(data)
return fmt.Sprintf("%x", hash)
}
func deepCopy(src interface{}) interface{} { func deepCopy(src interface{}) interface{} {
data, _ := json.Marshal(src) data, _ := json.Marshal(src)
var dst interface{} var dst interface{}