From 692fa563e1c585a1252e4346c6258fff44ad2df1 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Tue, 24 Feb 2026 12:45:35 +0300 Subject: [PATCH] Work version --- .gitignore | 46 +++++++ README.md | 344 +++++++++++++++++++++++++++++++++++++++++++++++++++ config.go | 156 +++++++++++++++++++++++ dashboard.go | 254 +++++++++++++++++++++++++++++++++++++ database.go | 104 ++++++++++++++++ env.go | 112 +++++++++++++++++ git.go | 207 +++++++++++++++++++++++++++++++ go.mod | 5 + grafana.go | 182 +++++++++++++++++++++++++++ main.go | 283 ++++++++++++++++++++++++++++++++++++++++++ panels.go | 313 ++++++++++++++++++++++++++++++++++++++++++++++ state.go | 264 +++++++++++++++++++++++++++++++++++++++ template.go | 31 +++++ 13 files changed, 2301 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.go create mode 100644 dashboard.go create mode 100644 database.go create mode 100644 env.go create mode 100644 git.go create mode 100644 go.mod create mode 100644 grafana.go create mode 100644 main.go create mode 100644 panels.go create mode 100644 state.go create mode 100644 template.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42a3258 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +grafana_gen +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..c97816d --- /dev/null +++ b/README.md @@ -0,0 +1,344 @@ +# grafana_gen — Руководство пользователя + +Утилита автоматически генерирует дашборды Grafana на основе данных из PostgreSQL. При каждом запуске она читает список клиентов и их ресурсов из БД, сравнивает с предыдущим состоянием и пересоздаёт единый дашборд только при наличии изменений. + +--- + +## Как это работает + +``` +PostgreSQL (test_info) + ↓ + Список клиентов и ресурсов (SID, domain_name, aliases) + ↓ + Проверка изменений (state.json) + ↓ нет изменений → выход + Загрузка шаблона (dashboard_template.json из Git) + ↓ + Генерация JSON дашборда + ↓ + Grafana API → создание/обновление дашборда +``` + +На каждый запуск создаётся **один дашборд** со всеми клиентами. Каждый клиент отображается как свёрнутая строка (collapsed row), внутри которой — панели по каждому ресурсу. + +--- + +## Требования + +- Go 1.21+ +- PostgreSQL с базой `test_info` +- Grafana с API-доступом (Service Account Token или Legacy API Key) +- Git (для загрузки шаблонов) +- Доступ к Gitea-репозиторию с шаблоном + +--- + +## Установка и запуск + +### Сборка + +```bash +git clone <репозиторий утилиты> +cd grafana_gen +go build -o grafana_gen . +``` + +### Первый запуск + +```bash +./grafana_gen \ + -grafana-url https://grafana.example.com \ + -grafana-api-key glsa_xxxxxxxxxxxx \ + -db-user waf_reader \ + -db-password secret +``` + +### Через .env файл (рекомендуется) + +Создайте файл `/etc/grafana_gen/grafana_gen.env`: + +```env +GRAFANA_URL=https://grafana.example.com +GRAFANA_API_KEY=glsa_xxxxxxxxxxxx +DB_USER=waf_reader +DB_PASSWORD=secret +GIT_TOKEN=your-gitea-token +``` + +Затем просто: + +```bash +./grafana_gen +``` + +--- + +## Конфигурация + +### Способы задать параметры (в порядке приоритета) + +| Приоритет | Способ | Пример | +|-----------|--------|--------| +| 1 | Флаг командной строки | `-grafana-url https://...` | +| 2 | Переменная окружения | `export GRAFANA_URL=https://...` | +| 3 | `.env` файл | `GRAFANA_URL=https://...` | +| 4 | Константа в `config.go` | `DefaultGrafanaURL = "https://..."` | + +### Пути поиска .env файла + +Утилита ищет `.env` файл в следующем порядке: + +1. Путь из переменной `GRAFANA_GEN_ENV_FILE` +2. `/etc/grafana_gen/grafana_gen.env` +3. `./grafana_gen.env` (рядом с бинарником) +4. `./.env` + +--- + +## Флаги командной строки + +### База данных + +| Флаг | Переменная окружения | По умолчанию | Описание | +|------|---------------------|--------------|----------| +| `-db-host` | — | `10.100.10.8` | Хост PostgreSQL | +| `-db-port` | — | `5432` | Порт PostgreSQL | +| `-db-user` | `DB_USER` | — | Пользователь БД (**обязательно**) | +| `-db-password` | `DB_PASSWORD` | — | Пароль БД (**обязательно**) | +| `-db-name` | — | `test_info` | Имя базы данных | +| `-use-manual` | — | `false` | Использовать таблицу `manual_info` вместо `sp_info` | + +### Grafana + +| Флаг | Переменная окружения | По умолчанию | Описание | +|------|---------------------|--------------|----------| +| `-grafana-url` | `GRAFANA_URL` | — | URL Grafana (**обязательно**) | +| `-grafana-api-key` | `GRAFANA_API_KEY` | — | API-ключ Grafana (**обязательно**) | +| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка в Grafana для дашборда | +| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Nodes` | Название дашборда | + +### Git / шаблоны + +| Флаг | Переменная окружения | По умолчанию | Описание | +|------|---------------------|--------------|----------| +| `-git-token` | `GIT_TOKEN` | — | Токен доступа к Gitea | +| `-templates-repo` | — | *(см. config.go)* | URL Git-репозитория с шаблонами | +| `-templates-branch` | — | `master` | Ветка репозитория | +| `-templates-path` | — | `/etc/grafana_gen/templates` | Локальный путь для шаблонов | +| `-skip-git-pull` | — | `false` | Не обновлять шаблоны из Git | + +### Управление запуском + +| Флаг | По умолчанию | Описание | +|------|--------------|----------| +| `-dry-run` | `false` | Сгенерировать JSON, но не отправлять в Grafana | +| `-force` | `false` | Принудительная регенерация даже без изменений | +| `-state-file` | `/var/lib/grafana_gen/state.json` | Путь к файлу состояния | + +--- + +## Приоритет параметров + +Пример: если одновременно задан флаг `-grafana-api-key`, переменная `GRAFANA_API_KEY` и значение в `.env` — используется **флаг командной строки** как наиболее приоритетный. + +``` +Флаг CLI > Переменная окружения > .env файл > константа в config.go +``` + +Это позволяет безопасно хранить секреты в `.env` и при необходимости переопределять их на лету без изменения файлов. + +--- + +## State-файл + +Утилита сохраняет состояние после каждого успешного запуска в JSON-файл (по умолчанию `/var/lib/grafana_gen/state.json`). + +При следующем запуске сравниваются: + +- Коммит шаблона в Git +- Версия шаблона (`version` в `dashboard_template.json`) +- SHA-256 хэш данных из БД (список клиентов, доменов, SID) +- Количество клиентов и доменов + +Если ничего не изменилось — дашборд не пересоздаётся, утилита завершается с кодом 0. + +``` +=== No Changes Detected === +No changes in templates or database since last run. +Skipping dashboard generation. + +Use -force flag to regenerate anyway. +``` + +Если изменения есть — выводится подробный отчёт: + +``` +=== Changes Detected === +Changes detected: + Template commit changed: abc12345 -> def67890 + Database content changed + - New SIDs: [SID_001, SID_002] +``` + +--- + +## Режим dry-run + +Позволяет проверить что будет сгенерировано без отправки в Grafana: + +```bash +./grafana_gen -dry-run +``` + +В лог выводится превью JSON дашборда и итоговая статистика: + +``` +DRY RUN: Dashboard generated but not sent to Grafana +=== Summary === +Clients: 12 +Skipped: 0 +``` + +--- + +## Запуск через cron + +Рекомендуемый вариант — запуск каждые 15 минут: + +```cron +*/15 * * * * /usr/local/bin/grafana_gen >> /var/log/grafana_gen.log 2>&1 +``` + +Утилита сама определяет нужно ли обновлять дашборд — частые запуски без изменений завершаются мгновенно. + +### Systemd timer (альтернатива) + +`/etc/systemd/system/grafana-gen.service`: +```ini +[Unit] +Description=Grafana Dashboard Generator +After=network.target postgresql.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/grafana_gen/grafana_gen.env +ExecStart=/usr/local/bin/grafana_gen +StandardOutput=journal +StandardError=journal +``` + +`/etc/systemd/system/grafana-gen.timer`: +```ini +[Unit] +Description=Run grafana-gen every 15 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=15min + +[Install] +WantedBy=timers.target +``` + +```bash +systemctl enable --now grafana-gen.timer +``` + +--- + +## Типичные сценарии + +### Первое развёртывание + +```bash +# 1. Создать .env +cp grafana_gen.env.example /etc/grafana_gen/grafana_gen.env +vim /etc/grafana_gen/grafana_gen.env + +# 2. Проверить без отправки +./grafana_gen -dry-run + +# 3. Создать дашборд +./grafana_gen +``` + +### Принудительное обновление после изменения шаблона + +```bash +./grafana_gen -force +``` + +### Переключение на резервную БД + +```bash +./grafana_gen -db-host 10.10.10.5 +``` + +### Тестирование с другим шаблоном + +```bash +./grafana_gen \ + -templates-path /tmp/my-templates \ + -skip-git-pull \ + -dry-run +``` + +### Использование таблицы ручного ввода + +```bash +./grafana_gen -use-manual +``` + +--- + +## Структура БД + +Утилита читает данные из двух таблиц базы `test_info`. + +### Таблица `sp_info` (основная) + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `sid` | text | Идентификатор ресурса | +| `domain_name` | text | Доменное имя | +| `aliases` | jsonb | JSON-массив дополнительных доменов | + +### Таблица `apps_settings` (справочник клиентов) + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `l7resourceid` | text | SID ресурса (связь с `sp_info.sid`) | +| `client_title` | text | Название клиента | + +Если `client_title` не найден — клиент группируется под именем `Unknown`. + +Таблица `manual_info` имеет ту же структуру, что и `sp_info`, и используется при флаге `-use-manual` для ручного управления данными без изменения основной таблицы. + +--- + +## Файловая система + +``` +/etc/grafana_gen/ +├── grafana_gen.env # Конфигурация (секреты) +└── templates/ # Клонированный Git-репозиторий с шаблонами + └── dashboard_template.json + +/var/lib/grafana_gen/ +└── state.json # Состояние последнего запуска + +/usr/local/bin/ +└── grafana_gen # Бинарник утилиты +``` + +### Права доступа + +```bash +# Директории +install -d -m 755 /etc/grafana_gen +install -d -m 755 /var/lib/grafana_gen + +# .env файл — только для владельца процесса +chmod 600 /etc/grafana_gen/grafana_gen.env +``` \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..3bd71c9 --- /dev/null +++ b/config.go @@ -0,0 +1,156 @@ +package main + +const ( + // Database settings + // Рекомендуется задавать через .env файл или переменные окружения + DefaultDBUser = "" // Задать через DB_USER в .env + DefaultDBPassword = "" // Задать через DB_PASSWORD в .env + DefaultDBName = "waf_info" + DefaultDBPort = 5432 + DefaultPrimaryDBHost = "10.100.10.8" + DefaultSecondaryDBHost = "10.100.13.5" + + // Git repository settings + DefaultTemplatesRepo = "https://svc-git.cirex.ru/cloudstack/grafana_gen_template.git" + DefaultTemplatesBranch = "master" + DefaultTemplatesPath = "/etc/grafana_gen/templates" // Системный путь + DefaultStateFile = "/var/lib/grafana_gen/state.json" // Системный путь для state + + // DefaultGitToken - Gitea access token для доступа к репозиторию + // ВАЖНО: Рекомендуется задавать через .env файл или переменную окружения! + // + // Как создать токен в Gitea: + // 1. Gitea → Settings → Applications → Generate New Token + // 2. Выбрать scope: repository (read) + // + // Варианты использования (в порядке приоритета): + // 1. Флаг командной строки: -git-token "your-gitea-token" + // 2. Переменная окружения: export GIT_TOKEN="your-gitea-token" + // 3. Файл .env: GIT_TOKEN=your-gitea-token + // 4. Hardcode здесь (не рекомендуется): DefaultGitToken = "your-gitea-token" + // + // Оставьте пустым для использования только флага/env/.env файла + DefaultGitToken = "" + + // DefaultGrafanaAPIKey - Grafana API ключ для создания дашбордов + // ВАЖНО: Рекомендуется задавать через .env файл или переменную окружения! + // + // Варианты использования (в порядке приоритета): + // 1. Флаг командной строки: -grafana-api-key "glsa-xxxxxxxxxxxx" + // 2. Переменная окружения: export GRAFANA_API_KEY="glsa-xxxxxxxxxxxx" + // 3. Файл .env: GRAFANA_API_KEY=glsa_xxxxxxxxxxxx + // 4. Hardcode здесь (не рекомендуется): DefaultGrafanaAPIKey = "glsa-xxxxxxxxxxxx" + // + // Оставьте пустым для использования только флага/env/.env файла + DefaultGrafanaAPIKey = "" + + // DefaultGrafanaURL - URL Grafana сервера + // Можно захардкодить для быстрого запуска + DefaultGrafanaURL = "" + + // Пример: + // DefaultGrafanaURL = "https://grafana.cirex.ru" + + // DefaultGrafanaFolder - папка в Grafana куда будут созданы дашборды + // Пример: "WAF - PTAF" создаст путь: Home -> Dashboards -> WAF - PTAF -> [Dashboard] + // Оставьте пустым для создания в General папке (корень) + DefaultGrafanaFolder = "WAF - Auto Generated" + + // Можно переопределить под ваши нужды: + // DefaultGrafanaFolder = "WAF - PTAF" + + // DefaultDashboardTitle - название единого дашборда со всеми клиентами + // Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes + DefaultDashboardTitle = "PT AF Nodes" + + // Другие примеры названий: + // DefaultDashboardTitle = "WAF Monitoring" + // DefaultDashboardTitle = "All Clients" + // DefaultDashboardTitle = "PTAF Overview" +) + +type Config struct { + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + GrafanaURL string + GrafanaAPIKey string + GrafanaFolder string // Папка в Grafana для дашбордов + DashboardTitle string // Название единого дашборда + UseManualInfo bool + DryRun bool + TemplatesRepo string + TemplatesBranch string + TemplatesPath string + SkipGitPull bool + StateFile string + ForceRun bool + GitToken string // GitLab token для приватного репозитория +} + +type ClientData struct { + ClientTitle string + Domains []DomainInfo +} + +type DomainInfo struct { + SID string + DomainName string + Aliases string +} + +type DashboardTemplate struct { + Version string `json:"version"` + Description string `json:"description"` + Datasource map[string]interface{} `json:"datasource"` + DashboardMeta map[string]interface{} `json:"dashboard_meta"` // uid, tags, version — больше не хардкод в Go + Dashboard map[string]interface{} `json:"dashboard"` + Layout LayoutConfig `json:"layout"` + Conditions map[string]ConditionDefinition `json:"conditions"` + PanelTemplates map[string]interface{} `json:"panel_templates"` + QueryTemplates map[string]interface{} `json:"query_templates"` + Panels map[string]interface{} `json:"panels"` + Queries map[string]interface{} `json:"queries"` +} + +type LayoutConfig struct { + TenantPanels []PanelLayout `json:"tenant_panels"` + DomainPanels []PanelLayout `json:"domain_panels"` + UseCollapsedRows bool `json:"use_collapsed_rows"` +} + +type PanelLayout struct { + PanelKey string `json:"panel_key"` + PanelTemplate string `json:"panel_template,omitempty"` + Scope string `json:"scope,omitempty"` + TitleFormat string `json:"title_format,omitempty"` + TitleSuffix string `json:"title_suffix,omitempty"` + Field string `json:"field,omitempty"` + Width int `json:"width"` + XOffset int `json:"x_offset"` + YOffset int `json:"y_offset,omitempty"` + CollapsedRow bool `json:"collapsed_row,omitempty"` + QueryConfig *QueryConfig `json:"query_config,omitempty"` + Condition *PanelCondition `json:"condition"` +} + +type QueryConfig struct { + BaseQuery string `json:"base_query"` + Filters []string `json:"filters"` + Metric string `json:"metric"` + Interval string `json:"interval"` + Field string `json:"field,omitempty"` + Extra map[string]interface{} `json:"extra,omitempty"` +} + +type PanelCondition struct { + Type string `json:"type"` + Value interface{} `json:"value"` +} + +type ConditionDefinition struct { + Description string `json:"description"` + Evaluator string `json:"evaluator"` +} diff --git a/dashboard.go b/dashboard.go new file mode 100644 index 0000000..4e1e3cf --- /dev/null +++ b/dashboard.go @@ -0,0 +1,254 @@ +package main + +import ( + "fmt" + "log" + "strings" +) + +// generateSingleDashboard создает один дашборд со всеми клиентами. +// Структура, метаданные и логика панелей полностью управляются шаблоном. +func generateSingleDashboard(clients map[string]ClientData, template *DashboardTemplate, config Config) map[string]interface{} { + panels := []interface{}{} + yPos := 0 + panelID := 100 + + datasourceType := template.Datasource["type"].(string) + datasourceUID := template.Datasource["uid"].(string) + useCollapsedRows := template.Layout.UseCollapsedRows + + for _, client := range clients { + if len(client.Domains) == 0 { + continue + } + + if useCollapsedRows { + row := createCollapsedRow(client, template, datasourceType, datasourceUID, panelID, yPos) + panels = append(panels, row) + panelID += 100 + yPos++ + } else { + panels = append(panels, createRowPanel(client.ClientTitle, template, panelID, yPos)) + panelID++ + yPos++ + + tenantPanels, nextID, nextY := generateLayoutPanels( + template.Layout.TenantPanels, client, nil, + template, datasourceType, datasourceUID, panelID, yPos, + ) + panels = append(panels, tenantPanels...) + panelID = nextID + yPos = nextY + + for _, domain := range client.Domains { + domainPanels, nextID, nextY := generateLayoutPanels( + template.Layout.DomainPanels, client, &domain, + template, datasourceType, datasourceUID, panelID, yPos, + ) + panels = append(panels, domainPanels...) + panelID = nextID + yPos = nextY + } + } + } + + // Берём метаданные дашборда из шаблона + dashboardBase := deepCopy(template.Dashboard).(map[string]interface{}) + applyDashboardMeta(dashboardBase, template, config.DashboardTitle) + dashboardBase["panels"] = panels + + return map[string]interface{}{ + "dashboard": dashboardBase, + "overwrite": true, + } +} + +// applyDashboardMeta применяет метаданные из секции dashboard_meta шаблона. +// Значения из шаблона используются как дефолты; title всегда берётся из config. +func applyDashboardMeta(dashboardBase map[string]interface{}, template *DashboardTemplate, title string) { + dashboardBase["title"] = title + + meta := template.DashboardMeta + if meta == nil { + // Минимальные дефолты если секция отсутствует + dashboardBase["id"] = nil + dashboardBase["version"] = 0 + return + } + + dashboardBase["id"] = nil // всегда nil для overwrite по uid + + if uid, ok := meta["uid"].(string); ok && uid != "" { + dashboardBase["uid"] = uid + } + if tags, ok := meta["tags"]; ok { + dashboardBase["tags"] = tags + } + if version, ok := meta["version"]; ok { + dashboardBase["version"] = version + } +} + +// createCollapsedRow создаёт row с панелями внутри (collapsed=true) +func createCollapsedRow( + client ClientData, + template *DashboardTemplate, + datasourceType, datasourceUID string, + startPanelID, yPos int, +) map[string]interface{} { + row := deepCopy(template.Panels["row"]).(map[string]interface{}) + row["id"] = startPanelID + row["title"] = client.ClientTitle + row["collapsed"] = true + + gridPos := row["gridPos"].(map[string]interface{}) + gridPos["y"] = yPos + + insidePanels := []interface{}{} + panelID := startPanelID + 1 + + for _, layout := range template.Layout.TenantPanels { + if !evaluateCondition(layout.Condition, client, nil) { + continue + } + domain := tenantDomain(client) + panel := buildPanel( + layout.PanelKey, template, domain, client, + datasourceType, datasourceUID, + panelID, yOffsetOrDefault(layout.YOffset, 1), layout.XOffset, layout.Width, + ) + if panel == nil { + continue + } + applyTitle(panel, layout, client.ClientTitle, "", "") + insidePanels = append(insidePanels, panel) + panelID++ + } + + for _, domain := range client.Domains { + for _, layout := range template.Layout.DomainPanels { + if !evaluateCondition(layout.Condition, client, &domain) { + continue + } + panel := buildPanel( + layout.PanelKey, template, domain, client, + datasourceType, datasourceUID, + panelID, layout.YOffset, layout.XOffset, layout.Width, + ) + if panel == nil { + continue + } + applyTitle(panel, layout, client.ClientTitle, domain.DomainName, domain.SID) + panel["description"] = domain.Aliases + insidePanels = append(insidePanels, panel) + panelID++ + } + } + + row["panels"] = insidePanels + return row +} + +// generateLayoutPanels генерирует список панелей по layouts (режим без collapsed rows) +func generateLayoutPanels( + layouts []PanelLayout, + client ClientData, + domain *DomainInfo, + template *DashboardTemplate, + datasourceType, datasourceUID string, + startPanelID, startY int, +) ([]interface{}, int, int) { + panels := []interface{}{} + panelID := startPanelID + maxHeight := 0 + + effectiveDomain := tenantDomain(client) + if domain != nil { + effectiveDomain = *domain + } + + for _, layout := range layouts { + if !evaluateCondition(layout.Condition, client, domain) { + continue + } + panel := buildPanel( + layout.PanelKey, template, effectiveDomain, client, + datasourceType, datasourceUID, + panelID, startY, layout.XOffset, layout.Width, + ) + if panel == nil { + continue + } + applyTitle(panel, layout, client.ClientTitle, effectiveDomain.DomainName, effectiveDomain.SID) + if domain != nil { + panel["description"] = domain.Aliases + } + panels = append(panels, panel) + panelID++ + + gridPos := panel["gridPos"].(map[string]interface{}) + if h, ok := gridPos["h"].(float64); ok && int(h) > maxHeight { + maxHeight = int(h) + } + } + + return panels, panelID, startY + maxHeight +} + +// applyTitle применяет title_format или title_suffix к панели. +// Поддерживаемые плейсхолдеры: {client}, {domain}, {sid}, %s (fmt.Sprintf для clientTitle). +// title_suffix: результат " " с подстановкой плейсхолдеров. +// title_format: полный контроль над заголовком через плейсхолдеры. +func applyTitle(panel map[string]interface{}, layout PanelLayout, clientTitle, domainName, sid string) { + applyPlaceholders := func(s string) string { + s = strings.ReplaceAll(s, "{client}", clientTitle) + s = strings.ReplaceAll(s, "{domain}", domainName) + s = strings.ReplaceAll(s, "{sid}", sid) + if strings.Contains(s, "%s") { + s = fmt.Sprintf(s, clientTitle) + } + return s + } + + if layout.TitleFormat != "" { + panel["title"] = applyPlaceholders(layout.TitleFormat) + } else if layout.TitleSuffix != "" && domainName != "" { + panel["title"] = fmt.Sprintf("%s %s", domainName, applyPlaceholders(layout.TitleSuffix)) + } +} + +// tenantDomain создаёт псевдо-домен для tenant-level панелей (все SID клиента через OR) +func tenantDomain(client ClientData) DomainInfo { + return DomainInfo{ + SID: buildTenantSIDQuery(client.Domains), + DomainName: client.ClientTitle, + Aliases: fmt.Sprintf("%d domains", len(client.Domains)), + } +} + +func buildTenantSIDQuery(domains []DomainInfo) string { + sids := make([]string, 0, len(domains)) + for _, d := range domains { + sids = append(sids, d.SID) + } + return strings.Join(sids, " OR ") +} + +func createRowPanel(title string, template *DashboardTemplate, id, yPos int) map[string]interface{} { + row := deepCopy(template.Panels["row"]).(map[string]interface{}) + row["id"] = id + row["title"] = title + row["collapsed"] = false + row["gridPos"].(map[string]interface{})["y"] = yPos + return row +} + +func yOffsetOrDefault(yOffset, defaultVal int) int { + if yOffset > 0 { + return yOffset + } + return defaultVal +} + +// Заглушка чтобы компилятор не ругался на неиспользуемый импорт +var _ = log.Printf diff --git a/database.go b/database.go new file mode 100644 index 0000000..8e4e6a5 --- /dev/null +++ b/database.go @@ -0,0 +1,104 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "strings" + + _ "github.com/lib/pq" +) + +func connectDB(config Config) (*sql.DB, error) { + connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + config.DBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName) + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, err + } + + if err := db.Ping(); err != nil { + return nil, err + } + + return db, nil +} + +func fetchClientsData(db *sql.DB, useManual bool) (map[string]ClientData, error) { + tableName := "sp_info" + if useManual { + tableName = "manual_info" + } + + // aliases это JSON массив в БД, конвертируем в текст через ::text + query := fmt.Sprintf(` + SELECT DISTINCT + COALESCE(a.client_title, 'Unknown') as client_title, + s.sid, + s.domain_name, + COALESCE(s.aliases::text, 'null') as aliases + FROM %s s + LEFT JOIN apps_settings a ON s.sid = a.l7resourceid + WHERE s.sid IS NOT NULL + ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid + `, tableName) + + rows, err := db.Query(query) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + defer rows.Close() + + clients := make(map[string]ClientData) + + for rows.Next() { + var clientTitle, sid, domainName, aliasesJSON string + + if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON); err != nil { + return nil, fmt.Errorf("scan failed: %w", err) + } + + // Распарсить JSON массив aliases + aliasesStr := parseAliasesJSON(aliasesJSON) + + if _, exists := clients[clientTitle]; !exists { + clients[clientTitle] = ClientData{ + ClientTitle: clientTitle, + Domains: []DomainInfo{}, + } + } + + client := clients[clientTitle] + client.Domains = append(client.Domains, DomainInfo{ + SID: sid, + DomainName: domainName, + Aliases: aliasesStr, + }) + clients[clientTitle] = client + } + + return clients, rows.Err() +} + +// parseAliasesJSON конвертирует JSON массив aliases в строку +// Input: ["domain1.com", "domain2.com"] или null +// Output: "domain1.com, domain2.com" или "" +func parseAliasesJSON(jsonStr string) string { + // Если null или пустое + if jsonStr == "" || jsonStr == "null" { + return "" + } + + // Попытаться распарсить как JSON массив + var aliases []string + if err := json.Unmarshal([]byte(jsonStr), &aliases); err != nil { + // Если не удалось распарсить - вернуть как есть + log.Printf("Warning: Failed to parse aliases JSON: %s (error: %v)", jsonStr, err) + return jsonStr + } + + // Соединить через запятую + return strings.Join(aliases, ", ") +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..e85fce2 --- /dev/null +++ b/env.go @@ -0,0 +1,112 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +const ( + DefaultEnvFile = "/etc/grafana_gen/grafana_gen.env" +) + +// loadEnvFile загружает переменные из .env файла +// Не переопределяет уже установленные переменные окружения +func loadEnvFile(filepath string) error { + // Если файл не существует - это не ошибка + if _, err := os.Stat(filepath); os.IsNotExist(err) { + return nil + } + + file, err := os.Open(filepath) + if err != nil { + return fmt.Errorf("failed to open env file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Пропустить пустые строки и комментарии + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Разделить на KEY=VALUE + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid format at line %d: %s", lineNum, line) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Убрать кавычки если есть + value = strings.Trim(value, "\"'") + + // Установить переменную окружения ТОЛЬКО если её ещё нет + if os.Getenv(key) == "" { + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("failed to set %s: %w", key, err) + } + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading env file: %w", err) + } + + return nil +} + +// loadEnvFileWithFallback загружает .env файл с fallback путями +func loadEnvFileWithFallback() error { + // Попробовать пути в порядке приоритета: + // 1. Переменная окружения GRAFANA_GEN_ENV_FILE + // 2. /etc/grafana_gen/grafana_gen.env + // 3. ./grafana_gen.env (текущая директория) + // 4. ./.env + + paths := []string{ + os.Getenv("GRAFANA_GEN_ENV_FILE"), + DefaultEnvFile, + "./grafana_gen.env", + "./.env", + } + + for _, path := range paths { + if path == "" { + continue + } + + if err := loadEnvFile(path); err != nil { + continue + } + + // Если загрузка успешна + if _, err := os.Stat(path); err == nil { + return nil + } + } + + // Если ни один файл не найден - это не ошибка (все переменные через flags/env) + return nil +} + +// loadEnvFileQuiet загружает .env файл без логирования +// Возвращает true если файл был загружен +func loadEnvFileQuiet(filepath string) bool { + if err := loadEnvFile(filepath); err != nil { + return false + } + // Проверить что файл существует + if _, err := os.Stat(filepath); err == nil { + return true + } + return false +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..746ac8c --- /dev/null +++ b/git.go @@ -0,0 +1,207 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +func updateTemplates(config Config) (string, error) { + log.Printf("Updating templates from Git: %s (branch: %s)", sanitizeRepoURL(config.TemplatesRepo), config.TemplatesBranch) + + // Check if templates directory exists + if _, err := os.Stat(config.TemplatesPath); os.IsNotExist(err) { + // Clone repository + if err := cloneRepository(config); err != nil { + return "", err + } + return getCurrentCommit(config.TemplatesPath), nil + } + + // Check if it's a valid git repository + if !isGitRepository(config.TemplatesPath) { + log.Printf("Warning: %s exists but is not a git repository", config.TemplatesPath) + log.Printf("Remove the directory or use -skip-git-pull flag") + return "", fmt.Errorf("not a git repository") + } + + // Pull latest changes and detect if there were updates + if err := pullRepository(config); err != nil { + return getCurrentCommit(config.TemplatesPath), err + } + + return getCurrentCommit(config.TemplatesPath), nil +} + +func cloneRepository(config Config) error { + log.Printf("Cloning templates repository...") + + repoURL := buildRepoURL(config.TemplatesRepo, config.GitToken) + + cmd := exec.Command("git", "clone", "-b", config.TemplatesBranch, repoURL, config.TemplatesPath) + cmd.Stdout = os.Stdout + // Don't show stderr to avoid leaking token + var stderr strings.Builder + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + // Sanitize error message + errMsg := sanitizeGitError(stderr.String()) + return fmt.Errorf("failed to clone repository: %w\n%s", err, errMsg) + } + + log.Printf("Templates cloned successfully") + showCurrentCommit(config.TemplatesPath) + return nil +} + +func pullRepository(config Config) error { + // Get current commit before pull + oldCommit := getCurrentCommit(config.TemplatesPath) + + log.Printf("Pulling latest templates...") + + // Update remote URL with token if provided + if config.GitToken != "" { + if err := updateRemoteURL(config); err != nil { + log.Printf("Warning: Failed to update remote URL: %v", err) + } + } + + cmd := exec.Command("git", "-C", config.TemplatesPath, "pull", "origin", config.TemplatesBranch) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := sanitizeGitError(stderr.String()) + return fmt.Errorf("failed to pull repository: %w\n%s", err, errMsg) + } + + // Get current commit after pull + newCommit := getCurrentCommit(config.TemplatesPath) + + // Check if there were changes + if oldCommit == newCommit { + log.Printf("No new changes in templates (commit: %s)", truncateHash(oldCommit)) + } else if oldCommit == "" { + log.Printf("Successfully pulled templates") + showCurrentCommit(config.TemplatesPath) + } else { + log.Printf("Templates updated: %s -> %s", truncateHash(oldCommit), truncateHash(newCommit)) + showCommitDiff(config.TemplatesPath, oldCommit, newCommit) + } + + return nil +} + +func updateRemoteURL(config Config) error { + repoURL := buildRepoURL(config.TemplatesRepo, config.GitToken) + cmd := exec.Command("git", "-C", config.TemplatesPath, "remote", "set-url", "origin", repoURL) + return cmd.Run() +} + +func buildRepoURL(repoURL, token string) string { + if token == "" { + return repoURL + } + + // For Gitea, format: https://TOKEN@svc-git.cirex.ru/... + // Remove https:// prefix if present + cleanURL := strings.TrimPrefix(repoURL, "https://") + cleanURL = strings.TrimPrefix(cleanURL, "http://") + + return fmt.Sprintf("https://%s@%s", token, cleanURL) +} + +func sanitizeRepoURL(repoURL string) string { + // Remove token from URL for logging + if strings.Contains(repoURL, "@") { + parts := strings.SplitN(repoURL, "@", 2) + if len(parts) == 2 { + // Keep protocol and domain + protocol := "https://" + if strings.HasPrefix(repoURL, "http://") { + protocol = "http://" + } + return protocol + "***:***@" + parts[1] + } + } + return repoURL +} + +func sanitizeGitError(errMsg string) string { + // Remove potential token from error messages + // Support both Gitea (TOKEN@) and GitLab (oauth2:TOKEN@) formats + + // Gitea format: https://TOKEN@host + if strings.Contains(errMsg, "@") && strings.Contains(errMsg, "://") { + parts := strings.Split(errMsg, "://") + for i := 1; i < len(parts); i++ { + atIndex := strings.Index(parts[i], "@") + if atIndex > 0 { + parts[i] = "***@" + parts[i][atIndex+1:] + } + } + errMsg = strings.Join(parts, "://") + } + + // GitLab format: https://oauth2:TOKEN@host (legacy support) + if strings.Contains(errMsg, "oauth2:") { + parts := strings.Split(errMsg, "oauth2:") + for i := 1; i < len(parts); i++ { + atIndex := strings.Index(parts[i], "@") + if atIndex > 0 { + parts[i] = "***" + parts[i][atIndex:] + } + } + errMsg = strings.Join(parts, "oauth2:") + } + + return errMsg +} + +func isGitRepository(path string) bool { + gitDir := path + "/.git" + _, err := os.Stat(gitDir) + return err == nil +} + +func getCurrentCommit(templatesPath string) string { + cmd := exec.Command("git", "-C", templatesPath, "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func showCurrentCommit(templatesPath string) { + cmd := exec.Command("git", "-C", templatesPath, "log", "-1", "--oneline") + output, err := cmd.Output() + if err == nil { + log.Printf("Current template commit: %s", strings.TrimSpace(string(output))) + } +} + +func showCommitDiff(templatesPath, oldCommit, newCommit string) { + // Show files changed + cmd := exec.Command("git", "-C", templatesPath, "diff", "--name-only", oldCommit, newCommit) + output, err := cmd.Output() + if err == nil && len(output) > 0 { + files := strings.Split(strings.TrimSpace(string(output)), "\n") + log.Printf("Changed files:") + for _, file := range files { + log.Printf(" - %s", file) + } + } +} + +func truncateHash(hash string) string { + if len(hash) > 8 { + return hash[:8] + } + return hash +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..530a479 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module grafana-dashboard-generator + +go 1.21 + +require github.com/lib/pq v1.10.9 diff --git a/grafana.go b/grafana.go new file mode 100644 index 0000000..f932922 --- /dev/null +++ b/grafana.go @@ -0,0 +1,182 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" +) + +// sendToGrafana отправляет дашборд в Grafana +// Дашборд будет создан в указанной папке (folder) +func sendToGrafana(dashboard map[string]interface{}, config Config) error { + // Получить или создать folder ID + folderID, err := ensureFolder(config) + if err != nil { + log.Printf("Warning: Failed to ensure folder exists: %v", err) + log.Printf("Dashboard will be created in General folder") + folderID = 0 // General folder + } + + // Добавить folder ID в запрос + dashboard["folderId"] = folderID + if folderID > 0 { + log.Printf("Creating dashboard in folder: %s (ID: %d)", config.GrafanaFolder, folderID) + } + + jsonData, err := json.Marshal(dashboard) + if err != nil { + return fmt.Errorf("failed to marshal dashboard: %w", err) + } + + url := fmt.Sprintf("%s/api/dashboards/db", strings.TrimRight(config.GrafanaURL, "/")) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GrafanaAPIKey)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("grafana API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response to get dashboard URL + var response map[string]interface{} + if err := json.Unmarshal(body, &response); err == nil { + if url, ok := response["url"].(string); ok { + log.Printf("Dashboard URL: %s%s", config.GrafanaURL, url) + } + } + + return nil +} + +// ensureFolder создает папку если её нет и возвращает её ID +func ensureFolder(config Config) (int, error) { + if config.GrafanaFolder == "" { + return 0, nil // General folder + } + + // Поиск существующей папки + folderID, err := findFolder(config) + if err == nil && folderID > 0 { + return folderID, nil + } + + // Создать папку если не найдена + return createFolder(config) +} + +// findFolder ищет папку по имени +func findFolder(config Config) (int, error) { + url := fmt.Sprintf("%s/api/folders", strings.TrimRight(config.GrafanaURL, "/")) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return 0, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GrafanaAPIKey)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return 0, fmt.Errorf("failed to list folders: status %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + var folders []map[string]interface{} + if err := json.Unmarshal(body, &folders); err != nil { + return 0, err + } + + // Поиск по title + for _, folder := range folders { + if title, ok := folder["title"].(string); ok && title == config.GrafanaFolder { + if id, ok := folder["id"].(float64); ok { + log.Printf("Found existing folder: %s (ID: %d)", config.GrafanaFolder, int(id)) + return int(id), nil + } + } + } + + return 0, fmt.Errorf("folder not found") +} + +// createFolder создает новую папку +func createFolder(config Config) (int, error) { + url := fmt.Sprintf("%s/api/folders", strings.TrimRight(config.GrafanaURL, "/")) + + payload := map[string]interface{}{ + "title": config.GrafanaFolder, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return 0, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return 0, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GrafanaAPIKey)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("failed to create folder: status %d: %s", resp.StatusCode, string(body)) + } + + var response map[string]interface{} + if err := json.Unmarshal(body, &response); err != nil { + return 0, err + } + + if id, ok := response["id"].(float64); ok { + log.Printf("Created new folder: %s (ID: %d)", config.GrafanaFolder, int(id)) + return int(id), nil + } + + return 0, fmt.Errorf("failed to get folder ID from response") +} + +func logDryRun(clientTitle string, dashboard map[string]interface{}) { + jsonData, _ := json.MarshalIndent(dashboard, "", " ") + log.Printf("DRY RUN: Dashboard for %s (%d bytes)", clientTitle, len(jsonData)) + // Optionally print first 500 chars + if len(jsonData) > 500 { + log.Printf("Preview: %s...", string(jsonData[:500])) + } else { + log.Printf("Preview: %s", string(jsonData)) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f30bbd3 --- /dev/null +++ b/main.go @@ -0,0 +1,283 @@ +package main + +import ( + "flag" + "log" + "os" +) + +func main() { + // Load environment variables from .env file before parsing flags + // This allows .env file to provide defaults that can be overridden by flags + loadEnvFileWithFallback() + + config := parseFlags() + + // Initialize state manager + stateManager := NewStateManager(config.StateFile) + if err := stateManager.Load(); err != nil { + log.Printf("Warning: Failed to load state: %v", err) + } + + // Show last run info + if lastRun := stateManager.GetLastRun(); !lastRun.IsZero() { + log.Printf("Last successful run: %s", lastRun.Format("2006-01-02 15:04:05")) + clientCount, domainCount, sidCount := stateManager.GetStats() + log.Printf("Previous state: %d clients, %d domains, %d SIDs", clientCount, domainCount, sidCount) + } + + // Update templates from Git + var templateCommit string + if !config.SkipGitPull { + commit, err := updateTemplates(config) + if err != nil { + log.Printf("Warning: Failed to update templates from Git: %v", err) + log.Printf("Continuing with existing templates...") + } + templateCommit = commit + } else { + // Get current commit without pulling + templateCommit = getCurrentCommit(config.TemplatesPath) + } + + // Load dashboard template + template, err := loadTemplate(config.TemplatesPath) + if err != nil { + log.Fatalf("Failed to load template: %v", err) + } + + log.Printf("Loaded template version: %s", template.Version) + + // Connect to database + db, err := connectDB(config) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + // Fetch clients data + clients, err := fetchClientsData(db, config.UseManualInfo) + if err != nil { + log.Fatalf("Failed to fetch clients data: %v", err) + } + + log.Printf("Found %d clients", len(clients)) + + // Check if there are any clients + if len(clients) == 0 { + log.Printf("No clients found in database. Nothing to generate.") + log.Printf("This is not an error - database is empty or no data matches criteria.") + os.Exit(0) + } + + // Filter out clients without domains + validClients := make(map[string]ClientData) + skippedCount := 0 + for clientTitle, clientData := range clients { + if len(clientData.Domains) == 0 { + log.Printf("Warning: Client %s has no domains, skipping", clientTitle) + skippedCount++ + continue + } + validClients[clientTitle] = clientData + } + + if len(validClients) == 0 { + log.Printf("No valid clients with domains found. Nothing to generate.") + os.Exit(0) + } + + // Check for changes + hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients) + + if !hasChanges && !config.ForceRun { + log.Printf("=== No Changes Detected ===") + log.Printf("No changes in templates or database since last run.") + log.Printf("Skipping dashboard generation.") + log.Printf("") + log.Printf("Use -force flag to regenerate anyway.") + os.Exit(0) + } + + if hasChanges { + log.Printf("=== Changes Detected ===") + log.Printf("%s", changeReason) + log.Printf("") + } + + if config.ForceRun { + log.Printf("Force run enabled - regenerating dashboard") + } + + // Generate single dashboard with all clients + log.Printf("Generating single dashboard: %s", config.DashboardTitle) + log.Printf("Including %d clients with %d total domains", len(validClients), countTotalDomains(validClients)) + + dashboard := generateSingleDashboard(validClients, template, config) + + if config.DryRun { + logDryRun(config.DashboardTitle, dashboard) + log.Printf("=== Summary ===") + log.Printf("Clients: %d", len(validClients)) + log.Printf("Skipped: %d", skippedCount) + log.Printf("DRY RUN: Dashboard generated but not sent to Grafana") + os.Exit(0) + } + + // Send to Grafana + 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("Skipped: %d", skippedCount) + log.Printf("Status: FAILED") + os.Exit(1) + } + + log.Printf("Successfully created/updated dashboard: %s", config.DashboardTitle) + + // Summary + log.Printf("=== Summary ===") + log.Printf("Dashboard: %s", config.DashboardTitle) + log.Printf("Clients: %d", len(validClients)) + if skippedCount > 0 { + log.Printf("Skipped: %d", skippedCount) + } + log.Printf("Total domains: %d", countTotalDomains(validClients)) + log.Printf("Status: SUCCESS") + + // Update state + stateManager.Update(templateCommit, template.Version, clients, changeReason) + if err := stateManager.Save(); err != nil { + log.Printf("Warning: Failed to save state: %v", err) + } else { + log.Printf("State saved to %s", config.StateFile) + } +} + +func countTotalDomains(clients map[string]ClientData) int { + count := 0 + for _, client := range clients { + count += len(client.Domains) + } + return count +} + +func parseFlags() Config { + config := Config{} + + // Database flags - resolve from env vars with fallback to defaults + dbUser := getEnvOrDefault("DB_USER", DefaultDBUser) + dbPassword := getEnvOrDefault("DB_PASSWORD", DefaultDBPassword) + + // Grafana settings - resolve from env vars with fallback to defaults + grafanaFolder := getEnvOrDefault("GRAFANA_FOLDER", DefaultGrafanaFolder) + dashboardTitle := getEnvOrDefault("DASHBOARD_TITLE", DefaultDashboardTitle) + + flag.StringVar(&config.DBHost, "db-host", DefaultPrimaryDBHost, "PostgreSQL host") + flag.IntVar(&config.DBPort, "db-port", DefaultDBPort, "PostgreSQL port") + flag.StringVar(&config.DBUser, "db-user", dbUser, "PostgreSQL user") + flag.StringVar(&config.DBPassword, "db-password", dbPassword, "PostgreSQL password") + flag.StringVar(&config.DBName, "db-name", DefaultDBName, "PostgreSQL database name") + flag.BoolVar(&config.UseManualInfo, "use-manual", false, "Use manual_info table instead of sp_info") + + // Grafana flags + var grafanaURLFlag, grafanaAPIKeyFlag string + flag.StringVar(&grafanaURLFlag, "grafana-url", "", "Grafana URL") + flag.StringVar(&grafanaAPIKeyFlag, "grafana-api-key", "", "Grafana API key") + flag.StringVar(&config.GrafanaFolder, "grafana-folder", grafanaFolder, "Grafana folder for dashboards") + flag.StringVar(&config.DashboardTitle, "dashboard-title", dashboardTitle, "Dashboard title") + + // Git flags + var gitTokenFlag string + flag.StringVar(&gitTokenFlag, "git-token", "", "Gitea token (overrides env var and hardcoded value)") + flag.StringVar(&config.TemplatesRepo, "templates-repo", DefaultTemplatesRepo, "Git repository URL for templates") + flag.StringVar(&config.TemplatesBranch, "templates-branch", DefaultTemplatesBranch, "Git branch for templates") + flag.StringVar(&config.TemplatesPath, "templates-path", DefaultTemplatesPath, "Local path for templates") + flag.BoolVar(&config.SkipGitPull, "skip-git-pull", false, "Skip updating templates from Git") + + // 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") + flag.BoolVar(&config.ForceRun, "force", false, "Force regeneration even if no changes detected") + + flag.Parse() + + // Resolve tokens with priority + config.GitToken = resolveToken("Git", gitTokenFlag, "GIT_TOKEN", DefaultGitToken) + config.GrafanaAPIKey = resolveToken("Grafana API", grafanaAPIKeyFlag, "GRAFANA_API_KEY", DefaultGrafanaAPIKey) + config.GrafanaURL = resolveValue("Grafana URL", grafanaURLFlag, "GRAFANA_URL", DefaultGrafanaURL) + + // Validate required parameters + if config.GrafanaURL == "" { + log.Fatal("Error: grafana-url is required (use -grafana-url flag, GRAFANA_URL env var, .env file, or set DefaultGrafanaURL in config.go)") + } + if config.GrafanaAPIKey == "" { + log.Fatal("Error: grafana-api-key is required (use -grafana-api-key flag, GRAFANA_API_KEY env var, .env file, or set DefaultGrafanaAPIKey in config.go)") + } + if config.DBUser == "" { + log.Fatal("Error: db-user is required (use -db-user flag, DB_USER env var, or .env file)") + } + if config.DBPassword == "" { + log.Fatal("Error: db-password is required (use -db-password flag, DB_PASSWORD env var, or .env file)") + } + + return config +} + +// resolveToken определяет токен с приоритетом: +// 1. Флаг командной строки +// 2. Переменная окружения +// 3. Hardcoded константа +func resolveToken(name, flagValue, envVar, defaultValue string) string { + // Приоритет 1: флаг командной строки + if flagValue != "" { + log.Printf("Using %s token from command line flag", name) + return flagValue + } + + // Приоритет 2: переменная окружения + if envValue := os.Getenv(envVar); envValue != "" { + log.Printf("Using %s token from %s environment variable", name, envVar) + return envValue + } + + // Приоритет 3: hardcoded в config.go + if defaultValue != "" { + log.Printf("Using %s token from config.go (hardcoded)", name) + return defaultValue + } + + // Токен не задан + return "" +} + +// resolveValue определяет значение с приоритетом (для non-secret параметров) +func resolveValue(name, flagValue, envVar, defaultValue string) string { + // Приоритет 1: флаг командной строки + if flagValue != "" { + return flagValue + } + + // Приоритет 2: переменная окружения + if envValue := os.Getenv(envVar); envValue != "" { + log.Printf("Using %s from %s environment variable", name, envVar) + return envValue + } + + // Приоритет 3: hardcoded в config.go + if defaultValue != "" { + log.Printf("Using %s from config.go (hardcoded)", name) + return defaultValue + } + + return "" +} + +// getEnvOrDefault возвращает переменную окружения или default значение +func getEnvOrDefault(envVar, defaultValue string) string { + if value := os.Getenv(envVar); value != "" { + return value + } + return defaultValue +} diff --git a/panels.go b/panels.go new file mode 100644 index 0000000..2e33672 --- /dev/null +++ b/panels.go @@ -0,0 +1,313 @@ +package main + +import ( + "fmt" + "log" + "strings" +) + +// targetBuilder — тип функции-строителя targets для конкретного query_mode. +type targetBuilder func(cfg map[string]interface{}, sidQuery string, datasourceType, datasourceUID string, template *DashboardTemplate, domain DomainInfo, client ClientData) []interface{} + +// queryModeRegistry — реестр строителей targets по query_mode. +// Чтобы добавить новый тип панели в Go: одна строка здесь + функция ниже. +// Всё остальное — только в JSON. +var queryModeRegistry = map[string]targetBuilder{ + "rps": buildRPSTargets, + "multi_target": buildMultiTargets, + "range_percent": buildRangePercentTargets, + "multi_sum_expression": buildMultiSumExpressionTargets, + "sum_expression": buildSumExpressionTargets, + "bucket_logs": buildBucketLogsTargets, +} + +// conditionEvaluator — тип функции-вычислителя условия отображения панели. +type conditionEvaluator func(value interface{}, client ClientData, domain *DomainInfo) bool + +// conditionRegistry — реестр вычислителей условий по типу. +// Чтобы добавить новый тип условия: одна строка здесь + лямбда. +var conditionRegistry = map[string]conditionEvaluator{ + "min_domains": func(value interface{}, client ClientData, _ *DomainInfo) bool { + return len(client.Domains) >= int(value.(float64)) + }, + "max_domains": func(value interface{}, client ClientData, _ *DomainInfo) bool { + return len(client.Domains) <= int(value.(float64)) + }, + "client_name_matches": func(value interface{}, client ClientData, _ *DomainInfo) bool { + return strings.Contains(client.ClientTitle, value.(string)) + }, +} + +// buildPanel — универсальная функция построения панели. +// Тип панели и логика запросов полностью определяются полем query_mode в JSON-шаблоне. +func buildPanel( + panelKey string, + template *DashboardTemplate, + domain DomainInfo, + client ClientData, + datasourceType, datasourceUID string, + id, yPos, xPos, width int, +) map[string]interface{} { + panelDef, ok := template.Panels[panelKey] + if !ok { + log.Printf("Warning: panel '%s' not found in template", panelKey) + return nil + } + + panelMap, ok := panelDef.(map[string]interface{}) + if !ok { + return nil + } + + queryMode, _ := panelMap["query_mode"].(string) + if queryMode == "" { + log.Printf("Warning: panel '%s' has no query_mode", panelKey) + return nil + } + + builder, ok := queryModeRegistry[queryMode] + if !ok { + log.Printf("Warning: unknown query_mode '%s' for panel '%s'. Available: %v", queryMode, panelKey, registeredModes()) + return nil + } + + queryConfigRaw, _ := panelMap["query_config"].(map[string]interface{}) + panel := copyPanelBase(panelMap, id, yPos, xPos, width) + sidQuery := buildSIDQuery(domain.SID) + + panel["targets"] = builder(queryConfigRaw, sidQuery, datasourceType, datasourceUID, template, domain, client) + panel["datasource"] = map[string]interface{}{ + "type": datasourceType, + "uid": datasourceUID, + } + + return panel +} + +// evaluateCondition проверяет условие отображения панели через реестр +func evaluateCondition(condition *PanelCondition, client ClientData, domain *DomainInfo) bool { + if condition == nil { + return true + } + evaluator, ok := conditionRegistry[condition.Type] + if !ok { + log.Printf("Warning: unknown condition type '%s'. Available: %v", condition.Type, registeredConditions()) + return true + } + return evaluator(condition.Value, client, domain) +} + +func registeredModes() []string { + modes := make([]string, 0, len(queryModeRegistry)) + for k := range queryModeRegistry { + modes = append(modes, k) + } + return modes +} + +func registeredConditions() []string { + types := make([]string, 0, len(conditionRegistry)) + for k := range conditionRegistry { + types = append(types, k) + } + return types +} + +// copyPanelBase копирует визуальную часть панели, убирая служебные поля шаблона +func copyPanelBase(panelMap map[string]interface{}, id, yPos, xPos, width int) map[string]interface{} { + panel := deepCopy(panelMap).(map[string]interface{}) + delete(panel, "query_mode") + delete(panel, "query_config") + + panel["id"] = id + gridPos, ok := panel["gridPos"].(map[string]interface{}) + if !ok { + gridPos = map[string]interface{}{} + panel["gridPos"] = gridPos + } + gridPos["y"] = yPos + gridPos["x"] = xPos + if width > 0 { + gridPos["w"] = width + } + return panel +} + +func buildSIDQuery(sid string) string { + if strings.Contains(sid, " OR ") { + return fmt.Sprintf("SID:(%s)", sid) + } + return fmt.Sprintf("SID:%s", sid) +} + +func replacePlaceholders(s, sid, clientTitle string) string { + s = strings.ReplaceAll(s, "{sid}", sid) + s = strings.ReplaceAll(s, "{sids}", sid) + s = strings.ReplaceAll(s, "{client_title}", clientTitle) + return s +} + +func getBucketAgg(template *DashboardTemplate) map[string]interface{} { + return deepCopy(template.Queries["bucket_agg"]).(map[string]interface{}) +} + +func makeDataTarget(alias, query, refID string, metric map[string]interface{}, hidden bool, datasourceType, datasourceUID string, bucketAgg map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "alias": alias, + "bucketAggs": []interface{}{ + map[string]interface{}{ + "field": bucketAgg["field"], + "id": "2", + "settings": bucketAgg["settings"], + "type": bucketAgg["type"], + }, + }, + "datasource": map[string]interface{}{"type": datasourceType, "uid": datasourceUID}, + "format": "table", + "hide": hidden, + "luceneQueryType": "Metric", + "metrics": []interface{}{metric}, + "query": query, + "queryType": "lucene", + "refId": refID, + "timeField": "@timestamp", + } +} + +func makeMathTarget(expression, refID string) map[string]interface{} { + return map[string]interface{}{ + "datasource": map[string]interface{}{ + "name": "Expression", + "type": "__expr__", + "uid": "__expr__", + }, + "expression": expression, + "hide": false, + "refId": refID, + "type": "math", + } +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// --- Строители targets --- + +func buildRPSTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, client ClientData) []interface{} { + query := replacePlaceholders(getString(cfg, "base_query"), sidQuery, client.ClientTitle) + alias := replacePlaceholders(getString(cfg, "alias"), sidQuery, client.ClientTitle) + bucketAgg := getBucketAgg(template) + countMetric := map[string]interface{}{"id": "1", "type": "count"} + + return []interface{}{ + makeDataTarget(alias, query, "A", countMetric, true, datasourceType, datasourceUID, bucketAgg), + makeMathTarget(getString(cfg, "expression"), getString(cfg, "expression_ref_id")), + } +} + +func buildMultiTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} { + targetsRaw, _ := cfg["targets"].([]interface{}) + bucketAgg := getBucketAgg(template) + countMetric := map[string]interface{}{"id": "1", "type": "count"} + + targets := []interface{}{} + for _, t := range targetsRaw { + tMap, ok := t.(map[string]interface{}) + if !ok { + continue + } + query := sidQuery + if suffix := getString(tMap, "query_suffix"); suffix != "" { + query = sidQuery + " " + suffix + } + targets = append(targets, makeDataTarget( + getString(tMap, "alias"), query, getString(tMap, "ref_id"), + countMetric, false, datasourceType, datasourceUID, bucketAgg, + )) + } + return targets +} + +func buildRangePercentTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} { + fieldFilter := getString(cfg, "field_filter") + totalRefID := getString(cfg, "total_ref_id") + rangeField := getString(cfg, "range_field") + rangesRaw, _ := cfg["ranges"].([]interface{}) + bucketAgg := getBucketAgg(template) + countMetric := map[string]interface{}{"id": "1", "type": "count"} + + totalQuery := sidQuery + if fieldFilter != "" { + totalQuery = sidQuery + " AND " + fieldFilter + } + + targets := []interface{}{ + makeDataTarget("Total", totalQuery, totalRefID, countMetric, true, datasourceType, datasourceUID, bucketAgg), + } + + for _, r := range rangesRaw { + rMap, ok := r.(map[string]interface{}) + if !ok { + continue + } + label := getString(rMap, "label") + refID := getString(rMap, "ref_id") + query := fmt.Sprintf("%s AND %s:[%s TO %s]", sidQuery, rangeField, getString(rMap, "from"), getString(rMap, "to")) + + targets = append(targets, + makeDataTarget(label, query, refID, countMetric, true, datasourceType, datasourceUID, bucketAgg), + makeMathTarget(fmt.Sprintf("($%s / $%s) * 100", refID, totalRefID), label), + ) + } + return targets +} + +func buildMultiSumExpressionTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} { + targetsRaw, _ := cfg["targets"].([]interface{}) + bucketAgg := getBucketAgg(template) + + targets := []interface{}{} + for _, t := range targetsRaw { + tMap, ok := t.(map[string]interface{}) + if !ok { + continue + } + sumMetric := map[string]interface{}{"field": getString(tMap, "field"), "id": "1", "type": "sum"} + targets = append(targets, + makeDataTarget(getString(tMap, "alias"), sidQuery, getString(tMap, "ref_id"), sumMetric, true, datasourceType, datasourceUID, bucketAgg), + makeMathTarget(getString(tMap, "expression"), getString(tMap, "expression_ref_id")), + ) + } + return targets +} + +func buildSumExpressionTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, template *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} { + bucketAgg := getBucketAgg(template) + sumMetric := map[string]interface{}{"field": getString(cfg, "field"), "id": "1", "type": "sum"} + + return []interface{}{ + makeDataTarget("", sidQuery, getString(cfg, "ref_id"), sumMetric, true, datasourceType, datasourceUID, bucketAgg), + makeMathTarget(getString(cfg, "expression"), getString(cfg, "expression_ref_id")), + } +} + +func buildBucketLogsTargets(cfg map[string]interface{}, sidQuery, datasourceType, datasourceUID string, _ *DashboardTemplate, _ DomainInfo, _ ClientData) []interface{} { + query := strings.ReplaceAll(getString(cfg, "base_query"), "{sid}", sidQuery) + return []interface{}{ + map[string]interface{}{ + "bucketAggs": deepCopy(cfg["bucket_aggs"]), + "datasource": map[string]interface{}{"type": datasourceType, "uid": datasourceUID}, + "format": "table", + "luceneQueryType": "Logs", + "metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}}, + "query": query, + "queryType": "lucene", + "refId": "A", + "timeField": "@timestamp", + }, + } +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..7f85415 --- /dev/null +++ b/state.go @@ -0,0 +1,264 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +type State struct { + LastRun time.Time `json:"last_run"` + TemplateCommit string `json:"template_commit"` + TemplateVersion string `json:"template_version"` + ClientsHash string `json:"clients_hash"` + ClientCount int `json:"client_count"` + DomainCount int `json:"domain_count"` + SIDs []string `json:"sids"` + LastChangeReason string `json:"last_change_reason,omitempty"` +} + +type StateManager struct { + stateFile string + state *State +} + +func NewStateManager(stateFile string) *StateManager { + return &StateManager{ + stateFile: stateFile, + } +} + +func (sm *StateManager) Load() error { + data, err := os.ReadFile(sm.stateFile) + if err != nil { + if os.IsNotExist(err) { + // First run - no state file + sm.state = &State{} + return nil + } + return fmt.Errorf("failed to read state file: %w", err) + } + + sm.state = &State{} + if err := json.Unmarshal(data, sm.state); err != nil { + return fmt.Errorf("failed to parse state file: %w", err) + } + + return nil +} + +func (sm *StateManager) Save() error { + sm.state.LastRun = time.Now() + + // Create directory if it doesn't exist + dir := filepath.Dir(sm.stateFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create state directory: %w", err) + } + + data, err := json.MarshalIndent(sm.state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + if err := os.WriteFile(sm.stateFile, data, 0644); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + return nil +} + +func (sm *StateManager) CheckChanges(templateCommit, templateVersion string, clients map[string]ClientData) (bool, string) { + if sm.state == nil { + return true, "First run - no previous state" + } + + reasons := []string{} + + // Check template commit + if sm.state.TemplateCommit != templateCommit { + reasons = append(reasons, fmt.Sprintf("Template commit changed: %s -> %s", + truncateHash(sm.state.TemplateCommit), truncateHash(templateCommit))) + } + + // Check template version + if sm.state.TemplateVersion != templateVersion { + reasons = append(reasons, fmt.Sprintf("Template version changed: %s -> %s", + sm.state.TemplateVersion, templateVersion)) + } + + // Calculate new hash + newHash := calculateClientsHash(clients) + if sm.state.ClientsHash != newHash { + reasons = append(reasons, "Database content changed") + + // Get details about what changed + newSIDs, removedSIDs, totalNew := compareSIDs(sm.state.SIDs, extractSIDs(clients)) + + if len(newSIDs) > 0 { + reasons = append(reasons, fmt.Sprintf(" - New SIDs: %v", newSIDs)) + } + if len(removedSIDs) > 0 { + reasons = append(reasons, fmt.Sprintf(" - Removed SIDs: %v", removedSIDs)) + } + if totalNew == 0 && len(newSIDs) == 0 && len(removedSIDs) == 0 { + reasons = append(reasons, " - Data modified (domains, aliases, etc.)") + } + } + + // Check client count + newClientCount := len(clients) + if sm.state.ClientCount != newClientCount { + reasons = append(reasons, fmt.Sprintf("Client count changed: %d -> %d", + sm.state.ClientCount, newClientCount)) + } + + // Check domain count + newDomainCount := countDomains(clients) + if sm.state.DomainCount != newDomainCount { + reasons = append(reasons, fmt.Sprintf("Domain count changed: %d -> %d", + sm.state.DomainCount, newDomainCount)) + } + + hasChanges := len(reasons) > 0 + changeReason := "" + if hasChanges { + changeReason = fmt.Sprintf("Changes detected:\n %s", joinReasons(reasons)) + } + + return hasChanges, changeReason +} + +func (sm *StateManager) Update(templateCommit, templateVersion string, clients map[string]ClientData, changeReason string) { + if sm.state == nil { + sm.state = &State{} + } + + sm.state.TemplateCommit = templateCommit + sm.state.TemplateVersion = templateVersion + sm.state.ClientsHash = calculateClientsHash(clients) + sm.state.ClientCount = len(clients) + sm.state.DomainCount = countDomains(clients) + sm.state.SIDs = extractSIDs(clients) + sm.state.LastChangeReason = changeReason +} + +func calculateClientsHash(clients map[string]ClientData) string { + // Create a deterministic representation + type hashData struct { + ClientTitle string + Domains []struct { + SID string + DomainName string + Aliases string + } + } + + var data []hashData + for _, client := range clients { + hd := hashData{ClientTitle: client.ClientTitle} + for _, domain := range client.Domains { + hd.Domains = append(hd.Domains, struct { + SID string + DomainName string + Aliases string + }{ + SID: domain.SID, + DomainName: domain.DomainName, + Aliases: domain.Aliases, + }) + } + // Sort domains for consistent hash + sort.Slice(hd.Domains, func(i, j int) bool { + return hd.Domains[i].SID < hd.Domains[j].SID + }) + data = append(data, hd) + } + + // Sort by client title for consistent hash + sort.Slice(data, func(i, j int) bool { + return data[i].ClientTitle < data[j].ClientTitle + }) + + // Calculate hash + jsonData, _ := json.Marshal(data) + hash := sha256.Sum256(jsonData) + return fmt.Sprintf("%x", hash) +} + +func extractSIDs(clients map[string]ClientData) []string { + sidMap := make(map[string]bool) + for _, client := range clients { + for _, domain := range client.Domains { + sidMap[domain.SID] = true + } + } + + sids := make([]string, 0, len(sidMap)) + for sid := range sidMap { + sids = append(sids, sid) + } + sort.Strings(sids) + return sids +} + +func compareSIDs(oldSIDs, newSIDs []string) (added, removed []string, total int) { + oldMap := make(map[string]bool) + for _, sid := range oldSIDs { + oldMap[sid] = true + } + + newMap := make(map[string]bool) + for _, sid := range newSIDs { + newMap[sid] = true + if !oldMap[sid] { + added = append(added, sid) + } + } + + for _, sid := range oldSIDs { + if !newMap[sid] { + removed = append(removed, sid) + } + } + + total = len(added) + len(removed) + return +} + +func countDomains(clients map[string]ClientData) int { + count := 0 + for _, client := range clients { + count += len(client.Domains) + } + return count +} + +func joinReasons(reasons []string) string { + result := "" + for i, reason := range reasons { + if i > 0 { + result += "\n " + } + result += reason + } + return result +} + +func (sm *StateManager) GetLastRun() time.Time { + if sm.state == nil { + return time.Time{} + } + return sm.state.LastRun +} + +func (sm *StateManager) GetStats() (clientCount, domainCount, sidCount int) { + if sm.state == nil { + return 0, 0, 0 + } + return sm.state.ClientCount, sm.state.DomainCount, len(sm.state.SIDs) +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..1239a9e --- /dev/null +++ b/template.go @@ -0,0 +1,31 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +func loadTemplate(templatesPath string) (*DashboardTemplate, error) { + templateFile := filepath.Join(templatesPath, "dashboard_template.json") + + data, err := os.ReadFile(templateFile) + if err != nil { + return nil, fmt.Errorf("failed to read template file: %w", err) + } + + var template DashboardTemplate + if err := json.Unmarshal(data, &template); err != nil { + return nil, fmt.Errorf("failed to parse template JSON: %w", err) + } + + return &template, nil +} + +func deepCopy(src interface{}) interface{} { + data, _ := json.Marshal(src) + var dst interface{} + json.Unmarshal(data, &dst) + return dst +}