diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c8c5c41..75e691c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,32 +10,33 @@ PostgreSQL sp_info + manual_info (UNION) apps_settings - client_info + client_info (waf_provider IS NOT NULL) │ ▼ fetchClientsData() - map[clientTitle]ClientData + map[clientTitle]ClientData{WafProvider} │ - ├──────────────────────────────────────────────────────┐ - │ │ - ▼ ▼ - generateSingleDashboard() generateAndSendAlerts() (OS) - generateVLDashboard() generateAndSendAlertsVL() (VL) - │ │ - ▼ ▼ - dashboard_template.json alert_rules_template.json - (Git-репозиторий) (Git-репозиторий) - │ │ - ▼ ▼ - POST /api/dashboards/db POST/PUT /api/v1/provisioning/alert-rules - (Grafana API) (Grafana Provisioning API, параллельно x5) - │ │ - ▼ ▼ - state.json ──── clients_hash state.json ──── alerts_hash - └─── alert_template_hash + ├──────────────────────────────────────────────────────────────────┐ + │ │ + spikClients (waf_provider=spik) spClients (waf_provider=sp) + │ │ + ├──────────────────────┐ ┌──────────────────────────┤ + │ │ │ │ + ▼ ▼ ▼ ▼ +generateVLDashboard() generateAndSendAlertsVL() generateVLDashboard() generateAndSendAlertsVL() +(SPIK дашборд) (группа: PTAF Grafana) (SP дашборд) (группа: SP PTAF Grafana) + (receiver: tg+mail+...) (receiver: For_SP) + │ │ │ │ + ▼ └────────────────┘ │ +dashboard_template.json activeUIDs (spik+SP+static) │ + │ │ │ + ▼ ▼ │ +POST /api/dashboards/db deleteObsoleteAlerts() POST /api/dashboards/db + state.json ── alerts_hash + └── alert_template_hash ``` -Генерация дашбордов и алертов для OS и VL управляется независимо через переменные окружения `DASHBOARD_OS_ENABLED`, `DASHBOARD_VL_ENABLED`, `ALERTS_OS_ENABLED`, `ALERTS_VL_ENABLED`. +Генерация дашбордов и алертов управляется независимо через переменные окружения `DASHBOARD_OS_ENABLED`, `DASHBOARD_VL_ENABLED`, `ALERTS_OS_ENABLED`, `ALERTS_VL_ENABLED`. --- @@ -43,15 +44,15 @@ PostgreSQL | Файл | Назначение | |---|---| -| `main.go` | Точка входа, оркестрация всего потока | +| `main.go` | Точка входа, оркестрация всего потока, разделение клиентов по `waf_provider` | | `config.go` | Константы, структуры `Config`, `ClientData`, `DomainInfo`, `DashboardTemplate` | -| `database.go` | Подключение к PostgreSQL, `fetchClientsData()` | +| `database.go` | Подключение к PostgreSQL, `fetchClientsData()`, чтение `waf_provider` | | `dashboard.go` | Генерация JSON дашборда (OpenSearch) из шаблона и данных клиентов | | `dashboard_vl.go` | Генерация JSON дашборда для VictoriaLogs | | `panels.go` | Построение панелей (OpenSearch), реестр query-режимов, `applyRPSThreshold()` | | `panels_vl.go` | Построение панелей для VictoriaLogs, LogsQL query builders | -| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), параллельный upsert | -| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs | +| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), `deleteObsoleteAlerts()` | +| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs, возвращает список активных UID | | `template_validator.go` | Валидация и автоисправление критичных настроек шаблона | | `grafana.go` | HTTP-клиент для Grafana API (дашборды, папки) | | `template.go` | Загрузка `dashboard_template.json` и `alert_rules_template.json` | @@ -67,7 +68,7 @@ PostgreSQL ### Источник данных -Данные всегда берутся из **обеих** таблиц одновременно через `UNION`: +Данные берутся из **обеих** таблиц одновременно через `UNION`. Читаются только клиенты у которых `waf_provider IS NOT NULL`: ```sql SELECT ... FROM ( @@ -77,6 +78,7 @@ SELECT ... FROM ( ) s LEFT JOIN apps_settings a ON s.sid = a.l7resourceid LEFT JOIN client_info ci ON a.client_title = ci.client_title +WHERE ci.waf_provider IS NOT NULL ``` Дубликаты по SID схлопываются автоматически (`UNION` без `ALL`). @@ -98,19 +100,35 @@ LEFT JOIN client_info ci ON a.client_title = ci.client_title - `rps_commercial_limit` — коммерческий лимит RPS для синей линии на графике (NULL = 100) - `four_hundred` — порог 4xx ошибок в % (NULL = алерт не создавать) - `five_hundred` — порог 5xx ошибок в % (NULL = алерт не создавать) +- `waf_provider` — тип провайдера WAF, определяет в какой дашборд и группу алертов попадает клиент: + - `spik` — основной VL дашборд + алерты в группе `PTAF Grafana` + - `sp` — отдельный SP дашборд + алерты в группе `SP PTAF Grafana` с contact point `For_SP` + - `NULL` — клиент игнорируется полностью + +### Разделение клиентов по waf_provider + +После загрузки данных `main.go` разделяет клиентов на две группы: + +```go +spikClients := map[string]ClientData{} // waf_provider = "spik" +spClients := map[string]ClientData{} // waf_provider = "sp" +``` + +Хэш для обнаружения изменений алертов считается только от `spikClients`. ### Пример добавления нового клиента ```sql --- 1. Ресурс появится автоматически из sp_info +-- 1. Ресурс появится автоматически из sp_info (или добавить в manual_info) -- 2. Привязать к клиенту INSERT INTO apps_settings (l7resourceid, client_title) VALUES ('SID12345', 'Название клиента'); --- 3. Задать лимиты для алертов (опционально) -INSERT INTO client_info (client_title, rps_limit, rps_commercial_limit, four_hundred, five_hundred) -VALUES ('Название клиента', 1800, 1200, 20, 10); +-- 3. Задать провайдера и лимиты +INSERT INTO client_info (client_title, waf_provider, rps_limit, rps_commercial_limit, four_hundred, five_hundred) +VALUES ('Название клиента', 'spik', 1800, 1200, 20, 10); +-- или для SP клиента: waf_provider = 'sp' ``` --- @@ -135,7 +153,7 @@ VALUES ('Название клиента', 1800, 1200, 20, 10); "status_codes": { "query_mode": "multi_target", "vl_interval": "1m", ... }, "response_time": { "query_mode": "range_percent", "vl_interval": "1m", ... }, "traffic_combined": { "query_mode": "multi_sum_expression", "vl_interval": "1m", ... }, - "overview_status_codes": { "query_mode": "bucket_logs", ... }, + "overview_status_codes": { "query_mode": "bucket_logs", ... }, "overview_tenant_per_nodes": { "query_mode": "bucket_logs", ... }, "overview_tenant_per_node": { "query_mode": "bucket_logs", ... }, "overview_uri_top15": { "query_mode": "bucket_logs", ... }, @@ -231,11 +249,18 @@ VALUES ('Название клиента', 1800, 1200, 20, 10); ### OpenSearch дашборд -Создаётся один дашборд со всеми клиентами через `generateSingleDashboard()`. +Создаётся через `generateSingleDashboard()` для `spikClients`. -### VictoriaLogs дашборд +### VictoriaLogs дашборды -Создаётся через `generateVLDashboard()` при наличии `VL_DATASOURCE_UID`. Использует те же панели и layout что и OS дашборд, но targets строятся через `queryModeRegistryVL` с LogsQL запросами. +Генерируются через `generateVLDashboard()` при наличии `VL_DATASOURCE_UID`: + +| Дашборд | Клиенты | UID | Папка | +|---|---|---|---| +| `PT AF Requests (VictoriaLogs)` | `spikClients` | `pt-af-requests-auto-vl` | `WAF - PTAF` | +| `SP PT AF Requests (VictoriaLogs)` | `spClients` | `pt-af-requests-auto-sp` | `WAF - PTAF` | + +SP дашборд генерируется только если `SP_DASHBOARD_TITLE` задан и есть клиенты с `waf_provider=sp`. **Особенности VL дашборда:** @@ -244,8 +269,6 @@ VALUES ('Название клиента', 1800, 1200, 20, 10); - `interval: "1m"` из поля `vl_interval` шаблона панели - `timeShift: "1m"` для обрезки неполной последней минуты - Math expressions для RPS: `$A / $__interval_ms * 1000` -- Math expressions для response time: `$ref / $total * 100` -- Transformation `renameByRegex` для очистки суффиксов в легенде **Структура дашборда:** @@ -256,36 +279,24 @@ VALUES ('Название клиента', 1800, 1200, 20, 10); ├─────────────────────────────────────────────────┤ │ ▶ PTAF-Nginx (свёрнутая строка) │ │ Response_status_code SID │ -│ Request Count by TENANT per nodes │ -│ Request Count by TENANT per node │ +│ Request Count by TENANT per nodes/node │ │ URI top 15 │ │ Response status code by Container │ │ Nginx vs Angie Status Codes │ ├─────────────────────────────────────────────────┤ │ ▶ Angie (свёрнутая строка) │ │ Errors by Apps │ -│ Errors by Upstream Path (app: ${error_server})│ +│ Errors by Upstream Path │ │ Errors by Node │ ├─────────────────────────────────────────────────┤ -│ ▼ Клиент A (развёрнутая строка) │ +│ ▼ Клиент A │ │ Status Codes | RPS | Total Traffic │ │ [domain1] Status Codes | Response Time | Traffic │ -│ [domain2] ... │ ├─────────────────────────────────────────────────┤ -│ ▶ Клиент B (свёрнутая строка) │ -│ ... │ +│ ▶ Клиент B ... │ └─────────────────────────────────────────────────┘ ``` -### Threshold на графике RPS - -На панель RPS автоматически накладываются цветные линии из `client_info`: - -| Линия | Значение | Источник | -|---|---|---| -| Синяя | `rps_commercial_limit` | `client_info.rps_commercial_limit` (NULL → 100) | -| Красная | `rps_limit` | `client_info.rps_limit` (0 → линия не рисуется) | - --- ## Алерты @@ -314,32 +325,43 @@ VL алерты имеют `noDataState: "NoData"` — при отсутстви UID каждого алерта генерируется детерминированно: `md5(client_title + "_vl:" + type)[:8]`. При повторном запуске алерт обновляется (`PUT`), а не создаётся заново. +**Разделение по waf_provider:** + +| Клиенты | Группа алертов | Contact point | Папка | +|---|---|---|---| +| `spikClients` | `PTAF Grafana` | `tg+mail+mattermost PTAF Grafana` | `WAF - PTAF` | +| `spClients` | `SP PTAF Grafana` | `For_SP` | `WAF - PTAF` | + ### Статические алерты (всегда присутствуют) -Описаны в `alert_rules_template.json` в секции `static_alerts`, не зависят от БД: +Описаны в `alert_rules_template.json`, не зависят от БД. Отправляются в группе `[SYS] PTAF Grafana` первыми (до динамических): | Алерт | Условие | for | |---|---|---| -| Use in / | диск `/` > 75% | 3m | -| Use in /var/log | диск `/var/log` > 75% | 5m | -| CPU Busy | CPU > 75% | 3m | -| RAM Busy | RAM > 75% | 3m | -| Состояние контейнеров | `docker_container_status == 0` | 2m | -| Упала Angie | `angie.service` не active | 1m | +| `[SYS] Use in / 75%` | диск `/` > 75% | 3m | +| `[SYS] Use in / 90%` | диск `/` > 90% | 1m | +| `[SYS] Use in /var/log 75%` | диск `/var/log` > 75% | 5m | +| `[SYS] Use in /var/log 90%` | диск `/var/log` > 90% | 1m | +| `[SYS] CPU Busy 75%` | CPU > 75% | 3m | +| `[SYS] CPU Busy 90%` | CPU > 90% | 1m | +| `[SYS] RAM Busy 75%` | RAM > 75% | 3m | +| `[SYS] RAM Busy 90%` | RAM > 90% | 1m | +| `[SYS] Состояние контейнеров` | контейнер упал | 2m | +| `[SYS] Упала Angie` | `angie.service` не active | 1m | + +Если `ALERTS_OS_ENABLED=false` — статические алерты отправляются через VL путь. + +### Удаление устаревших алертов + +После каждой генерации `deleteObsoleteAlerts()` получает все алерты из папки `WAF - PTAF` и удаляет те, чьи UID не входят в актуальный набор (spik + SP + статические). Это автоматически удаляет алерты клиентов которые были удалены из БД или у которых изменился `waf_provider`. ### Параллельная отправка -Все алерты (динамические + статические) отправляются параллельно через горутины с ограничением **5 одновременных запросов** к Grafana API: - -``` -[RPS client1] [RPS client2] [4xx client1] [5xx client2] [static: CPU] ← 5 горутин - ↓ освободился слот - [static: RAM] ... -``` +Все алерты отправляются параллельно через горутины с ограничением **5 одновременных запросов** к Grafana API. ### Подавление DatasourceNoData алертов -VL алерты при отсутствии данных переходят в состояние `NoData` (не `Alerting`). Для подавления уведомлений об этом состоянии в Grafana создан Silence с матчером `alertname = DatasourceNoData`. +VL алерты при отсутствии данных переходят в состояние `NoData`. Для подавления уведомлений в Grafana создан Silence с матчером `alertname = DatasourceNoData`. --- @@ -348,7 +370,7 @@ VL алерты при отсутствии данных переходят в `template_validator.go` запускается автоматически после загрузки шаблона и проверяет/исправляет: - `vl_interval: "1m"` для панелей: rps, rps_multi, status_codes, response_time, traffic_combined, traffic_combined_total -- `spanNulls: true` для панели status_codes +- `spanNulls: 180000` для панели status_codes - `vl_base_query` с `TENANT:in(${tenant:csv})` для `overview_tenant_per_nodes` - `vl_base_query` с `node_name:in(${node_name:csv})` для `overview_tenant_per_node` - Layout tenant_panels в три столбца по 8 (status_codes + rps/rps_multi + traffic_combined_total) @@ -360,8 +382,6 @@ VL алерты при отсутствии данных переходят в Путь по умолчанию: `/var/lib/grafana_gen/state.json` -Хранит хэши для обнаружения изменений между запусками: - ```json { "last_run": "2026-02-25T10:00:00Z", @@ -387,7 +407,7 @@ VL алерты при отсутствии данных переходят в ничего не изменилось → выход без действий (если не указан -force) ``` -Хэш алертов сохраняется после успешного выполнения независимо от того какие системы алертов включены (OS/VL). +`alerts_hash` считается только от `spikClients` и сохраняется после успешной отправки. --- @@ -404,7 +424,7 @@ VL алерты при отсутствии данных переходят в ### Пути к .env файлу (перебираются по порядку) -1. `$GRAFANA_GEN_ENV_FILE` (переменная окружения) +1. `$GRAFANA_GEN_ENV_FILE` 2. `/etc/grafana_gen/grafana_gen.env` 3. `./grafana_gen.env` 4. `./.env` @@ -420,10 +440,10 @@ DB_PASSWORD=secret GRAFANA_URL=https://grafana.example.com GRAFANA_API_KEY=glsa-xxxxxxxxxxxxxxxxxxxx -# Git (Gitea) +# Git GIT_TOKEN=your-gitea-token -# Алерты (OpenSearch) +# Алерты OpenSearch ALERTS_DATASOURCE_UID=af84zsvlp9blsa # VictoriaLogs @@ -431,6 +451,12 @@ VL_DATASOURCE_UID=efewdonokxybkf VL_DASHBOARD_TITLE=PT AF Requests (VictoriaLogs) VL_GRAFANA_FOLDER=WAF - PTAF +# SP дашборд и алерты +SP_DASHBOARD_TITLE=SP PT AF Requests (VictoriaLogs) +SP_DASHBOARD_UID=pt-af-requests-auto-sp +SP_ALERTS_GROUP=SP PTAF Grafana +SP_ALERTS_RECEIVER=For_SP + # Управление генерацией DASHBOARD_OS_ENABLED=false DASHBOARD_VL_ENABLED=true @@ -451,16 +477,21 @@ ALERTS_VL_ENABLED=true | `-db-name` | — | `waf_info` | PostgreSQL база данных | | `-grafana-url` | `GRAFANA_URL` | — | URL Grafana | | `-grafana-api-key` | `GRAFANA_API_KEY` | — | Grafana API ключ | -| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для дашбордов | -| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Nodes` | Название дашборда | +| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для OS дашборда | +| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Requests` | Название OS дашборда | | `-alerts-folder` | `ALERTS_FOLDER` | `WAF - PTAF` | Папка для алертов | -| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Получатель уведомлений | -| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов | +| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Contact point для SPIK алертов | +| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов для SPIK | | `-alerts-datasource-uid` | `ALERTS_DATASOURCE_UID` | `af84zsvlp9blsa` | UID datasource OpenSearch для алертов | -| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource (пустая = не генерировать VL) | -| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название VL дашборда | -| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для VL дашборда (пустая = как у OS) | -| `-vl-dashboard-uid` | `VL_DASHBOARD_UID` | `pt-af-requests-auto-vl` | UID VL дашборда в Grafana | +| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource | +| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название SPIK VL дашборда | +| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для SPIK VL дашборда | +| `-vl-dashboard-uid` | `VL_DASHBOARD_UID` | `pt-af-requests-auto-vl` | UID SPIK VL дашборда | +| `-sp-dashboard-title` | `SP_DASHBOARD_TITLE` | `SP PT AF Requests (VictoriaLogs)` | Название SP дашборда | +| `-sp-dashboard-uid` | `SP_DASHBOARD_UID` | `pt-af-requests-auto-sp` | UID SP дашборда | +| `-sp-grafana-folder` | `SP_GRAFANA_FOLDER` | — | Папка для SP дашборда | +| — | `SP_ALERTS_GROUP` | `SP PTAF Grafana` | Группа алертов для SP клиентов | +| — | `SP_ALERTS_RECEIVER` | `For_SP` | Contact point для SP алертов | | `-git-token` | `GIT_TOKEN` | — | Gitea токен | | `-templates-repo` | — | `svc-git.cirex.ru/...` | URL Git-репозитория шаблонов | | `-templates-branch` | — | `master` | Ветка шаблонов | @@ -489,10 +520,10 @@ ALERTS_VL_ENABLED=true # Без обновления шаблонов из Git ./grafana_gen -skip-git-pull -# Только VL дашборд и VL алерты +# Только VL дашборды и VL алерты DASHBOARD_OS_ENABLED=false ALERTS_OS_ENABLED=false ./grafana_gen -# Явное указание всех параметров +# Явное указание параметров ./grafana_gen \ -db-host=10.100.10.8 \ -db-user=reader \ diff --git a/alerts.go b/alerts.go index 0274bc8..765124a 100644 --- a/alerts.go +++ b/alerts.go @@ -74,7 +74,7 @@ func buildRPSAlertRule(client ClientData, tmpl AlertRulesTemplate, config Config // Получаем настройки из шаблона defaults := tmpl.Defaults receiver := orDefault(config.AlertsReceiver, defaults.Receiver) - group := orDefault(config.AlertsGroup, defaults.Group) + group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle) timeRangeFrom := int64(1800) if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok { @@ -341,36 +341,56 @@ func generateAndSendAlerts(clients map[string]ClientData, tmpl AlertRulesTemplat 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), - }) + // WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой) + if t.client.PtafFallbackCode != "" && strings.ToLower(t.client.PtafFallbackCode) != "pass" { + for _, domain := range t.client.Domains { + rule := buildWAFBlockAlertRule(t.client, domain, tmpl, config) + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("%s WAF %s / %s SID:%s", t.client.PtafFallbackCode, t.client.ClientTitle, domain.DomainName, domain.SID), + }) + } } - 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), - }) - } - } - // Статические алерты — отправляются первыми (появляются в начале списка) - if err := sendStaticAlerts(tmpl, config, dryRun); err != nil { - log.Printf("Warning: static alerts error: %v", err) + // Error rate алерты — пороги берутся из каждого домена (apps_settings) + // Генерируем для response_status_code и upstream_status_code + for _, domain := range t.client.Domains { + for _, statusField := range []string{"response_status_code", "upstream_status_code"} { + if domain.Limit4xx > 0 { + for _, rule := range buildErrorRateAlertRules(t.client, domain, 4, domain.Limit4xx, statusField, tmpl, config) { + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("4xx %s %s / %s SID:%s", statusField, t.client.ClientTitle, domain.DomainName, domain.SID), + }) + } + } + if domain.Limit5xx > 0 { + for _, rule := range buildErrorRateAlertRules(t.client, domain, 5, domain.Limit5xx, statusField, tmpl, config) { + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("5xx %s %s / %s SID:%s", statusField, t.client.ClientTitle, domain.DomainName, domain.SID), + }) + } + } + } + } } log.Printf("Alerts: sending %d dynamic rules (concurrency: 5)", len(tasks)) - sent, failed := parallelUpsert(tasks, config, dryRun, 5) + sent, failed := parallelUpsert(tasks, config, dryRun, 20) 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) + } + // Обновляем шаблон сообщения Contact Point if err := upsertContactPoint(tmpl, config, dryRun); err != nil { log.Printf("Warning: failed to update contact point message template: %v", err) @@ -379,29 +399,13 @@ func generateAndSendAlerts(clients map[string]ClientData, tmpl AlertRulesTemplat 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{} { +// buildErrorRateAlertRules строит алерт на процент 4xx или 5xx ответов для конкретного домена. +// statusField: "response_status_code" или "upstream_status_code" +func buildErrorRateAlertRules(client ClientData, domain DomainInfo, errCode, limitPct int, statusField string, 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 ") + group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle) codeRange := "[400 TO 499]" codeName := "4xx" @@ -410,151 +414,180 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler codeName = "5xx" } - uid := generateAlertUID(client.ClientTitle, codeName) - totalRefID := "Total" - errRefID := "Errors" - totalReduceID := "_TotalReduce" - errReduceID := "_ErrorsReduce" - percentRefID := "Значение" - thresholdRefID := "Превышение" - - summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct) - - relFrom := int64(900) - forDuration := "1m" if errCode == 4 { forDuration = "3m" } - 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": forDuration, - "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", - }, + relFrom := int64(900) + + var rules []map[string]interface{} + + { + sidQuery := "SID:" + domain.SID + fieldShort := "response" + if statusField == "upstream_status_code" { + fieldShort = "upstream" + } + uid := generateAlertUID(client.ClientTitle+"_"+domain.SID+"_"+fieldShort, codeName) + title := fmt.Sprintf("%s %s %s / %s SID:%s", codeName, fieldShort, client.ClientTitle, domain.DomainName, domain.SID) + fieldDesc := "ответ от PTAF, не от origin" + if statusField == "upstream_status_code" { + fieldDesc = "ответ от origin" + } + summary := fmt.Sprintf( + "🔴 %s / %s (SID:%s) — %s %s ошибок трафика держится > %s.", + client.ClientTitle, domain.DomainName, domain.SID, codeName, fieldShort, forDuration, + ) + thresholdAnnotation := fmt.Sprintf(">%d%% для %s (%s)", limitPct, statusField, fieldDesc) + fieldInfoAnnotation := fmt.Sprintf("для %s (%s)", statusField, fieldDesc) + + totalRefID := "Total" + errRefID := "Errors" + totalReduceID := "_TotalReduce" + errReduceID := "_ErrorsReduce" + percentRefID := "Значение" + thresholdRefID := "Превышение" + + rule := map[string]interface{}{ + "uid": uid, + "title": title, + "condition": thresholdRefID, + "noDataState": defaults.NoDataState, + "execErrState": defaults.ExecErrState, + "isPaused": false, + "folderUID": "", + "ruleGroup": group, + "for": forDuration, + "annotations": map[string]string{ + "summary": summary, + "client_domain": fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID), + "code_field": fmt.Sprintf("%s %s", codeName, fieldShort), + "for_duration": forDuration, + "threshold": thresholdAnnotation, + "field_info": fieldInfoAnnotation, }, - // 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", - }, + "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("%s:%s AND %s", statusField, 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", }, - "datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"}, - "expression": percentRefID, - "intervalMs": 1000, - "maxDataPoints": 43200, - "refId": thresholdRefID, - "type": "threshold", }, }, - }, + } + rules = append(rules, rule) } + + return rules } // sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД) @@ -600,7 +633,7 @@ func sendStaticAlerts(tmpl AlertRulesTemplate, config Config, dryRun bool) error tasks = append(tasks, alertTask{rule: rule, label: title}) } - sent, failed := parallelUpsert(tasks, config, dryRun, 5) + sent, failed := parallelUpsert(tasks, config, dryRun, 20) log.Printf("Static alerts: sent=%d, failed=%d", sent, failed) return nil } @@ -780,63 +813,110 @@ func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) err return fmt.Errorf("failed to parse contact points: %w", err) } - // Ищем contact point с нужным именем - var target map[string]interface{} + // Ищем все telegram интеграции с нужным именем и обновляем message + var updated int for _, cp := range contactPoints { - if name, _ := cp["name"].(string); name == receiverName { - target = cp - break + name, _ := cp["name"].(string) + if name != receiverName { + continue } + // Обновляем все типы интеграций (telegram, email, slack и т.д.) + uid, _ := cp["uid"].(string) + cpType, _ := cp["type"].(string) + settings, ok := cp["settings"].(map[string]interface{}) + if !ok { + settings = make(map[string]interface{}) + cp["settings"] = settings + } + // Поле для шаблона зависит от типа интеграции + switch cpType { + case "slack": + settings["text"] = tmpl.ContactPoint.MessageTemplate + default: // telegram, email, oncall, mattermost и др. + settings["message"] = tmpl.ContactPoint.MessageTemplate + } + + if dryRun { + data, _ := json.MarshalIndent(cp, "", " ") + log.Printf("DRY RUN: Contact point update for '%s' (uid: %s):\n%s", receiverName, uid, string(data)) + updated++ + continue + } + + payload, _ := json.Marshal(cp) + updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload)) + updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey) + updReq.Header.Set("Content-Type", "application/json") + updReq.Header.Set("X-Disable-Provenance", "true") + + updResp, err := http.DefaultClient.Do(updReq) + if err != nil { + log.Printf("Warning: failed to update contact point '%s' (uid: %s): %v", receiverName, uid, err) + continue + } + updResp.Body.Close() + updated++ + log.Printf("Contact point '%s' (uid: %s) message template updated", receiverName, uid) } - if target == nil { - log.Printf("Contact point '%s' not found, skipping message template update", receiverName) - return nil + if updated == 0 { + log.Printf("No telegram integrations found for contact point '%s'", receiverName) } - - uid, _ := target["uid"].(string) - - // Contact point — одиночный объект с полем type и settings - if cpType, _ := target["type"].(string); cpType != "telegram" { - log.Printf("Contact point '%s' is not Telegram (type: %s), skipping", receiverName, cpType) - return nil - } - - settings, ok := target["settings"].(map[string]interface{}) - if !ok { - settings = make(map[string]interface{}) - target["settings"] = settings - } - settings["message"] = tmpl.ContactPoint.MessageTemplate - - if dryRun { - data, _ := json.MarshalIndent(target, "", " ") - log.Printf("DRY RUN: Contact point update for '%s':\n%s", receiverName, string(data)) - return nil - } - - // PUT /api/v1/provisioning/contact-points/{uid} - payload, _ := json.Marshal(target) - updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload)) - updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey) - updReq.Header.Set("Content-Type", "application/json") - updReq.Header.Set("X-Disable-Provenance", "true") - - updResp, err := http.DefaultClient.Do(updReq) - if err != nil { - return fmt.Errorf("failed to update contact point: %w", err) - } - defer updResp.Body.Close() - - respBody, _ := io.ReadAll(updResp.Body) - if updResp.StatusCode < 200 || updResp.StatusCode >= 300 { - return fmt.Errorf("contact point update failed: status %d: %s", updResp.StatusCode, string(respBody)) - } - - log.Printf("Contact point '%s' message template updated", receiverName) return nil } +// deleteObsoleteAlerts удаляет алерты из папки которые не входят в актуальный набор UID. +func deleteObsoleteAlerts(activeUIDs map[string]bool, folderUID string, config Config) { + baseURL := strings.TrimRight(config.GrafanaURL, "/") + + req, err := http.NewRequest("GET", baseURL+"/api/v1/provisioning/alert-rules", nil) + if err != nil { + log.Printf("Warning: failed to list alert rules: %v", err) + return + } + req.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("Warning: failed to list alert rules: %v", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var rules []map[string]interface{} + if err := json.Unmarshal(body, &rules); err != nil { + log.Printf("Warning: failed to parse alert rules list: %v", err) + return + } + + deleted := 0 + for _, rule := range rules { + uid, _ := rule["uid"].(string) + ruleFolderUID, _ := rule["folderUID"].(string) + if ruleFolderUID != folderUID { + continue + } + if activeUIDs[uid] { + continue + } + delReq, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s", baseURL, uid), nil) + delReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey) + delResp, err := http.DefaultClient.Do(delReq) + if err != nil { + log.Printf("Warning: failed to delete alert %s: %v", uid, err) + continue + } + delResp.Body.Close() + title, _ := rule["title"].(string) + log.Printf("Deleted obsolete alert: %s (%s)", title, uid) + deleted++ + } + if deleted > 0 { + log.Printf("Alerts: deleted %d obsolete rules", deleted) + } +} + // orDefault возвращает val если не пустой, иначе def func orDefault(val, def string) string { if val != "" { @@ -844,3 +924,113 @@ func orDefault(val, def string) string { } return def } + +// buildWAFBlockAlertRule строит алерт на количество 418 ответов за минуту (OpenSearch). +func buildWAFBlockAlertRule(client ClientData, domain DomainInfo, tmpl AlertRulesTemplate, config Config) map[string]interface{} { + datasourceUID := config.AlertsDatasourceUID + defaults := tmpl.Defaults + receiver := orDefault(config.AlertsReceiver, defaults.Receiver) + group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle) + + uid := generateAlertUID(client.ClientTitle+"_"+domain.SID, "waf_block") + title := fmt.Sprintf("%s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID) + + clientDomain := fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID) + summary := fmt.Sprintf("🟡 %s — %s ошибка WAF - кол-во штук за 1 минуту.", clientDomain, client.PtafFallbackCode) + thresholdAnnotation := fmt.Sprintf(">%d штук(и) для response_status_code (ответ от PTAF, не от origin)", domain.PtafFallbackCodeAlertCount) + + sidQuery := "SID:" + domain.SID + dataRefID := "A" + reduceRefID := "_Reduce" + thresholdRefID := "Превышение" + relFrom := int64(120) + + return map[string]interface{}{ + "uid": uid, + "title": title, + "condition": thresholdRefID, + "noDataState": defaults.NoDataState, + "execErrState": defaults.ExecErrState, + "isPaused": false, + "folderUID": "", + "ruleGroup": group, + "for": "1m", + "annotations": map[string]string{ + "summary": summary, + "client_domain": clientDomain, + "code_field": fmt.Sprintf("%s WAF", client.PtafFallbackCode), + "for_duration": "1m", + "threshold": thresholdAnnotation, + "field_info": "для response_status_code (ответ от PTAF, не от origin)", + "runbook": "Проверить логи docker, процитировать ошибку и эскалировать на аналитика.", + "severity": "warning", + "value_unit": "шт.", + }, + "notification_settings": map[string]string{"receiver": receiver}, + "data": []interface{}{ + map[string]interface{}{ + "refId": dataRefID, + "queryType": "lucene", + "relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0}, + "datasourceUid": datasourceUID, + "model": map[string]interface{}{ + "datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID}, + "query": fmt.Sprintf("response_status_code:%s AND %s", client.PtafFallbackCode, sidQuery), + "luceneQueryType": "Metric", + "timeField": "@timestamp", + "metrics": []interface{}{ + map[string]interface{}{"id": "1", "type": "count"}, + }, + "bucketAggs": []interface{}{ + map[string]interface{}{ + "id": "2", + "type": "date_histogram", + "field": "@timestamp", + "settings": map[string]interface{}{ + "interval": "1m", + "min_doc_count": "1", + }, + }, + }, + "refId": dataRefID, + }, + }, + map[string]interface{}{ + "refId": reduceRefID, + "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": dataRefID, + "intervalMs": 1000, + "maxDataPoints": 43200, + "reducer": "last", + "refId": reduceRefID, + "type": "reduce", + }, + }, + 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{}{domain.PtafFallbackCodeAlertCount, 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", + }, + }, + }, + } +} diff --git a/alerts_vl.go b/alerts_vl.go index d920d23..2b8d4c7 100644 --- a/alerts_vl.go +++ b/alerts_vl.go @@ -26,7 +26,7 @@ func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Conf defaults := tmpl.Defaults receiver := orDefault(config.AlertsReceiver, defaults.Receiver) - group := orDefault(config.AlertsGroup, defaults.Group) + group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle) timeRangeFrom := int64(1800) if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok { @@ -182,16 +182,15 @@ func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Conf } } -// buildErrorRateAlertRuleVL строит правило алерта на процент ошибок для VictoriaLogs. -func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} { +// buildErrorRateAlertRulesVL строит алерт на процент ошибок для конкретного домена (VictoriaLogs). +// statusField: "response_status_code" или "upstream_status_code" +func buildErrorRateAlertRulesVL(client ClientData, domain DomainInfo, errCode, limitPct int, statusField string, tmpl AlertRulesTemplate, config Config) []map[string]interface{} { datasourceUID := config.VLDatasourceUID datasourceType := DefaultVLDatasourceType defaults := tmpl.Defaults receiver := orDefault(config.AlertsReceiver, defaults.Receiver) - group := orDefault(config.AlertsGroup, defaults.Group) - - sidQuery := buildTenantSIDQueryVL(client.Domains) + group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle) codeFrom, codeTo := 400, 499 codeName := "4xx" @@ -200,143 +199,173 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al codeName = "5xx" } - uid := generateAlertUID(client.ClientTitle+"_vl", codeName) - totalRefID := "Total" - errRefID := "Errors" - totalReduceID := "_TotalReduce" - errReduceID := "_ErrorsReduce" - percentRefID := "Значение" - thresholdRefID := "Превышение" - - summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct) - - relFrom := int64(900) - forDuration := "1m" if errCode == 4 { forDuration = "3m" } - totalExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:1m) count() hits", sidQuery) - errExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF response_status_code:>=%d response_status_code:<=%d | stats by (_time:1m) count() hits", sidQuery, codeFrom, codeTo) + relFrom := int64(900) - makeVLModel := func(refID, expr, legend string) map[string]interface{} { - return map[string]interface{}{ - "datasource": map[string]interface{}{ - "type": datasourceType, - "uid": datasourceUID, - }, - "editorMode": "code", - "expr": expr, - "legendFormat": legend, - "maxLines": 1000, - "queryType": "statsRange", - "refId": refID, - "intervalMs": 60000, - "maxDataPoints": 43200, + var rules []map[string]interface{} + + { + sidQuery := fmt.Sprintf("SID:%s", domain.SID) + // Короткое имя поля для использования в названии алерта + fieldShort := "response" + if statusField == "upstream_status_code" { + fieldShort = "upstream" } - } - - makeReduce := func(refID, expression, reducer string) map[string]interface{} { - return map[string]interface{}{ - "datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"}, - "expression": expression, - "intervalMs": 1000, - "maxDataPoints": 43200, - "reducer": reducer, - "refId": refID, - "type": "reduce", + uid := generateAlertUID(client.ClientTitle+"_vl_"+domain.SID+"_"+fieldShort, codeName) + title := fmt.Sprintf("VL %s %s %s / %s SID:%s", codeName, fieldShort, client.ClientTitle, domain.DomainName, domain.SID) + fieldDesc := "ответ от PTAF, не от origin" + if statusField == "upstream_status_code" { + fieldDesc = "ответ от origin" } - } + summary := fmt.Sprintf( + "🔴 %s / %s (SID:%s) — %s %s ошибок трафика держится > %s.", + client.ClientTitle, domain.DomainName, domain.SID, codeName, fieldShort, forDuration, + ) + thresholdAnnotation := fmt.Sprintf(">%d%% для %s (%s)", limitPct, statusField, fieldDesc) + fieldInfoAnnotation := fmt.Sprintf("для %s (%s)", statusField, fieldDesc) - return map[string]interface{}{ - "uid": uid, - "title": fmt.Sprintf("VL %s %s", codeName, client.ClientTitle), - "condition": thresholdRefID, - "noDataState": "NoData", // VL: нет данных = нет алерта - "execErrState": "Error", // VL: ошибка datasource = Error, не Alerting - "isPaused": false, - "folderUID": "", - "ruleGroup": group, - "for": forDuration, - "annotations": map[string]string{"summary": summary}, - "notification_settings": map[string]string{"receiver": receiver}, - "data": []interface{}{ - map[string]interface{}{ - "refId": totalRefID, - "queryType": "statsRange", - "relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0}, - "datasourceUid": datasourceUID, - "model": makeVLModel(totalRefID, totalExpr, "Total"), - }, - map[string]interface{}{ - "refId": errRefID, - "queryType": "statsRange", - "relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0}, - "datasourceUid": datasourceUID, - "model": makeVLModel(errRefID, errExpr, codeName), - }, - map[string]interface{}{ - "refId": totalReduceID, - "relativeTimeRange": map[string]interface{}{"from": 0, "to": 0}, - "datasourceUid": "__expr__", - "model": makeReduce(totalReduceID, totalRefID, "sum"), - }, - map[string]interface{}{ - "refId": errReduceID, - "relativeTimeRange": map[string]interface{}{"from": 0, "to": 0}, - "datasourceUid": "__expr__", - "model": makeReduce(errReduceID, errRefID, "sum"), - }, - 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", + totalRefID := "Total" + errRefID := "Errors" + totalReduceID := "_TotalReduce" + errReduceID := "_ErrorsReduce" + percentRefID := "Значение" + thresholdRefID := "Превышение" + + totalExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:1m) count() hits", sidQuery) + errExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF %s:>=%d %s:<=%d | stats by (_time:1m) count() hits", sidQuery, statusField, codeFrom, statusField, codeTo) + + makeVLModel := func(refID, expr, legend string) map[string]interface{} { + return map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": datasourceType, + "uid": datasourceUID, }, + "editorMode": "code", + "expr": expr, + "legendFormat": legend, + "maxLines": 1000, + "queryType": "statsRange", + "refId": refID, + "intervalMs": 60000, + "maxDataPoints": 43200, + } + } + + makeReduce := func(refID, expression, reducer string) map[string]interface{} { + return map[string]interface{}{ + "datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"}, + "expression": expression, + "intervalMs": 1000, + "maxDataPoints": 43200, + "reducer": reducer, + "refId": refID, + "type": "reduce", + } + } + + rule := map[string]interface{}{ + "uid": uid, + "title": title, + "condition": thresholdRefID, + "noDataState": "NoData", // VL: нет данных = нет алерта + "execErrState": "Error", // VL: ошибка datasource = Error, не Alerting + "isPaused": false, + "folderUID": "", + "ruleGroup": group, + "for": forDuration, + "annotations": map[string]string{ + "summary": summary, + "client_domain": fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID), + "code_field": fmt.Sprintf("%s %s", codeName, fieldShort), + "for_duration": forDuration, + "threshold": thresholdAnnotation, + "field_info": fieldInfoAnnotation, }, - 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", - }, + "notification_settings": map[string]string{"receiver": receiver}, + "data": []interface{}{ + map[string]interface{}{ + "refId": totalRefID, + "queryType": "statsRange", + "relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0}, + "datasourceUid": datasourceUID, + "model": makeVLModel(totalRefID, totalExpr, "Total"), + }, + map[string]interface{}{ + "refId": errRefID, + "queryType": "statsRange", + "relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0}, + "datasourceUid": datasourceUID, + "model": makeVLModel(errRefID, errExpr, codeName), + }, + map[string]interface{}{ + "refId": totalReduceID, + "relativeTimeRange": map[string]interface{}{"from": 0, "to": 0}, + "datasourceUid": "__expr__", + "model": makeReduce(totalReduceID, totalRefID, "sum"), + }, + map[string]interface{}{ + "refId": errReduceID, + "relativeTimeRange": map[string]interface{}{"from": 0, "to": 0}, + "datasourceUid": "__expr__", + "model": makeReduce(errReduceID, errRefID, "sum"), + }, + 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", + }, + }, + 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", }, - "datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"}, - "expression": percentRefID, - "intervalMs": 1000, - "maxDataPoints": 43200, - "refId": thresholdRefID, - "type": "threshold", }, }, - }, + } + rules = append(rules, rule) } + + return rules } // generateAndSendAlertsVL генерирует и отправляет алерты для VictoriaLogs. -func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTemplate, config Config, dryRun bool) error { +func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTemplate, config Config, dryRun bool) ([]string, error) { if config.VLDatasourceUID == "" { log.Printf("Skipping VL alerts: VLDatasourceUID not configured") - return nil + return nil, nil } folderUID, err := ensureAlertFolder(config) if err != nil { - return fmt.Errorf("failed to ensure alert folder: %w", err) + return nil, fmt.Errorf("failed to ensure alert folder: %w", err) } var tasks []alertTask @@ -356,34 +385,167 @@ func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTempl }) } - // Error rate алерты (4xx и 5xx) - for _, errCode := range []int{4, 5} { - limitPct := client.Limit4xx - if errCode == 5 { - limitPct = client.Limit5xx + // WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой) + if client.PtafFallbackCode != "" && strings.ToLower(client.PtafFallbackCode) != "pass" { + for _, domain := range client.Domains { + rule := buildWAFBlockAlertRuleVL(client, domain, tmpl, config) + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("VL %s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID), + }) } - if limitPct <= 0 { - continue + } + + // Error rate алерты — пороги берутся из каждого домена (apps_settings) + // Генерируем для response_status_code и upstream_status_code + for _, domain := range client.Domains { + for _, statusField := range []string{"response_status_code", "upstream_status_code"} { + if domain.Limit4xx > 0 { + for _, rule := range buildErrorRateAlertRulesVL(client, domain, 4, domain.Limit4xx, statusField, tmpl, config) { + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("VL 4xx %s %s / %s SID:%s", statusField, client.ClientTitle, domain.DomainName, domain.SID), + }) + } + } + if domain.Limit5xx > 0 { + for _, rule := range buildErrorRateAlertRulesVL(client, domain, 5, domain.Limit5xx, statusField, tmpl, config) { + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("VL 5xx %s %s / %s SID:%s", statusField, client.ClientTitle, domain.DomainName, domain.SID), + }) + } + } } - rule := buildErrorRateAlertRuleVL(client, errCode, limitPct, tmpl, config) - rule["folderUID"] = folderUID - tasks = append(tasks, alertTask{ - rule: rule, - label: fmt.Sprintf("VL %dxx %s", errCode, client.ClientTitle), - }) } } - sent, failed := parallelUpsert(tasks, config, dryRun, 5) + // Обновляем шаблон contact point (если OS алерты отключены — делаем здесь) + if !config.AlertsOSEnabled { + if err := upsertContactPoint(tmpl, config, dryRun); err != nil { + log.Printf("Warning: failed to update contact point: %v", err) + } + } + + sent, failed := parallelUpsert(tasks, config, dryRun, 20) log.Printf("VL Alerts: sent=%d failed=%d", sent, failed) - // Статические алерты (CPU, RAM, диск, контейнеры, Angie) - // Отправляем только если OS алерты отключены, иначе они уже отправлены там - if !config.AlertsOSEnabled { - if err := sendStaticAlerts(tmpl, config, dryRun); err != nil { - log.Printf("Warning: VL static alerts error: %v", err) + // Статические алерты отправляются отдельно из main.go чтобы избежать + // дублирования при вызове функции для разных провайдеров (spik/sp) + + // Собираем активные UID для возврата + activeUIDs := []string{} + for _, task := range tasks { + if uid, ok := task.rule["uid"].(string); ok { + activeUIDs = append(activeUIDs, uid) } } - return nil + return activeUIDs, nil +} + +// buildWAFBlockAlertRuleVL строит алерт на количество 418 ответов за минуту (VictoriaLogs). +func buildWAFBlockAlertRuleVL(client ClientData, domain DomainInfo, tmpl AlertRulesTemplate, config Config) map[string]interface{} { + datasourceUID := config.VLDatasourceUID + datasourceType := DefaultVLDatasourceType + + defaults := tmpl.Defaults + receiver := orDefault(config.AlertsReceiver, defaults.Receiver) + group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle) + + uid := generateAlertUID(client.ClientTitle+"_vl_"+domain.SID, "waf_block") + title := fmt.Sprintf("VL %s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID) + + clientDomain := fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID) + summary := fmt.Sprintf("🟡 %s — %s ошибка WAF - кол-во штук за 1 минуту.", clientDomain, client.PtafFallbackCode) + thresholdAnnotation := fmt.Sprintf(">%d штук(и) для response_status_code (ответ от PTAF, не от origin)", domain.PtafFallbackCodeAlertCount) + + expr := fmt.Sprintf("SID:%s log_type:nginx-access-PTAF response_status_code:=%s | stats by (_time:1m) count() hits", domain.SID, client.PtafFallbackCode) + + dataRefID := "A" + reduceRefID := "_Reduce" + thresholdRefID := "Превышение" + relFrom := int64(120) + + return map[string]interface{}{ + "uid": uid, + "title": title, + "condition": thresholdRefID, + "noDataState": "NoData", + "execErrState": "Error", + "isPaused": false, + "folderUID": "", + "ruleGroup": group, + "for": "1m", + "annotations": map[string]string{ + "summary": summary, + "client_domain": clientDomain, + "code_field": fmt.Sprintf("%s WAF", client.PtafFallbackCode), + "for_duration": "1m", + "threshold": thresholdAnnotation, + "field_info": "для response_status_code (ответ от PTAF, не от origin)", + "runbook": "Проверить логи docker, процитировать ошибку и эскалировать на аналитика.", + "severity": "warning", + "value_unit": "шт.", + }, + "notification_settings": map[string]string{"receiver": receiver}, + "data": []interface{}{ + map[string]interface{}{ + "refId": dataRefID, + "queryType": "statsRange", + "relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0}, + "datasourceUid": datasourceUID, + "model": map[string]interface{}{ + "datasource": map[string]interface{}{"type": datasourceType, "uid": datasourceUID}, + "editorMode": "code", + "expr": expr, + "legendFormat": fmt.Sprintf("418 %s", domain.DomainName), + "maxLines": 1000, + "queryType": "statsRange", + "refId": dataRefID, + "intervalMs": 60000, + "maxDataPoints": 43200, + }, + }, + map[string]interface{}{ + "refId": reduceRefID, + "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": dataRefID, + "intervalMs": 1000, + "maxDataPoints": 43200, + "reducer": "last", + "refId": reduceRefID, + "type": "reduce", + }, + }, + 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{}{domain.PtafFallbackCodeAlertCount, 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", + }, + }, + }, + } } diff --git a/config.go b/config.go index 80cf2d2..17b55f0 100644 --- a/config.go +++ b/config.go @@ -71,6 +71,11 @@ const ( DefaultVLGrafanaFolder = "" // Пустая = та же папка что и основная DefaultVLDatasourceType = "victoriametrics-logs-datasource-ptaf-sw" // Тип VL datasource плагина DefaultVLDashboardUID = "pt-af-requests-auto-vl" // UID второго дашборда + DefaultSPDashboardTitle = "SP PT AF Requests (VictoriaLogs)" // Название SP дашборда + DefaultSPDashboardUID = "pt-af-requests-auto-sp" // UID SP дашборда + DefaultSPGrafanaFolder = "" // Пустая = та же папка что и основная + DefaultSPAlertsGroup = "SP PTAF Grafana" // Группа алертов для SP + DefaultSPAlertsReceiver = "For_SP" // Contact point для SP алертов // DefaultDashboardTitle - название единого дашборда со всеми клиентами // Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes @@ -114,6 +119,11 @@ type Config struct { VLDashboardTitle string // Название второго дашборда (пустая строка = не генерировать) VLGrafanaFolder string // Папка для VL дашборда (пустая = та же что и основная) VLDashboardUID string // UID второго дашборда в Grafana + SPDashboardTitle string // Название SP дашборда (пустая строка = не генерировать) + SPDashboardUID string // UID SP дашборда в Grafana + SPGrafanaFolder string // Папка для SP дашборда (пустая = та же что и основная) + SPAlertsGroup string // Группа алертов для SP клиентов + SPAlertsReceiver string // Contact point для SP алертов } type ClientData struct { @@ -121,14 +131,18 @@ type ClientData struct { Domains []DomainInfo RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД) RPSCommercialLimit int // коммерческий лимит, при NULL берётся 100 - Limit4xx int // порог 4xx ответов в % (0 = алерт не создавать) - Limit5xx int // порог 5xx ответов в % (0 = алерт не создавать) + + WafProvider string // waf_provider из client_info (spik, sp, ...) + PtafFallbackCode string // HTTP код блокировки PTAF из client_info (напр. "418"), "pass" = алерт не нужен } type DomainInfo struct { - SID string - DomainName string - Aliases string + SID string + DomainName string + Aliases string + Limit4xx int // порог 4xx ошибок в % (из apps_settings, default 20) + Limit5xx int // порог 5xx ошибок в % (из apps_settings, default 10) + PtafFallbackCodeAlertCount int // порог 418 ошибок в штуках (из apps_settings, default 1) } type DashboardTemplate struct { diff --git a/database.go b/database.go index 2273cfa..01e26a1 100644 --- a/database.go +++ b/database.go @@ -38,8 +38,11 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) { COALESCE(s.aliases::text, 'null') as aliases, 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 + COALESCE(a.four_hundred, 20) as limit_4xx, + COALESCE(a.five_hundred, 10) as limit_5xx, + COALESCE(a.ptaf_fallback_code_alert_count, 1) as ptaf_fallback_code_alert_count, + COALESCE(MAX(ci.waf_provider) OVER (PARTITION BY a.client_title), '') as waf_provider, + COALESCE(MAX(ci.ptaf_fallback_code) OVER (PARTITION BY a.client_title), '') as ptaf_fallback_code FROM ( SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL UNION @@ -47,6 +50,7 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) { ) s LEFT JOIN apps_settings a ON s.sid = a.l7resourceid LEFT JOIN client_info ci ON a.client_title = ci.client_title + WHERE ci.waf_provider IS NOT NULL ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid ` @@ -59,11 +63,10 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) { clients := make(map[string]ClientData) for rows.Next() { - var clientTitle, sid, domainName, aliasesJSON string - var rpsLimit, rpsCommercialLimit int - var limit4xx, limit5xx sql.NullInt64 + var clientTitle, sid, domainName, aliasesJSON, wafProvider, ptafFallbackCode string + var rpsLimit, rpsCommercialLimit, limit4xx, limit5xx, ptafFallbackCodeAlertCount int - if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON, &rpsLimit, &rpsCommercialLimit, &limit4xx, &limit5xx); err != nil { + if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON, &rpsLimit, &rpsCommercialLimit, &limit4xx, &limit5xx, &ptafFallbackCodeAlertCount, &wafProvider, &ptafFallbackCode); err != nil { return nil, fmt.Errorf("scan failed: %w", err) } @@ -76,16 +79,19 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) { Domains: []DomainInfo{}, RPSLimit: rpsLimit, RPSCommercialLimit: rpsCommercialLimit, - Limit4xx: nullIntToInt(limit4xx), - Limit5xx: nullIntToInt(limit5xx), + WafProvider: wafProvider, + PtafFallbackCode: ptafFallbackCode, } } client := clients[clientTitle] client.Domains = append(client.Domains, DomainInfo{ - SID: sid, - DomainName: domainName, - Aliases: aliasesStr, + SID: sid, + DomainName: domainName, + Aliases: aliasesStr, + Limit4xx: limit4xx, + Limit5xx: limit5xx, + PtafFallbackCodeAlertCount: ptafFallbackCodeAlertCount, }) clients[clientTitle] = client } @@ -93,14 +99,6 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) { 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 в строку // Input: ["domain1.com", "domain2.com"] или null // Output: "domain1.com, domain2.com" или "" diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aeddeae --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/main.go b/main.go index 8538998..3c4768b 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,26 @@ import ( "flag" "log" "os" + "strings" ) +// makeSPAlertsConfig создаёт конфиг для SP алертов с отдельной группой и contact point. +func makeSPAlertsConfig(config Config) Config { + spConfig := config + spConfig.AlertsGroup = config.SPAlertsGroup + spConfig.AlertsReceiver = config.SPAlertsReceiver + return spConfig +} + +// makeSPConfig создаёт конфиг для SP дашборда на основе основного конфига. +func makeSPConfig(config Config) Config { + spConfig := config + spConfig.VLDashboardTitle = config.SPDashboardTitle + spConfig.VLDashboardUID = config.SPDashboardUID + spConfig.VLGrafanaFolder = config.SPGrafanaFolder + return spConfig +} + func main() { // Load environment variables from .env file before parsing flags // This allows .env file to provide defaults that can be overridden by flags @@ -95,9 +113,21 @@ func main() { os.Exit(0) } + // Разделяем клиентов по waf_provider + spikClients := map[string]ClientData{} + spClients := map[string]ClientData{} + for k, v := range validClients { + switch strings.ToLower(v.WafProvider) { + case "spik": + spikClients[k] = v + case "sp": + spClients[k] = v + } + } + // Хэш шаблона алертов — считается независимо от дашборда alertTemplateHash := hashAlertTemplate(config.TemplatesPath) - alertsChanged := stateManager.AlertsNeedUpdate(validClients) || stateManager.AlertTemplateChanged(alertTemplateHash) + alertsChanged := stateManager.AlertsNeedUpdate(spikClients) || stateManager.AlertTemplateChanged(alertTemplateHash) // Check for changes hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients) @@ -124,11 +154,11 @@ func main() { log.Printf("Force run enabled - regenerating dashboard") } - // Generate single dashboard with all clients + // Generate single dashboard with spik clients log.Printf("Generating single dashboard: %s", config.DashboardTitle) - log.Printf("Including %d clients with %d total domains", len(validClients), countTotalDomains(validClients)) + log.Printf("Including %d spik clients with %d total domains", len(spikClients), countTotalDomains(spikClients)) - dashboard := generateSingleDashboard(validClients, template, config) + dashboard := generateSingleDashboard(spikClients, template, config) if config.DryRun { if config.DashboardOSEnabled { @@ -137,19 +167,24 @@ func main() { if alertTmpl != nil { log.Printf("DRY RUN: Generating alert rules preview...") if config.AlertsOSEnabled { - _ = generateAndSendAlerts(validClients, *alertTmpl, config, true) + _ = generateAndSendAlerts(spikClients, *alertTmpl, config, true) } if config.AlertsVLEnabled { - _ = generateAndSendAlertsVL(validClients, *alertTmpl, config, true) + _, _ = generateAndSendAlertsVL(spikClients, *alertTmpl, config, true) } } log.Printf("=== Summary ===") - log.Printf("Clients: %d", len(validClients)) + log.Printf("Spik clients: %d, SP clients: %d", len(spikClients), len(spClients)) log.Printf("Skipped: %d", skippedCount) log.Printf("DRY RUN: Dashboard generated but not sent to Grafana") if config.VLDatasourceUID != "" && config.DashboardVLEnabled { - vlDashboard := generateVLDashboard(validClients, template, config) + vlDashboard := generateVLDashboard(spikClients, template, config) logDryRun(config.VLDashboardTitle, vlDashboard) + if len(spClients) > 0 && config.SPDashboardTitle != "" { + spConfig := makeSPConfig(config) + spDashboard := generateVLDashboard(spClients, template, spConfig) + logDryRun(config.SPDashboardTitle, spDashboard) + } } os.Exit(0) } @@ -159,7 +194,7 @@ func main() { if err := sendToGrafana(dashboard, config); err != nil { log.Printf("Error sending dashboard: %v", err) log.Printf("=== Summary ===") - log.Printf("Clients: %d", len(validClients)) + log.Printf("Spik clients: %d", len(spikClients)) log.Printf("Skipped: %d", skippedCount) log.Printf("Status: FAILED") os.Exit(1) @@ -169,10 +204,10 @@ func main() { log.Printf("Dashboard OS: disabled (DASHBOARD_OS_ENABLED=false)") } - // Generate VictoriaLogs dashboard if configured + // Generate VictoriaLogs dashboard for spik clients if config.VLDatasourceUID != "" && config.DashboardVLEnabled { log.Printf("Generating VictoriaLogs dashboard: %s", config.VLDashboardTitle) - vlDashboard := generateVLDashboard(validClients, template, config) + vlDashboard := generateVLDashboard(spikClients, template, config) if err := sendVLDashboardToGrafana(vlDashboard, config); err != nil { log.Printf("Warning: VictoriaLogs dashboard failed: %v", err) } else { @@ -182,29 +217,74 @@ func main() { log.Printf("Dashboard VL: disabled (DASHBOARD_VL_ENABLED=false)") } + // Generate SP VictoriaLogs dashboard for sp clients + if config.VLDatasourceUID != "" && config.SPDashboardTitle != "" && len(spClients) > 0 { + log.Printf("Generating SP VictoriaLogs dashboard: %s (%d clients)", config.SPDashboardTitle, len(spClients)) + spConfig := makeSPConfig(config) + spDashboard := generateVLDashboard(spClients, template, spConfig) + if err := sendVLDashboardToGrafana(spDashboard, spConfig); err != nil { + log.Printf("Warning: SP dashboard failed: %v", err) + } else { + log.Printf("Successfully created/updated SP dashboard: %s", config.SPDashboardTitle) + } + } + // Generate and send alert rules if alertTmpl != nil { if alertsChanged || config.ForceRun { - osOK := true + osErr := false + vlErr := false if config.AlertsOSEnabled { - if err := generateAndSendAlerts(validClients, *alertTmpl, config, false); err != nil { + if err := generateAndSendAlerts(spikClients, *alertTmpl, config, false); err != nil { log.Printf("Warning: alerts generation failed: %v", err) - osOK = false + osErr = true } } else { log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)") } + allActiveUIDs := map[string]bool{} if config.AlertsVLEnabled { - if err := generateAndSendAlertsVL(validClients, *alertTmpl, config, false); err != nil { + spikUIDs, err := generateAndSendAlertsVL(spikClients, *alertTmpl, config, false) + if err != nil { log.Printf("Warning: VL alerts generation failed: %v", err) - osOK = false + vlErr = true + } + for _, uid := range spikUIDs { + allActiveUIDs[uid] = true + } + // SP алерты — для sp клиентов с отдельным contact point и группой + if len(spClients) > 0 { + spAlertsConfig := makeSPAlertsConfig(config) + spUIDs, err := generateAndSendAlertsVL(spClients, *alertTmpl, spAlertsConfig, false) + if err != nil { + log.Printf("Warning: SP VL alerts generation failed: %v", err) + } + for _, uid := range spUIDs { + allActiveUIDs[uid] = true + } + } + // Статические алерты — отправляем один раз с основным конфигом + // (не с SP конфигом, чтобы попали в правильную группу [SYS] PTAF Grafana) + if !config.AlertsOSEnabled { + if err := sendStaticAlerts(*alertTmpl, config, false); err != nil { + log.Printf("Warning: VL static alerts error: %v", err) + } + for _, a := range alertTmpl.StaticAlerts { + if uid, ok := a["uid"].(string); ok { + allActiveUIDs[uid] = true + } + } + } + if folderUID, err := ensureAlertFolder(config); err == nil { + deleteObsoleteAlerts(allActiveUIDs, folderUID, config) } } else { log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)") } - // Сохраняем хэш если хотя бы одна из систем алертов не вернула ошибку - if osOK { - stateManager.UpdateAlertsHash(validClients) + // Сохраняем хэш если не было критических ошибок + // Частичные ошибки отдельных алертов не блокируют сохранение хэша + if !osErr && !vlErr { + stateManager.UpdateAlertsHash(spikClients) stateManager.UpdateAlertTemplateHash(alertTemplateHash) } } else { @@ -215,7 +295,7 @@ func main() { // Summary log.Printf("=== Summary ===") log.Printf("Dashboard: %s", config.DashboardTitle) - log.Printf("Clients: %d", len(validClients)) + log.Printf("Spik clients: %d, SP clients: %d", len(spikClients), len(spClients)) if skippedCount > 0 { log.Printf("Skipped: %d", skippedCount) } @@ -291,6 +371,16 @@ func parseFlags() Config { flag.StringVar(&config.VLGrafanaFolder, "vl-grafana-folder", vlGrafanaFolder, "Grafana folder for VictoriaLogs dashboard (empty = same as main)") flag.StringVar(&config.VLDashboardUID, "vl-dashboard-uid", vlDashboardUID, "VictoriaLogs dashboard UID in Grafana") + // SP dashboard flags + spDashboardTitle := getEnvOrDefault("SP_DASHBOARD_TITLE", DefaultSPDashboardTitle) + spDashboardUID := getEnvOrDefault("SP_DASHBOARD_UID", DefaultSPDashboardUID) + spGrafanaFolder := getEnvOrDefault("SP_GRAFANA_FOLDER", DefaultSPGrafanaFolder) + flag.StringVar(&config.SPDashboardTitle, "sp-dashboard-title", spDashboardTitle, "SP VictoriaLogs dashboard title") + flag.StringVar(&config.SPDashboardUID, "sp-dashboard-uid", spDashboardUID, "SP VictoriaLogs dashboard UID in Grafana") + flag.StringVar(&config.SPGrafanaFolder, "sp-grafana-folder", spGrafanaFolder, "Grafana folder for SP dashboard (empty = same as main)") + config.SPAlertsGroup = getEnvOrDefault("SP_ALERTS_GROUP", DefaultSPAlertsGroup) + config.SPAlertsReceiver = getEnvOrDefault("SP_ALERTS_RECEIVER", DefaultSPAlertsReceiver) + // Other flags 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") diff --git a/state.go b/state.go index 9236319..5b2e05c 100644 --- a/state.go +++ b/state.go @@ -193,22 +193,33 @@ func calculateClientsHash(clients map[string]ClientData) string { return fmt.Sprintf("%x", hash) } -// calculateAlertsHash считает хэш только от данных нужных для алертов (client_title + rps_limit) +// calculateAlertsHash считает хэш от данных нужных для алертов (rps_limit + пороги per-SID) func calculateAlertsHash(clients map[string]ClientData) string { + type domainAlert struct { + SID string + Limit4xx int + Limit5xx int + } type alertData struct { ClientTitle string RPSLimit int - Limit4xx int - Limit5xx int + Domains []domainAlert } var data []alertData for _, client := range clients { - if client.RPSLimit > 0 || client.Limit4xx > 0 || client.Limit5xx > 0 { + var domains []domainAlert + for _, d := range client.Domains { + domains = append(domains, domainAlert{ + SID: d.SID, + Limit4xx: d.Limit4xx, + Limit5xx: d.Limit5xx, + }) + } + if client.RPSLimit > 0 || len(domains) > 0 { data = append(data, alertData{ ClientTitle: client.ClientTitle, RPSLimit: client.RPSLimit, - Limit4xx: client.Limit4xx, - Limit5xx: client.Limit5xx, + Domains: domains, }) } }