Change alerts
This commit is contained in:
parent
63351eb754
commit
02b262681e
8 changed files with 996 additions and 498 deletions
191
ARCHITECTURE.md
191
ARCHITECTURE.md
|
|
@ -10,32 +10,33 @@
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
sp_info + manual_info (UNION)
|
sp_info + manual_info (UNION)
|
||||||
apps_settings
|
apps_settings
|
||||||
client_info
|
client_info (waf_provider IS NOT NULL)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
fetchClientsData()
|
fetchClientsData()
|
||||||
map[clientTitle]ClientData
|
map[clientTitle]ClientData{WafProvider}
|
||||||
│
|
│
|
||||||
├──────────────────────────────────────────────────────┐
|
├──────────────────────────────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
▼ ▼
|
spikClients (waf_provider=spik) spClients (waf_provider=sp)
|
||||||
generateSingleDashboard() generateAndSendAlerts() (OS)
|
|
||||||
generateVLDashboard() generateAndSendAlertsVL() (VL)
|
|
||||||
│ │
|
│ │
|
||||||
▼ ▼
|
├──────────────────────┐ ┌──────────────────────────┤
|
||||||
dashboard_template.json alert_rules_template.json
|
│ │ │ │
|
||||||
(Git-репозиторий) (Git-репозиторий)
|
▼ ▼ ▼ ▼
|
||||||
│ │
|
generateVLDashboard() generateAndSendAlertsVL() generateVLDashboard() generateAndSendAlertsVL()
|
||||||
▼ ▼
|
(SPIK дашборд) (группа: PTAF Grafana) (SP дашборд) (группа: SP PTAF Grafana)
|
||||||
POST /api/dashboards/db POST/PUT /api/v1/provisioning/alert-rules
|
(receiver: tg+mail+...) (receiver: For_SP)
|
||||||
(Grafana API) (Grafana Provisioning API, параллельно x5)
|
│ │ │ │
|
||||||
│ │
|
▼ └────────────────┘ │
|
||||||
▼ ▼
|
dashboard_template.json activeUIDs (spik+SP+static) │
|
||||||
state.json ──── clients_hash state.json ──── alerts_hash
|
│ │ │
|
||||||
└─── alert_template_hash
|
▼ ▼ │
|
||||||
|
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` |
|
| `config.go` | Константы, структуры `Config`, `ClientData`, `DomainInfo`, `DashboardTemplate` |
|
||||||
| `database.go` | Подключение к PostgreSQL, `fetchClientsData()` |
|
| `database.go` | Подключение к PostgreSQL, `fetchClientsData()`, чтение `waf_provider` |
|
||||||
| `dashboard.go` | Генерация JSON дашборда (OpenSearch) из шаблона и данных клиентов |
|
| `dashboard.go` | Генерация JSON дашборда (OpenSearch) из шаблона и данных клиентов |
|
||||||
| `dashboard_vl.go` | Генерация JSON дашборда для VictoriaLogs |
|
| `dashboard_vl.go` | Генерация JSON дашборда для VictoriaLogs |
|
||||||
| `panels.go` | Построение панелей (OpenSearch), реестр query-режимов, `applyRPSThreshold()` |
|
| `panels.go` | Построение панелей (OpenSearch), реестр query-режимов, `applyRPSThreshold()` |
|
||||||
| `panels_vl.go` | Построение панелей для VictoriaLogs, LogsQL query builders |
|
| `panels_vl.go` | Построение панелей для VictoriaLogs, LogsQL query builders |
|
||||||
| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), параллельный upsert |
|
| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), `deleteObsoleteAlerts()` |
|
||||||
| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs |
|
| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs, возвращает список активных UID |
|
||||||
| `template_validator.go` | Валидация и автоисправление критичных настроек шаблона |
|
| `template_validator.go` | Валидация и автоисправление критичных настроек шаблона |
|
||||||
| `grafana.go` | HTTP-клиент для Grafana API (дашборды, папки) |
|
| `grafana.go` | HTTP-клиент для Grafana API (дашборды, папки) |
|
||||||
| `template.go` | Загрузка `dashboard_template.json` и `alert_rules_template.json` |
|
| `template.go` | Загрузка `dashboard_template.json` и `alert_rules_template.json` |
|
||||||
|
|
@ -67,7 +68,7 @@ PostgreSQL
|
||||||
|
|
||||||
### Источник данных
|
### Источник данных
|
||||||
|
|
||||||
Данные всегда берутся из **обеих** таблиц одновременно через `UNION`:
|
Данные берутся из **обеих** таблиц одновременно через `UNION`. Читаются только клиенты у которых `waf_provider IS NOT NULL`:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT ... FROM (
|
SELECT ... FROM (
|
||||||
|
|
@ -77,6 +78,7 @@ SELECT ... FROM (
|
||||||
) s
|
) s
|
||||||
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
||||||
LEFT JOIN client_info ci ON a.client_title = ci.client_title
|
LEFT JOIN client_info ci ON a.client_title = ci.client_title
|
||||||
|
WHERE ci.waf_provider IS NOT NULL
|
||||||
```
|
```
|
||||||
|
|
||||||
Дубликаты по SID схлопываются автоматически (`UNION` без `ALL`).
|
Дубликаты по 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)
|
- `rps_commercial_limit` — коммерческий лимит RPS для синей линии на графике (NULL = 100)
|
||||||
- `four_hundred` — порог 4xx ошибок в % (NULL = алерт не создавать)
|
- `four_hundred` — порог 4xx ошибок в % (NULL = алерт не создавать)
|
||||||
- `five_hundred` — порог 5xx ошибок в % (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
|
```sql
|
||||||
-- 1. Ресурс появится автоматически из sp_info
|
-- 1. Ресурс появится автоматически из sp_info (или добавить в manual_info)
|
||||||
|
|
||||||
-- 2. Привязать к клиенту
|
-- 2. Привязать к клиенту
|
||||||
INSERT INTO apps_settings (l7resourceid, client_title)
|
INSERT INTO apps_settings (l7resourceid, client_title)
|
||||||
VALUES ('SID12345', 'Название клиента');
|
VALUES ('SID12345', 'Название клиента');
|
||||||
|
|
||||||
-- 3. Задать лимиты для алертов (опционально)
|
-- 3. Задать провайдера и лимиты
|
||||||
INSERT INTO client_info (client_title, rps_limit, rps_commercial_limit, four_hundred, five_hundred)
|
INSERT INTO client_info (client_title, waf_provider, rps_limit, rps_commercial_limit, four_hundred, five_hundred)
|
||||||
VALUES ('Название клиента', 1800, 1200, 20, 10);
|
VALUES ('Название клиента', 'spik', 1800, 1200, 20, 10);
|
||||||
|
-- или для SP клиента: waf_provider = 'sp'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -231,11 +249,18 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
|
||||||
|
|
||||||
### OpenSearch дашборд
|
### 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 дашборда:**
|
**Особенности VL дашборда:**
|
||||||
|
|
||||||
|
|
@ -244,8 +269,6 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
|
||||||
- `interval: "1m"` из поля `vl_interval` шаблона панели
|
- `interval: "1m"` из поля `vl_interval` шаблона панели
|
||||||
- `timeShift: "1m"` для обрезки неполной последней минуты
|
- `timeShift: "1m"` для обрезки неполной последней минуты
|
||||||
- Math expressions для RPS: `$A / $__interval_ms * 1000`
|
- 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 (свёрнутая строка) │
|
│ ▶ PTAF-Nginx (свёрнутая строка) │
|
||||||
│ Response_status_code SID │
|
│ Response_status_code SID │
|
||||||
│ Request Count by TENANT per nodes │
|
│ Request Count by TENANT per nodes/node │
|
||||||
│ Request Count by TENANT per node │
|
|
||||||
│ URI top 15 │
|
│ URI top 15 │
|
||||||
│ Response status code by Container │
|
│ Response status code by Container │
|
||||||
│ Nginx vs Angie Status Codes │
|
│ Nginx vs Angie Status Codes │
|
||||||
├─────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────┤
|
||||||
│ ▶ Angie (свёрнутая строка) │
|
│ ▶ Angie (свёрнутая строка) │
|
||||||
│ Errors by Apps │
|
│ Errors by Apps │
|
||||||
│ Errors by Upstream Path (app: ${error_server})│
|
│ Errors by Upstream Path │
|
||||||
│ Errors by Node │
|
│ Errors by Node │
|
||||||
├─────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────┤
|
||||||
│ ▼ Клиент A (развёрнутая строка) │
|
│ ▼ Клиент A │
|
||||||
│ Status Codes | RPS | Total Traffic │
|
│ Status Codes | RPS | Total Traffic │
|
||||||
│ [domain1] Status Codes | Response Time | 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`), а не создаётся заново.
|
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 |
|
| Алерт | Условие | for |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Use in / | диск `/` > 75% | 3m |
|
| `[SYS] Use in / 75%` | диск `/` > 75% | 3m |
|
||||||
| Use in /var/log | диск `/var/log` > 75% | 5m |
|
| `[SYS] Use in / 90%` | диск `/` > 90% | 1m |
|
||||||
| CPU Busy | CPU > 75% | 3m |
|
| `[SYS] Use in /var/log 75%` | диск `/var/log` > 75% | 5m |
|
||||||
| RAM Busy | RAM > 75% | 3m |
|
| `[SYS] Use in /var/log 90%` | диск `/var/log` > 90% | 1m |
|
||||||
| Состояние контейнеров | `docker_container_status == 0` | 2m |
|
| `[SYS] CPU Busy 75%` | CPU > 75% | 3m |
|
||||||
| Упала Angie | `angie.service` не active | 1m |
|
| `[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:
|
Все алерты отправляются параллельно через горутины с ограничением **5 одновременных запросов** к Grafana API.
|
||||||
|
|
||||||
```
|
|
||||||
[RPS client1] [RPS client2] [4xx client1] [5xx client2] [static: CPU] ← 5 горутин
|
|
||||||
↓ освободился слот
|
|
||||||
[static: RAM] ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Подавление DatasourceNoData алертов
|
### Подавление DatasourceNoData алертов
|
||||||
|
|
||||||
VL алерты при отсутствии данных переходят в состояние `NoData` (не `Alerting`). Для подавления уведомлений об этом состоянии в Grafana создан Silence с матчером `alertname = DatasourceNoData`.
|
VL алерты при отсутствии данных переходят в состояние `NoData`. Для подавления уведомлений в Grafana создан Silence с матчером `alertname = DatasourceNoData`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -348,7 +370,7 @@ VL алерты при отсутствии данных переходят в
|
||||||
`template_validator.go` запускается автоматически после загрузки шаблона и проверяет/исправляет:
|
`template_validator.go` запускается автоматически после загрузки шаблона и проверяет/исправляет:
|
||||||
|
|
||||||
- `vl_interval: "1m"` для панелей: rps, rps_multi, status_codes, response_time, traffic_combined, traffic_combined_total
|
- `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` с `TENANT:in(${tenant:csv})` для `overview_tenant_per_nodes`
|
||||||
- `vl_base_query` с `node_name:in(${node_name:csv})` для `overview_tenant_per_node`
|
- `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)
|
- Layout tenant_panels в три столбца по 8 (status_codes + rps/rps_multi + traffic_combined_total)
|
||||||
|
|
@ -360,8 +382,6 @@ VL алерты при отсутствии данных переходят в
|
||||||
|
|
||||||
Путь по умолчанию: `/var/lib/grafana_gen/state.json`
|
Путь по умолчанию: `/var/lib/grafana_gen/state.json`
|
||||||
|
|
||||||
Хранит хэши для обнаружения изменений между запусками:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"last_run": "2026-02-25T10:00:00Z",
|
"last_run": "2026-02-25T10:00:00Z",
|
||||||
|
|
@ -387,7 +407,7 @@ VL алерты при отсутствии данных переходят в
|
||||||
ничего не изменилось → выход без действий (если не указан -force)
|
ничего не изменилось → выход без действий (если не указан -force)
|
||||||
```
|
```
|
||||||
|
|
||||||
Хэш алертов сохраняется после успешного выполнения независимо от того какие системы алертов включены (OS/VL).
|
`alerts_hash` считается только от `spikClients` и сохраняется после успешной отправки.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -404,7 +424,7 @@ VL алерты при отсутствии данных переходят в
|
||||||
|
|
||||||
### Пути к .env файлу (перебираются по порядку)
|
### Пути к .env файлу (перебираются по порядку)
|
||||||
|
|
||||||
1. `$GRAFANA_GEN_ENV_FILE` (переменная окружения)
|
1. `$GRAFANA_GEN_ENV_FILE`
|
||||||
2. `/etc/grafana_gen/grafana_gen.env`
|
2. `/etc/grafana_gen/grafana_gen.env`
|
||||||
3. `./grafana_gen.env`
|
3. `./grafana_gen.env`
|
||||||
4. `./.env`
|
4. `./.env`
|
||||||
|
|
@ -420,10 +440,10 @@ DB_PASSWORD=secret
|
||||||
GRAFANA_URL=https://grafana.example.com
|
GRAFANA_URL=https://grafana.example.com
|
||||||
GRAFANA_API_KEY=glsa-xxxxxxxxxxxxxxxxxxxx
|
GRAFANA_API_KEY=glsa-xxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
# Git (Gitea)
|
# Git
|
||||||
GIT_TOKEN=your-gitea-token
|
GIT_TOKEN=your-gitea-token
|
||||||
|
|
||||||
# Алерты (OpenSearch)
|
# Алерты OpenSearch
|
||||||
ALERTS_DATASOURCE_UID=af84zsvlp9blsa
|
ALERTS_DATASOURCE_UID=af84zsvlp9blsa
|
||||||
|
|
||||||
# VictoriaLogs
|
# VictoriaLogs
|
||||||
|
|
@ -431,6 +451,12 @@ VL_DATASOURCE_UID=efewdonokxybkf
|
||||||
VL_DASHBOARD_TITLE=PT AF Requests (VictoriaLogs)
|
VL_DASHBOARD_TITLE=PT AF Requests (VictoriaLogs)
|
||||||
VL_GRAFANA_FOLDER=WAF - PTAF
|
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_OS_ENABLED=false
|
||||||
DASHBOARD_VL_ENABLED=true
|
DASHBOARD_VL_ENABLED=true
|
||||||
|
|
@ -451,16 +477,21 @@ ALERTS_VL_ENABLED=true
|
||||||
| `-db-name` | — | `waf_info` | PostgreSQL база данных |
|
| `-db-name` | — | `waf_info` | PostgreSQL база данных |
|
||||||
| `-grafana-url` | `GRAFANA_URL` | — | URL Grafana |
|
| `-grafana-url` | `GRAFANA_URL` | — | URL Grafana |
|
||||||
| `-grafana-api-key` | `GRAFANA_API_KEY` | — | Grafana API ключ |
|
| `-grafana-api-key` | `GRAFANA_API_KEY` | — | Grafana API ключ |
|
||||||
| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для дашбордов |
|
| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для OS дашборда |
|
||||||
| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Nodes` | Название дашборда |
|
| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Requests` | Название OS дашборда |
|
||||||
| `-alerts-folder` | `ALERTS_FOLDER` | `WAF - PTAF` | Папка для алертов |
|
| `-alerts-folder` | `ALERTS_FOLDER` | `WAF - PTAF` | Папка для алертов |
|
||||||
| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Получатель уведомлений |
|
| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Contact point для SPIK алертов |
|
||||||
| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов |
|
| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов для SPIK |
|
||||||
| `-alerts-datasource-uid` | `ALERTS_DATASOURCE_UID` | `af84zsvlp9blsa` | UID datasource OpenSearch для алертов |
|
| `-alerts-datasource-uid` | `ALERTS_DATASOURCE_UID` | `af84zsvlp9blsa` | UID datasource OpenSearch для алертов |
|
||||||
| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource (пустая = не генерировать VL) |
|
| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource |
|
||||||
| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название VL дашборда |
|
| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название SPIK VL дашборда |
|
||||||
| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для VL дашборда (пустая = как у OS) |
|
| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для SPIK VL дашборда |
|
||||||
| `-vl-dashboard-uid` | `VL_DASHBOARD_UID` | `pt-af-requests-auto-vl` | UID VL дашборда в Grafana |
|
| `-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 токен |
|
| `-git-token` | `GIT_TOKEN` | — | Gitea токен |
|
||||||
| `-templates-repo` | — | `svc-git.cirex.ru/...` | URL Git-репозитория шаблонов |
|
| `-templates-repo` | — | `svc-git.cirex.ru/...` | URL Git-репозитория шаблонов |
|
||||||
| `-templates-branch` | — | `master` | Ветка шаблонов |
|
| `-templates-branch` | — | `master` | Ветка шаблонов |
|
||||||
|
|
@ -489,10 +520,10 @@ ALERTS_VL_ENABLED=true
|
||||||
# Без обновления шаблонов из Git
|
# Без обновления шаблонов из Git
|
||||||
./grafana_gen -skip-git-pull
|
./grafana_gen -skip-git-pull
|
||||||
|
|
||||||
# Только VL дашборд и VL алерты
|
# Только VL дашборды и VL алерты
|
||||||
DASHBOARD_OS_ENABLED=false ALERTS_OS_ENABLED=false ./grafana_gen
|
DASHBOARD_OS_ENABLED=false ALERTS_OS_ENABLED=false ./grafana_gen
|
||||||
|
|
||||||
# Явное указание всех параметров
|
# Явное указание параметров
|
||||||
./grafana_gen \
|
./grafana_gen \
|
||||||
-db-host=10.100.10.8 \
|
-db-host=10.100.10.8 \
|
||||||
-db-user=reader \
|
-db-user=reader \
|
||||||
|
|
|
||||||
362
alerts.go
362
alerts.go
|
|
@ -74,7 +74,7 @@ func buildRPSAlertRule(client ClientData, tmpl AlertRulesTemplate, config Config
|
||||||
// Получаем настройки из шаблона
|
// Получаем настройки из шаблона
|
||||||
defaults := tmpl.Defaults
|
defaults := tmpl.Defaults
|
||||||
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
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)
|
timeRangeFrom := int64(1800)
|
||||||
if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok {
|
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),
|
label: fmt.Sprintf("RPS %s (limit: %d)", t.client.ClientTitle, t.client.RPSLimit),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if t.client.Limit4xx > 0 {
|
// WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой)
|
||||||
rule := buildErrorRateAlertRule(t.client, 4, t.client.Limit4xx, tmpl, config)
|
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
|
rule["folderUID"] = folderUID
|
||||||
tasks = append(tasks, alertTask{
|
tasks = append(tasks, alertTask{
|
||||||
rule: rule,
|
rule: rule,
|
||||||
label: fmt.Sprintf("4xx %s (limit: %d%%)", t.client.ClientTitle, t.client.Limit4xx),
|
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),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Статические алерты — отправляются первыми (появляются в начале списка)
|
// Error rate алерты — пороги берутся из каждого домена (apps_settings)
|
||||||
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil {
|
// Генерируем для response_status_code и upstream_status_code
|
||||||
log.Printf("Warning: static alerts error: %v", err)
|
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))
|
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)
|
log.Printf("Alerts: sent=%d, failed=%d", sent, failed)
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
return fmt.Errorf("%d alert rules failed to upsert", failed)
|
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
|
// Обновляем шаблон сообщения Contact Point
|
||||||
if err := upsertContactPoint(tmpl, config, dryRun); err != nil {
|
if err := upsertContactPoint(tmpl, config, dryRun); err != nil {
|
||||||
log.Printf("Warning: failed to update contact point message template: %v", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildErrorRateAlertRule строит алерт на процент 4xx или 5xx ответов для клиента.
|
// buildErrorRateAlertRules строит алерт на процент 4xx или 5xx ответов для конкретного домена.
|
||||||
// Структура: два count-запроса (total + errors) → reduce → math (%) → threshold
|
// statusField: "response_status_code" или "upstream_status_code"
|
||||||
func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
|
func buildErrorRateAlertRules(client ClientData, domain DomainInfo, errCode, limitPct int, statusField string, tmpl AlertRulesTemplate, config Config) []map[string]interface{} {
|
||||||
datasourceUID := config.AlertsDatasourceUID
|
datasourceUID := config.AlertsDatasourceUID
|
||||||
defaults := tmpl.Defaults
|
defaults := tmpl.Defaults
|
||||||
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
||||||
group := orDefault(config.AlertsGroup, defaults.Group)
|
group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
|
||||||
|
|
||||||
// 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]"
|
codeRange := "[400 TO 499]"
|
||||||
codeName := "4xx"
|
codeName := "4xx"
|
||||||
|
|
@ -410,7 +414,34 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
||||||
codeName = "5xx"
|
codeName = "5xx"
|
||||||
}
|
}
|
||||||
|
|
||||||
uid := generateAlertUID(client.ClientTitle, codeName)
|
forDuration := "1m"
|
||||||
|
if errCode == 4 {
|
||||||
|
forDuration = "3m"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
totalRefID := "Total"
|
||||||
errRefID := "Errors"
|
errRefID := "Errors"
|
||||||
totalReduceID := "_TotalReduce"
|
totalReduceID := "_TotalReduce"
|
||||||
|
|
@ -418,18 +449,9 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
||||||
percentRefID := "Значение"
|
percentRefID := "Значение"
|
||||||
thresholdRefID := "Превышение"
|
thresholdRefID := "Превышение"
|
||||||
|
|
||||||
summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct)
|
rule := map[string]interface{}{
|
||||||
|
|
||||||
relFrom := int64(900)
|
|
||||||
|
|
||||||
forDuration := "1m"
|
|
||||||
if errCode == 4 {
|
|
||||||
forDuration = "3m"
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
"title": fmt.Sprintf("%s %s", codeName, client.ClientTitle),
|
"title": title,
|
||||||
"condition": thresholdRefID,
|
"condition": thresholdRefID,
|
||||||
"noDataState": defaults.NoDataState,
|
"noDataState": defaults.NoDataState,
|
||||||
"execErrState": defaults.ExecErrState,
|
"execErrState": defaults.ExecErrState,
|
||||||
|
|
@ -437,7 +459,14 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
||||||
"folderUID": "",
|
"folderUID": "",
|
||||||
"ruleGroup": group,
|
"ruleGroup": group,
|
||||||
"for": forDuration,
|
"for": forDuration,
|
||||||
"annotations": map[string]string{"summary": summary},
|
"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,
|
||||||
|
},
|
||||||
"notification_settings": map[string]string{"receiver": receiver},
|
"notification_settings": map[string]string{"receiver": receiver},
|
||||||
"data": []interface{}{
|
"data": []interface{}{
|
||||||
// Total запрос
|
// Total запрос
|
||||||
|
|
@ -456,7 +485,7 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
||||||
"luceneQueryType": "Metric",
|
"luceneQueryType": "Metric",
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||||
"query": fmt.Sprintf("response_status_code:[0 TO 599] AND (%s)", sidQuery),
|
"query": fmt.Sprintf("response_status_code:[0 TO 599] AND %s", sidQuery),
|
||||||
"queryType": "lucene",
|
"queryType": "lucene",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": totalRefID,
|
"refId": totalRefID,
|
||||||
|
|
@ -479,7 +508,7 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
||||||
"luceneQueryType": "Metric",
|
"luceneQueryType": "Metric",
|
||||||
"maxDataPoints": 43200,
|
"maxDataPoints": 43200,
|
||||||
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||||
"query": fmt.Sprintf("response_status_code:%s AND (%s)", codeRange, sidQuery),
|
"query": fmt.Sprintf("%s:%s AND %s", statusField, codeRange, sidQuery),
|
||||||
"queryType": "lucene",
|
"queryType": "lucene",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": errRefID,
|
"refId": errRefID,
|
||||||
|
|
@ -555,6 +584,10 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД)
|
// sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД)
|
||||||
|
|
@ -600,7 +633,7 @@ func sendStaticAlerts(tmpl AlertRulesTemplate, config Config, dryRun bool) error
|
||||||
tasks = append(tasks, alertTask{rule: rule, label: title})
|
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)
|
log.Printf("Static alerts: sent=%d, failed=%d", sent, failed)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -780,43 +813,37 @@ func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) err
|
||||||
return fmt.Errorf("failed to parse contact points: %w", err)
|
return fmt.Errorf("failed to parse contact points: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ищем contact point с нужным именем
|
// Ищем все telegram интеграции с нужным именем и обновляем message
|
||||||
var target map[string]interface{}
|
var updated int
|
||||||
for _, cp := range contactPoints {
|
for _, cp := range contactPoints {
|
||||||
if name, _ := cp["name"].(string); name == receiverName {
|
name, _ := cp["name"].(string)
|
||||||
target = cp
|
if name != receiverName {
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
}
|
// Обновляем все типы интеграций (telegram, email, slack и т.д.)
|
||||||
|
uid, _ := cp["uid"].(string)
|
||||||
if target == nil {
|
cpType, _ := cp["type"].(string)
|
||||||
log.Printf("Contact point '%s' not found, skipping message template update", receiverName)
|
settings, ok := cp["settings"].(map[string]interface{})
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if !ok {
|
||||||
settings = make(map[string]interface{})
|
settings = make(map[string]interface{})
|
||||||
target["settings"] = settings
|
cp["settings"] = settings
|
||||||
}
|
}
|
||||||
|
// Поле для шаблона зависит от типа интеграции
|
||||||
|
switch cpType {
|
||||||
|
case "slack":
|
||||||
|
settings["text"] = tmpl.ContactPoint.MessageTemplate
|
||||||
|
default: // telegram, email, oncall, mattermost и др.
|
||||||
settings["message"] = tmpl.ContactPoint.MessageTemplate
|
settings["message"] = tmpl.ContactPoint.MessageTemplate
|
||||||
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
data, _ := json.MarshalIndent(target, "", " ")
|
data, _ := json.MarshalIndent(cp, "", " ")
|
||||||
log.Printf("DRY RUN: Contact point update for '%s':\n%s", receiverName, string(data))
|
log.Printf("DRY RUN: Contact point update for '%s' (uid: %s):\n%s", receiverName, uid, string(data))
|
||||||
return nil
|
updated++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/v1/provisioning/contact-points/{uid}
|
payload, _ := json.Marshal(cp)
|
||||||
payload, _ := json.Marshal(target)
|
|
||||||
updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload))
|
updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload))
|
||||||
updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
|
updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
|
||||||
updReq.Header.Set("Content-Type", "application/json")
|
updReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
@ -824,19 +851,72 @@ func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) err
|
||||||
|
|
||||||
updResp, err := http.DefaultClient.Do(updReq)
|
updResp, err := http.DefaultClient.Do(updReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update contact point: %w", err)
|
log.Printf("Warning: failed to update contact point '%s' (uid: %s): %v", receiverName, uid, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
defer updResp.Body.Close()
|
updResp.Body.Close()
|
||||||
|
updated++
|
||||||
respBody, _ := io.ReadAll(updResp.Body)
|
log.Printf("Contact point '%s' (uid: %s) message template updated", receiverName, uid)
|
||||||
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)
|
if updated == 0 {
|
||||||
|
log.Printf("No telegram integrations found for contact point '%s'", receiverName)
|
||||||
|
}
|
||||||
return nil
|
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
|
// orDefault возвращает val если не пустой, иначе def
|
||||||
func orDefault(val, def string) string {
|
func orDefault(val, def string) string {
|
||||||
if val != "" {
|
if val != "" {
|
||||||
|
|
@ -844,3 +924,113 @@ func orDefault(val, def string) string {
|
||||||
}
|
}
|
||||||
return def
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
244
alerts_vl.go
244
alerts_vl.go
|
|
@ -26,7 +26,7 @@ func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Conf
|
||||||
|
|
||||||
defaults := tmpl.Defaults
|
defaults := tmpl.Defaults
|
||||||
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
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)
|
timeRangeFrom := int64(1800)
|
||||||
if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok {
|
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.
|
// buildErrorRateAlertRulesVL строит алерт на процент ошибок для конкретного домена (VictoriaLogs).
|
||||||
func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
|
// 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
|
datasourceUID := config.VLDatasourceUID
|
||||||
datasourceType := DefaultVLDatasourceType
|
datasourceType := DefaultVLDatasourceType
|
||||||
|
|
||||||
defaults := tmpl.Defaults
|
defaults := tmpl.Defaults
|
||||||
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
||||||
group := orDefault(config.AlertsGroup, defaults.Group)
|
group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
|
||||||
|
|
||||||
sidQuery := buildTenantSIDQueryVL(client.Domains)
|
|
||||||
|
|
||||||
codeFrom, codeTo := 400, 499
|
codeFrom, codeTo := 400, 499
|
||||||
codeName := "4xx"
|
codeName := "4xx"
|
||||||
|
|
@ -200,7 +199,35 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
|
||||||
codeName = "5xx"
|
codeName = "5xx"
|
||||||
}
|
}
|
||||||
|
|
||||||
uid := generateAlertUID(client.ClientTitle+"_vl", codeName)
|
forDuration := "1m"
|
||||||
|
if errCode == 4 {
|
||||||
|
forDuration = "3m"
|
||||||
|
}
|
||||||
|
|
||||||
|
relFrom := int64(900)
|
||||||
|
|
||||||
|
var rules []map[string]interface{}
|
||||||
|
|
||||||
|
{
|
||||||
|
sidQuery := fmt.Sprintf("SID:%s", domain.SID)
|
||||||
|
// Короткое имя поля для использования в названии алерта
|
||||||
|
fieldShort := "response"
|
||||||
|
if statusField == "upstream_status_code" {
|
||||||
|
fieldShort = "upstream"
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
totalRefID := "Total"
|
totalRefID := "Total"
|
||||||
errRefID := "Errors"
|
errRefID := "Errors"
|
||||||
totalReduceID := "_TotalReduce"
|
totalReduceID := "_TotalReduce"
|
||||||
|
|
@ -208,17 +235,8 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
|
||||||
percentRefID := "Значение"
|
percentRefID := "Значение"
|
||||||
thresholdRefID := "Превышение"
|
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)
|
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)
|
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{} {
|
makeVLModel := func(refID, expr, legend string) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
|
|
@ -249,9 +267,9 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
rule := map[string]interface{}{
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
"title": fmt.Sprintf("VL %s %s", codeName, client.ClientTitle),
|
"title": title,
|
||||||
"condition": thresholdRefID,
|
"condition": thresholdRefID,
|
||||||
"noDataState": "NoData", // VL: нет данных = нет алерта
|
"noDataState": "NoData", // VL: нет данных = нет алерта
|
||||||
"execErrState": "Error", // VL: ошибка datasource = Error, не Alerting
|
"execErrState": "Error", // VL: ошибка datasource = Error, не Alerting
|
||||||
|
|
@ -259,7 +277,14 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
|
||||||
"folderUID": "",
|
"folderUID": "",
|
||||||
"ruleGroup": group,
|
"ruleGroup": group,
|
||||||
"for": forDuration,
|
"for": forDuration,
|
||||||
"annotations": map[string]string{"summary": summary},
|
"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,
|
||||||
|
},
|
||||||
"notification_settings": map[string]string{"receiver": receiver},
|
"notification_settings": map[string]string{"receiver": receiver},
|
||||||
"data": []interface{}{
|
"data": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
|
|
@ -325,18 +350,22 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateAndSendAlertsVL генерирует и отправляет алерты для VictoriaLogs.
|
// 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 == "" {
|
if config.VLDatasourceUID == "" {
|
||||||
log.Printf("Skipping VL alerts: VLDatasourceUID not configured")
|
log.Printf("Skipping VL alerts: VLDatasourceUID not configured")
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
folderUID, err := ensureAlertFolder(config)
|
folderUID, err := ensureAlertFolder(config)
|
||||||
if err != nil {
|
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
|
var tasks []alertTask
|
||||||
|
|
@ -356,34 +385,167 @@ func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTempl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error rate алерты (4xx и 5xx)
|
// WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой)
|
||||||
for _, errCode := range []int{4, 5} {
|
if client.PtafFallbackCode != "" && strings.ToLower(client.PtafFallbackCode) != "pass" {
|
||||||
limitPct := client.Limit4xx
|
for _, domain := range client.Domains {
|
||||||
if errCode == 5 {
|
rule := buildWAFBlockAlertRuleVL(client, domain, tmpl, config)
|
||||||
limitPct = client.Limit5xx
|
|
||||||
}
|
|
||||||
if limitPct <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rule := buildErrorRateAlertRuleVL(client, errCode, limitPct, tmpl, config)
|
|
||||||
rule["folderUID"] = folderUID
|
rule["folderUID"] = folderUID
|
||||||
tasks = append(tasks, alertTask{
|
tasks = append(tasks, alertTask{
|
||||||
rule: rule,
|
rule: rule,
|
||||||
label: fmt.Sprintf("VL %dxx %s", errCode, client.ClientTitle),
|
label: fmt.Sprintf("VL %s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sent, failed := parallelUpsert(tasks, config, dryRun, 5)
|
// 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем шаблон 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)
|
log.Printf("VL Alerts: sent=%d failed=%d", sent, failed)
|
||||||
|
|
||||||
// Статические алерты (CPU, RAM, диск, контейнеры, Angie)
|
// Статические алерты отправляются отдельно из main.go чтобы избежать
|
||||||
// Отправляем только если OS алерты отключены, иначе они уже отправлены там
|
// дублирования при вызове функции для разных провайдеров (spik/sp)
|
||||||
if !config.AlertsOSEnabled {
|
|
||||||
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil {
|
// Собираем активные UID для возврата
|
||||||
log.Printf("Warning: VL static alerts error: %v", err)
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
config.go
18
config.go
|
|
@ -71,6 +71,11 @@ const (
|
||||||
DefaultVLGrafanaFolder = "" // Пустая = та же папка что и основная
|
DefaultVLGrafanaFolder = "" // Пустая = та же папка что и основная
|
||||||
DefaultVLDatasourceType = "victoriametrics-logs-datasource-ptaf-sw" // Тип VL datasource плагина
|
DefaultVLDatasourceType = "victoriametrics-logs-datasource-ptaf-sw" // Тип VL datasource плагина
|
||||||
DefaultVLDashboardUID = "pt-af-requests-auto-vl" // UID второго дашборда
|
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 - название единого дашборда со всеми клиентами
|
// DefaultDashboardTitle - название единого дашборда со всеми клиентами
|
||||||
// Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes
|
// Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes
|
||||||
|
|
@ -114,6 +119,11 @@ type Config struct {
|
||||||
VLDashboardTitle string // Название второго дашборда (пустая строка = не генерировать)
|
VLDashboardTitle string // Название второго дашборда (пустая строка = не генерировать)
|
||||||
VLGrafanaFolder string // Папка для VL дашборда (пустая = та же что и основная)
|
VLGrafanaFolder string // Папка для VL дашборда (пустая = та же что и основная)
|
||||||
VLDashboardUID string // UID второго дашборда в Grafana
|
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 {
|
type ClientData struct {
|
||||||
|
|
@ -121,14 +131,18 @@ type ClientData struct {
|
||||||
Domains []DomainInfo
|
Domains []DomainInfo
|
||||||
RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД)
|
RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД)
|
||||||
RPSCommercialLimit int // коммерческий лимит, при NULL берётся 100
|
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 {
|
type DomainInfo struct {
|
||||||
SID string
|
SID string
|
||||||
DomainName string
|
DomainName string
|
||||||
Aliases 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 {
|
type DashboardTemplate struct {
|
||||||
|
|
|
||||||
30
database.go
30
database.go
|
|
@ -38,8 +38,11 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
||||||
COALESCE(s.aliases::text, 'null') as aliases,
|
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_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,
|
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,
|
COALESCE(a.four_hundred, 20) as limit_4xx,
|
||||||
MAX(ci.five_hundred) OVER (PARTITION BY a.client_title) as limit_5xx
|
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 (
|
FROM (
|
||||||
SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL
|
SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL
|
||||||
UNION
|
UNION
|
||||||
|
|
@ -47,6 +50,7 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
||||||
) s
|
) s
|
||||||
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
||||||
LEFT JOIN client_info ci ON a.client_title = ci.client_title
|
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
|
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)
|
clients := make(map[string]ClientData)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var clientTitle, sid, domainName, aliasesJSON string
|
var clientTitle, sid, domainName, aliasesJSON, wafProvider, ptafFallbackCode string
|
||||||
var rpsLimit, rpsCommercialLimit int
|
var rpsLimit, rpsCommercialLimit, limit4xx, limit5xx, ptafFallbackCodeAlertCount int
|
||||||
var limit4xx, limit5xx sql.NullInt64
|
|
||||||
|
|
||||||
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)
|
return nil, fmt.Errorf("scan failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,8 +79,8 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
||||||
Domains: []DomainInfo{},
|
Domains: []DomainInfo{},
|
||||||
RPSLimit: rpsLimit,
|
RPSLimit: rpsLimit,
|
||||||
RPSCommercialLimit: rpsCommercialLimit,
|
RPSCommercialLimit: rpsCommercialLimit,
|
||||||
Limit4xx: nullIntToInt(limit4xx),
|
WafProvider: wafProvider,
|
||||||
Limit5xx: nullIntToInt(limit5xx),
|
PtafFallbackCode: ptafFallbackCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +89,9 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
||||||
SID: sid,
|
SID: sid,
|
||||||
DomainName: domainName,
|
DomainName: domainName,
|
||||||
Aliases: aliasesStr,
|
Aliases: aliasesStr,
|
||||||
|
Limit4xx: limit4xx,
|
||||||
|
Limit5xx: limit5xx,
|
||||||
|
PtafFallbackCodeAlertCount: ptafFallbackCodeAlertCount,
|
||||||
})
|
})
|
||||||
clients[clientTitle] = client
|
clients[clientTitle] = client
|
||||||
}
|
}
|
||||||
|
|
@ -93,14 +99,6 @@ func fetchClientsData(db *sql.DB) (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" или ""
|
||||||
|
|
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -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=
|
||||||
130
main.go
130
main.go
|
|
@ -4,8 +4,26 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"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() {
|
func main() {
|
||||||
// Load environment variables from .env file before parsing flags
|
// Load environment variables from .env file before parsing flags
|
||||||
// This allows .env file to provide defaults that can be overridden by flags
|
// This allows .env file to provide defaults that can be overridden by flags
|
||||||
|
|
@ -95,9 +113,21 @@ func main() {
|
||||||
os.Exit(0)
|
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)
|
alertTemplateHash := hashAlertTemplate(config.TemplatesPath)
|
||||||
alertsChanged := stateManager.AlertsNeedUpdate(validClients) || stateManager.AlertTemplateChanged(alertTemplateHash)
|
alertsChanged := stateManager.AlertsNeedUpdate(spikClients) || 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)
|
||||||
|
|
@ -124,11 +154,11 @@ func main() {
|
||||||
log.Printf("Force run enabled - regenerating dashboard")
|
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("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.DryRun {
|
||||||
if config.DashboardOSEnabled {
|
if config.DashboardOSEnabled {
|
||||||
|
|
@ -137,19 +167,24 @@ func main() {
|
||||||
if alertTmpl != nil {
|
if alertTmpl != nil {
|
||||||
log.Printf("DRY RUN: Generating alert rules preview...")
|
log.Printf("DRY RUN: Generating alert rules preview...")
|
||||||
if config.AlertsOSEnabled {
|
if config.AlertsOSEnabled {
|
||||||
_ = generateAndSendAlerts(validClients, *alertTmpl, config, true)
|
_ = generateAndSendAlerts(spikClients, *alertTmpl, config, true)
|
||||||
}
|
}
|
||||||
if config.AlertsVLEnabled {
|
if config.AlertsVLEnabled {
|
||||||
_ = generateAndSendAlertsVL(validClients, *alertTmpl, config, true)
|
_, _ = generateAndSendAlertsVL(spikClients, *alertTmpl, config, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("=== Summary ===")
|
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("Skipped: %d", skippedCount)
|
||||||
log.Printf("DRY RUN: Dashboard generated but not sent to Grafana")
|
log.Printf("DRY RUN: Dashboard generated but not sent to Grafana")
|
||||||
if config.VLDatasourceUID != "" && config.DashboardVLEnabled {
|
if config.VLDatasourceUID != "" && config.DashboardVLEnabled {
|
||||||
vlDashboard := generateVLDashboard(validClients, template, config)
|
vlDashboard := generateVLDashboard(spikClients, template, config)
|
||||||
logDryRun(config.VLDashboardTitle, vlDashboard)
|
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)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +194,7 @@ func main() {
|
||||||
if err := sendToGrafana(dashboard, config); err != nil {
|
if err := sendToGrafana(dashboard, config); err != nil {
|
||||||
log.Printf("Error sending dashboard: %v", err)
|
log.Printf("Error sending dashboard: %v", err)
|
||||||
log.Printf("=== Summary ===")
|
log.Printf("=== Summary ===")
|
||||||
log.Printf("Clients: %d", len(validClients))
|
log.Printf("Spik clients: %d", len(spikClients))
|
||||||
log.Printf("Skipped: %d", skippedCount)
|
log.Printf("Skipped: %d", skippedCount)
|
||||||
log.Printf("Status: FAILED")
|
log.Printf("Status: FAILED")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -169,10 +204,10 @@ func main() {
|
||||||
log.Printf("Dashboard OS: disabled (DASHBOARD_OS_ENABLED=false)")
|
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 {
|
if config.VLDatasourceUID != "" && config.DashboardVLEnabled {
|
||||||
log.Printf("Generating VictoriaLogs dashboard: %s", config.VLDashboardTitle)
|
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 {
|
if err := sendVLDashboardToGrafana(vlDashboard, config); err != nil {
|
||||||
log.Printf("Warning: VictoriaLogs dashboard failed: %v", err)
|
log.Printf("Warning: VictoriaLogs dashboard failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -182,29 +217,74 @@ func main() {
|
||||||
log.Printf("Dashboard VL: disabled (DASHBOARD_VL_ENABLED=false)")
|
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
|
// Generate and send alert rules
|
||||||
if alertTmpl != nil {
|
if alertTmpl != nil {
|
||||||
if alertsChanged || config.ForceRun {
|
if alertsChanged || config.ForceRun {
|
||||||
osOK := true
|
osErr := false
|
||||||
|
vlErr := false
|
||||||
if config.AlertsOSEnabled {
|
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)
|
log.Printf("Warning: alerts generation failed: %v", err)
|
||||||
osOK = false
|
osErr = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)")
|
log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)")
|
||||||
}
|
}
|
||||||
|
allActiveUIDs := map[string]bool{}
|
||||||
if config.AlertsVLEnabled {
|
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)
|
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 {
|
} else {
|
||||||
log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)")
|
log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)")
|
||||||
}
|
}
|
||||||
// Сохраняем хэш если хотя бы одна из систем алертов не вернула ошибку
|
// Сохраняем хэш если не было критических ошибок
|
||||||
if osOK {
|
// Частичные ошибки отдельных алертов не блокируют сохранение хэша
|
||||||
stateManager.UpdateAlertsHash(validClients)
|
if !osErr && !vlErr {
|
||||||
|
stateManager.UpdateAlertsHash(spikClients)
|
||||||
stateManager.UpdateAlertTemplateHash(alertTemplateHash)
|
stateManager.UpdateAlertTemplateHash(alertTemplateHash)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -215,7 +295,7 @@ func main() {
|
||||||
// Summary
|
// Summary
|
||||||
log.Printf("=== Summary ===")
|
log.Printf("=== Summary ===")
|
||||||
log.Printf("Dashboard: %s", config.DashboardTitle)
|
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 {
|
if skippedCount > 0 {
|
||||||
log.Printf("Skipped: %d", skippedCount)
|
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.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")
|
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
|
// 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")
|
||||||
|
|
|
||||||
25
state.go
25
state.go
|
|
@ -193,22 +193,33 @@ func calculateClientsHash(clients map[string]ClientData) string {
|
||||||
return fmt.Sprintf("%x", hash)
|
return fmt.Sprintf("%x", hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateAlertsHash считает хэш только от данных нужных для алертов (client_title + rps_limit)
|
// calculateAlertsHash считает хэш от данных нужных для алертов (rps_limit + пороги per-SID)
|
||||||
func calculateAlertsHash(clients map[string]ClientData) string {
|
func calculateAlertsHash(clients map[string]ClientData) string {
|
||||||
type alertData struct {
|
type domainAlert struct {
|
||||||
ClientTitle string
|
SID string
|
||||||
RPSLimit int
|
|
||||||
Limit4xx int
|
Limit4xx int
|
||||||
Limit5xx int
|
Limit5xx int
|
||||||
}
|
}
|
||||||
|
type alertData struct {
|
||||||
|
ClientTitle string
|
||||||
|
RPSLimit int
|
||||||
|
Domains []domainAlert
|
||||||
|
}
|
||||||
var data []alertData
|
var data []alertData
|
||||||
for _, client := range clients {
|
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{
|
data = append(data, alertData{
|
||||||
ClientTitle: client.ClientTitle,
|
ClientTitle: client.ClientTitle,
|
||||||
RPSLimit: client.RPSLimit,
|
RPSLimit: client.RPSLimit,
|
||||||
Limit4xx: client.Limit4xx,
|
Domains: domains,
|
||||||
Limit5xx: client.Limit5xx,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue