commit 692fa563e1c585a1252e4346c6258fff44ad2df1 Author: Magnus Root Date: Tue Feb 24 12:45:35 2026 +0300 Work version 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 +}