Мой опыт перевода Grafana под управление Terraform или что делать если надоело тыкать на кнопки в GUI

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Введение

Вам сказали развернуть систему мониторинга, вы выбрали связку Prometheus + Grafana. Развернули Grafana на своих серверах (VM/Docker/Kubernetes) и подключили Data Source Prometheus (а возможно вам еще сказали развернуть логирование и вы используете Grafana Loki) и далее по гайдам из ютуба начали создавать свои дашборды и настраивать алерты.

Все работает идеально, но в один момент вы начинаете думать о том, чтобы хранить созданные сущности Grafana в коде, чтобы их можно было легко восстановить в случае потери данных или же развернуть при создании новой среды (dev/prod). Экспортировать дашборды не составит труда, это можно сделать и через GUI, но как же источники данных, политики уведомлении, contact points и сами алерты?
Знакомая история? Возможно, что нет. А у меня да!

Перед прочтением

В данной статье довольно много кода и команд. Я больше хочу рассказать о своем опыте и не преследую цели написать обучающую статью, но если она кому-то поможет, то это замечательно!

Что планируем

Задача довольно проста - перевести полностью настроенную Grafana под управление terraform (обожаю его!). Но перед этим изучив её API и получив нужные данные.

С чем начинаем

У меня в распоряжении развернутый Prometheus и Grafana Loki, подключенные к Grafana (все крутится в Kubernetes). Имеется два дашборда: первый для просмотра статуса запущеннных микросервисов и второй для просмотра логов. Для каждой панели в этих двух дашбордах настроен свой алерт. В случае с алертами в первом дашборде, в дискорд приходят уведомления, если микросервис остановился, а также resolved сообщение, что микросервис вновь запущен. В случае с алертами во втором дашборде, в дискорд приходят уведомления, если количество ERROR логов в минуту превысило 25 единиц.

Grafana API

API-ключ

Для начала следует получить API ключ, чтобы иметь возможность обращаться к API графаны. Зайдем в настройки и перейдем во вкладку "Service accounts"

Создадим сервисный аккаунт с ролью Admin и назовем его terraform:

Сгенерируем токен для сервисного аккаунта и по желанию назначим дату истечения токена:

Скопируем токен и сохраним его в переменную окружения:

$ export GRAFANA_TOKEN=<token_itself>

Получение ID ресурсов

Попробуем выполнить запрос к REST API графаны с полученным выше токеном:

$ curl -H "Authorization: Bearer $GRAFANA_TOKEN" https://<grafana_host>/api/dashboards/home | jq
{
  "meta": {
    "isHome": true,
    "canSave": false,
    "canEdit": true,
    "canAdmin": false,
	<other_content_is_hidden>
}

Я использую Grafana v10.0.5, поэтому буду использовать конечные точки отсюда. И так, получим ID всех сущностей, которые нам нужно будет импортировать в terraform. Для начала получим список всех источников данных:

curl --location 'https://<grafana_host>/api/datasources' \
	 --header "Authorization: Bearer $GRAFANA_TOKEN" | jq
[
  {
    "id": 5,
    "uid": "FeVw13D2",
    "orgId": 1,
    "name": "Loki",
    "type": "loki",
	<other_content_is_hidden>
  },
  {
    "id": 1,
    "uid": "d4V1dDwe",
    "orgId": 1,
    "name": "Prometheus",
    "type": "prometheus",
    <other_content_is_hidden>
  }
]

Список всех дашбордов:

$ curl --location "https://<grafana_host>/api/search?query=%" \
       --header "Authorization: Bearer $GRAFANA_TOKEN" | jq
[
  {
    "id": 9,
    "uid": "FvDw34Dq",
    "title": "Kubernetes deployment running status",
	<other_content_is_hidden>
  },
  {
    "id": 12,
    "uid": "3Cw21Sqwr",
    "title": "Kubernetes logging",
    <other_content_is_hidden>
  }
]

Список всех папок:

$ curl --location "https://<grafana_host>/api/folders" \
	 --header "Authorization: Bearer $GRAFANA_TOKEN" | jq
[
  {
    "id": 11,
    "uid": "8Dve31Xcs",
    "title": "Discord Alerting"
  },
  {
    "id": 14,
    "uid": "cDq3s12Zs",
    "title": "Discord Alerting Loki"
  }
]

Список contact points:

$ curl --location "https://<grafana_host>/api/v1/provisioning/contact-points" \
--header "Authorization: Bearer $GRAFANA_TOKEN" | jq
[
  {
    "uid": "cMux1S3cS",
    "name": "Discord",
    "type": "discord",
	  <other_content_is_hidden>
    "disableResolveMessage": false
  },
  {
    "uid": "BqgE23vD",
    "name": "Discord (without Resolved)",
    "type": "discord",
    <other_content_is_hidden>
    "disableResolveMessage": true
  }
]

В документации вы не сможете найти как получить список всех алертов. Я долго искал и нашел на форуме графаны конечную точку:

curl --location "https://<grafana_host>/api/ruler/grafana/api/v1/rules" \
	 --header "Authorization: Bearer $GRAFANA_TOKEN" | jq
{
  "Discord Alerting": [
    {
      "name": "default",
      "interval": "30s",
      "rules": [
        {
          "expr": "",
          "for": "3m",
          "labels": {
            "discord": "channel"
          },
          "annotations": {
            <other_content_is_hidden>
            "description": "site-api status available"
          },
          "grafana_alert": {
            "id": 9,
            "orgId": 1,
            "title": "site-api status available",
            "condition": "B",
            "data": [
              {
                <other_content_is_hidden>
                "datasourceUid": "d4V1dDwe",
                "model": {
                  "datasource": {
                    "type": "prometheus",
                    "uid": "d4V1dDwe"
                  },
                  "editorMode": "builder",
                  "expr": "kube_deployment_status_replicas_available{namespace=\"development\", deployment=\"site-api\"}",
                  <other_content_is_hidden>
                }
              }
            ],
            "intervalSeconds": 30,
            "uid": "fW3vDw31S",
            <other_content_is_hidden>
          }
        },
      ]
    }
  ],
  "Discord Alerting Loki": [
    {
      "name": "default",
      "interval": "30s",
      "rules": [
        {
          "expr": "",
          "for": "30s",
          "labels": {
            "discord": "channel_resolved_0",
          },
          "annotations": {
	        <other_content_is_hidden>
            "description": "office-api ERROR logs count MORE THAN 25 for one minute!"
          },
          "grafana_alert": {
            "id": 17,
            "orgId": 1,
            "title": "office-api ERROR logs count",
            "condition": "B",
            "data": [
              {
                <other_content_is_hidden>
                "datasourceUid": "FeVw13D2",
                "model": {
                  "datasource": {
                    "type": "loki",
                    "uid": "FeVw13D2"
                  },
                  "editorMode": "code",
                  "expr": "count_over_time(({namespace=\"development\", app=\"office-api\"} |= \"ERROR\")[1m])",
                  <other_content_is_hidden>
                },
                {
                  <other_content_is_hidden>
                }
              }
            ],
            "intervalSeconds": 20,            
            "uid": "-vFw2ZdwW",
            <other_content_is_hidden>
          }
        }
      ]
    }
  ]
}

В "rules" содержится список всех алертов и их id. У меня их много, поэтому я решил ограничиться только одним из каждой папки.

Terraform

Создаем первоначальную структуру

Код будет храниться в Git репозитории, поэтому важно правильно огранизовать структуру папок и файлов.
Создадим следующую структуру папок:

$ tree -a
.
├── .env.example
├── environments
│   └── development
│       ├── .env
│       ├── main.tf
│       └── variables.tf
├── .gitignore
└── modules
    ├── grafana_alerting
    │   ├── main.tf
    │   ├── outputs.tf
    │   └── variables.tf
    └── grafana_oss
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

Добавим в .gitignore указания не добавлять в git репозитории файлы, связанные с terraform и файлы, хранящие переменные окружения:

# file: ./.gitignore
*terraform*
.env

В каждом окружении будет свой .env файл со своими переменными окружения. Так как эти файлы не будут храниться в git, то мы создали файл .env.example, содержащий переменные оружения с пустыми значениями:

# file: ./.env.example
export GRAFANA_AUTH=
export GRAFANA_URL=

Эти переменные окружения будут использоваться для настройки провайдера в terraform. GRAFANA_AUTH должен содержать API токен или login/password в base64. Через GRAFANA_URL указывается адрес по которому развернута графана.
Скопируем файл в папку ./environments/development и укажем значения:

$ cp ./.env.example ./environments/development/.env
# file: ./environments/development/.env
export GRAFANA_AUTH=$GRAFANA_TOKEN
export GRAFANA_URL="https://<grafana_host>/"

Настройка провайдера

Подключим провайдер Grafana и наши будущие модули:

// file: ./environments/development/main.tf

terraform {
  required_providers {
    grafana = {
      source = "grafana/grafana"
      version = "2.1.0"
    }
  }
}

provider "grafana" {
}

module "grafana_oss" {
  source = "../../modules/grafana_oss"
}

module "grafana_alerting" {
  source = "../../modules/grafana_alerting"
}

В main.tf файл в каждом модуле необходимо также добавить Grafana в required_providers:

// files: ./modules/grafana_oss/main.tf && ./modules/grafana_alerts/main.tf

terraform {
  required_providers {
    grafana = {
      source = "grafana/grafana"
      version = "2.1.0"
    }
  }
}

Запустим .env файл тем самым активировав переменные окружения:

$ . ./environments/development/.env

Выполним terraform init для инициализации модулей и установки провайдера:

$ terraform -chdir=./environments/development init

Initializing the backend...
Initializing modules...
- grafana_alerting in ../../modules/grafana_alerting
- grafana_oss in ../../modules/grafana_oss

Initializing provider plugins...
- Finding grafana/grafana versions matching "2.1.0"...
- Installing grafana/grafana v2.1.0...
- Installed grafana/grafana v2.1.0 (unauthenticated)

Импорт конфигурации

Terraform позволяет импортировать состояние и с недавних версии конфигурацию в формате HQL. Затем конфигурацию и само состояния мы будем перемещать в модуль средставами Terraform.

grafana_folder

Импортировать папку можно указав её UID или ID. Везде где можно будем использовать UID. У меня две папки, поэтому импортируем их обе. Добавим в конец корневого main.tf следующее:

// file: ./environments/main.tf
<other_content_is_hidden>

import {
  id = "8Dve31Xcs"
  to = grafana_folder.discord-alerting
}

import {
  id = "cDq3s12Zs"
  to = grafana_folder.discord-alerting-loki
}

Выполним команду terraform plan с параметром -generate-out-config и указав в качестве значения название файла:

terraform -chdir=./environments/development plan -generate-config-out folder.tf
grafana_folder.discord-alerting: Preparing import... [id=8Dve31Xcs]
grafana_folder.discord-alerting-loki: Preparing import... [id=cDq3s12Zs]
grafana_folder.discord-alerting-loki: Refreshing state... [id=cDq3s12Zs]
grafana_folder.discord-alerting: Refreshing state... [id=8Dve31Xcs]

Terraform will perform the following actions:

  # grafana_folder.discord-alerting will be imported
  # (config will be generated)
    resource "grafana_folder" "discord-alerting" {
        id     = "0:11"
        org_id = "0"
        title  = "Discord Alerting"
        uid    = "8Dve31Xcs"
        url    = "https://<grafana_host>/dashboards/f/8Dve31Xcs/discord-alerting"
    }

  # grafana_folder.discord-alerting-loki will be imported
  # (config will be generated)
    resource "grafana_folder" "discord-alerting-loki" {
        id     = "0:14"
        org_id = "0"
        title  = "Discord Alerting Loki"
        uid    = "cDq3s12Zs"
        url    = "https://<grafana_host>/dashboards/f/cDq3s12Zs/discord-alerting-loki"
    }

Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.

terraform вывел в консоль ресурсы, которые планируется импортировать, а также создал файл folder.tf с готовой конфигурацией. Примем изменения тем самым добавив ресурсы в состояние:

$ terraform -chdir=./environments/development apply          

Terraform will perform the following actions:
<imported_resources>
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

grafana_folder.discord-alerting: Importing... [id=8Dve31Xcs]
grafana_folder.discord-alerting: Import complete [id=8Dve31Xcs]
grafana_folder.discord-alerting-loki: Importing... [id=cDq3s12Zs]
grafana_folder.discord-alerting-loki: Import complete [id=cDq3s12Zs]

Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.

terraform просит ввести yes, если вы хотите принять изменения. Он также указывает, что в ходе terraform apply будет импортировано два ресурса.

Удалим директивы import из main.tf файла. Теперь осталось перенести импортированную конфигурацию и ее состояние из root модуля в grafana_oss. Сначала переместим конфигурационный файл:

$ mv ./environments/development/folder.tf ./modules/grafana_oss 

Если вы попробуете выполнить terraform plan, то terraform увидит, что конфигурация исчезла и захочет удалить ресурсы. Чтобы этого не случилось, переместим также состояние ресурсов в модуль:

$ terraform -chdir=./environments/development state mv grafana_folder.discord-alerting module.grafana_oss.grafana_folder.discord-alerting
Move "grafana_folder.discord-alerting" to "module.grafana_oss.grafana_folder.discord-alerting"
Successfully moved 1 object(s).

$ terraform -chdir=./environments/development state mv grafana_folder.discord-alerting-loki module.grafana_oss.grafana_folder.discord-alerting-loki
Move "grafana_folder.discord-alerting-loki" to "module.grafana_oss.grafana_folder.discord-alerting-loki"
Successfully moved 1 object(s).

Убедимся, что импорт прошел успешно:

$ terraform -chdir=./environments/development plan 
module.grafana_oss.grafana_folder.discord-alerting-loki: Refreshing state... [id=0:14]
module.grafana_oss.grafana_folder.discord-alerting: Refreshing state... [id=0:11]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found
no differences, so no changes are needed.

Terraform не видит изменении и сообщает, что инфраструктура не имеет изменении.
Провернем те же самые операции с остальными сущностями.

grafana_data_source

Добавим директивы import для импорта источников данных:

// file: ./environments/main.tf
<other_content_is_hidden>

import {
  id = "d4V1dDwe"
  to = grafana_data_source.prometheus
}

import {
  id = "FeVw13D2"
  to = grafana_data_source.loki
}

Запустим terraform plan:

terraform -chdir=./environments/development plan -generate-config-out data_source.tf
Terraform will perform the following actions:

  # grafana_data_source.loki will be imported
  # (config will be generated)
    resource "grafana_data_source" "loki" {
        access_mode        = "proxy"
        basic_auth_enabled = false
        id                 = "1:5"
        is_default         = false
        json_data_encoded  = jsonencode(
            {
                manageAlerts = false
            }
        )
        name               = "Loki"
        org_id             = "1"
        type               = "loki"
        uid                = "FeVw13D2"
        url                = "http://loki-stack:3100"
    }

  # grafana_data_source.prometheus will be imported
  # (config will be generated)
    resource "grafana_data_source" "prometheus" {
        access_mode        = "proxy"
        basic_auth_enabled = false
        id                 = "1:1"
        is_default         = true
        json_data_encoded  = jsonencode(
            {
                httpMethod    = "POST"
                tlsSkipVerify = true
            }
        )
        name               = "Prometheus"
        org_id             = "1"
        type               = "prometheus"
        uid                = "d4V1dDwe"
        url                = "http://prometheus-kube-prometheus-prometheus:9090"
    }

Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.

Выполним terraform apply и перед перемещением состояния и кода в модуль, немного отредактируем его, удалив ненужные значения:

// file: ./environments/development/data_source.tf
resource "grafana_data_source" "loki" {
  json_data_encoded        = "{\"manageAlerts\":false}"
  name                     = "Loki"
  org_id                   = "1"
  type                     = "loki"
  uid                      = "FeVw13D2"
  url                      = "http://loki-stack:3100"
}

resource "grafana_data_source" "prometheus" {
  is_default               = true
  json_data_encoded        = "{\"httpMethod\":\"POST\",\"tlsSkipVerify\":true}"
  name                     = "Prometheus"
  org_id                   = "1"
  type                     = "prometheus"
  uid                      = "d4V1dDwe"
  url                      = "http://prometheus-kube-prometheus-prometheus:9090"
}

Переместим всё в модуль:

$ mv ./environments/development/data_source.tf ./modules/grafana_oss 

$ terraform -chdir=./environments/development state mv grafana_data_source.loki module.grafana_oss.grafana_data_source.loki                 
Move "grafana_data_source.loki" to "module.grafana_oss.grafana_data_source.loki"
Successfully moved 1 object(s).

$ terraform -chdir=./environments/development state mv grafana_data_source.prometheus module.grafana_oss.grafana_data_source.prometheus
Move "grafana_data_source.prometheus" to "module.grafana_oss.grafana_data_source.prometheus"
Successfully moved 1 object(s).

Вынесем url источников данных в переменные terraform для гибкой настройки. Создадим переменные в модульном variables.tf со значениями по умолчанию - если у вас развернуты источники данных и Grafana в Kubernetes или docker-compose, то они скорее всего будут такими:

// file: ./modules/grafana_oss/variables.tf
variable "loki_data_source_url" {
  type = string
  default = "http://loki-stack:3100"
}

variable "prometheus_data_source_url" {
  type = string
  default = "http://prometheus-kube-prometheus-prometheus:9090"
}

Удалим значения из конфигурационного файла и вместо этого укажем переменные:

// file: ./modules/grafana_oss/data_source.tf
resource "grafana_data_source" "loki" {
  <other_content_is_hidden>
  url                      = var.loki_data_source_url
}

resource "grafana_data_source" "prometheus" {
  <other_content_is_hidden>
  url                      = var.prometheus_data_source_url
}

grafana_dashboard

Импортируем дашборды. Помимо хранения кода в Terraform, у каждого дашборда в Grafana есть своя JSON схема. Для начала импортируем дашборды в Terraform:

// file: ./environments/development/main.tf
<other_content_is_hidden>

import {
  id = "FvDw34Dq"
  to = grafana_dashboard.k8s-deployment-running-status
}

import {
  id = "3Cw21Sqwr"
  to = grafana_dashboard.k8s-logging
}

Выполним terraform plan с параметром для генерации конфигурации:

$ terraform -chdir=./environments/development plan -generate-config-out=dashboard.tf

<other_content_is_hidden>
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.

Примем изменения и сразу же переместим всё в модуль:

$ terraform -chdir=./environments/development apply

<other_content_is_hidden>
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

grafana_dashboard.k8s-logging: Importing... [id"3Cw21Sqwr]
grafana_dashboard.k8s-logging: Import complete [id"3Cw21Sqwr]
grafana_dashboard.k8s-deployment-running-status: Importing... [id=FvDw34Dq]
grafana_dashboard.k8s-deployment-running-status: Import complete [id=FvDw34Dq]

Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.

$ mv ./environments/development/dashboard.tf ./modules/grafana_oss 

$ terraform -chdir=./environments/development state mv grafana_dashboard.k8s-deployment-running-status module.grafana_oss.grafana_dashboard.k8s-deployment-running-status 
Move "grafana_dashboard.k8s-deployment-running-status" to "module.grafana_oss.grafana_dashboard.k8s-deployment-running-status"
Successfully moved 1 object(s).

$ terraform -chdir=./environments/development state mv grafana_dashboard.k8s-logging module.grafana_oss.grafana_dashboard.k8s-logging
Move "grafana_dashboard.k8s-logging" to "module.grafana_oss.grafana_dashboard.k8s-logging"
Successfully moved 1 object(s).

Посмотрим на сгенерированную конфигурацию:

// file: ./modules/grafana_oss/dashboard.tf
resource "grafana_dashboard" "k8s-logging" {
  config_json = "<huge_json_content>"
  folder      = null
  message     = null
  org_id      = "0"
  overwrite   = null
}

resource "grafana_dashboard" "k8s-deployment-running-status" {
  config_json = "<huge_json_content>"
  folder      = null
  message     = null
  org_id      = "0"
  overwrite   = null
}

В config_json содержится JSON схема дашборда. Так как значение очень длинное, то я решил его не вставлять. Хранить такие данные прямо в коде плохая идея, поэтому вынесем JSON схему дашборда в отдельный файл. Создадим папку где будут храниться схемы для всех дашбордов:

$ mkdir dasboard_schemas

Перед переносом JSON из config_json в файл, его необходимо прежде дезэкранировать и форматировать. Для дезэкранирования можно вывести значение как строку в Python, а затем для форматирования передать вывод echo в утилиту jq. Давайте экспортируем дашборды более легким способом - через GUI Графаны.

Зайдем в дашборд Kubernetes logging и нажмем на Export:

Нажмем Save to file и переместим файл в созданную ранее директорию dasboard_schemas. Переименуем его в k8s-logging.json. Тоже самое провернем с другим дашбордом.

В конечном итоге у нас получиться такая структура проекта:

$ tree
.
├── dashboard_schemas
│   ├── k8s-deployment-running-status.json
│   └── k8s-logging.json
├── environments
│   └── development
│       ├── main.tf
│       └── variables.tf
└── modules
    ├── grafana_alerting
    │   ├── main.tf
    │   ├── outputs.tf
    │   └── variables.tf
    └── grafana_oss
        ├── dashboard.tf
        ├── data_source.tf
        ├── folder.tf
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

Теперь удалим ненужные поля в конфигурации дашбордов, а также заменим содержимое config_json вызовом функции file() для извлечения содержимого файла:

// file: ./modules/grafana_oss/dashboard.tf
resource "grafana_dashboard" "k8s-logging" {
  config_json = "${file("../../dashboard_schemas/k8s-logging.json")}"
  org_id      = "0"
}

resource "grafana_dashboard" "k8s-deployment-running-status" {
  config_json = "${file("../../dashboard_schemas/k8s-deployment-running-status.json")}"
  org_id      = "0"
}

Выполним terraform plan и убедимся, что конфигурация не была изменена:

$ terraform -chdir=./environments/development plan

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

Просмотрев схемы дашбордов, я заметил, что там используются UID источников данных. В k8s-logging.json, например, UID источника данных Loki встречается 12 раз:

// file: ./dashboard_schemas/k8s-logging.json
<other_content_is_hidden>
"datasource": {
	"type": "loki",
	"uid": "FeVw13D2"
}
<other_content_is_hidden>

Так как в разных окружениях у источников данных может быть свой UID, то лучшим решением будет не указывать напрямую в коде UID, а вставлять его, используя значения из состояния Terraform.

Воспользуемся функцией templatefile. С помощью этой функции можно передавать в файл переменные и затем использовать их. Переменные задаются также как и в конфигурационных файлах terraform через ${}. Заменим везде значение UID на переменную:

// file: ./dashboard_schemas/k8s-logging.json
<other_content_is_hidden>
"datasource": {
  "type": "loki",
  "uid": "${loki_data_source_id}"
}
<other_content_is_hidden>

Аналогично заменим строковые значения на переменные в k8s-deployment-running-status.json:

// file: ./dashboard_schemas/k8s-deployment-running-status.json
"datasource": {
  "type": "prometheus",
  "uid": "${prometheus_data_source_uid}"
}

Чтобы вместо ${} использовать значения их нужно указать при вызове функции templatefile. Первым аргументом указывается путь до файла, вторым - список всех переменных:

resource "grafana_dashboard" "k8s-logging" {
  config_json = templatefile("../../dashboard_schemas/k8s-logging.json", {
    loki_data_source_uid = grafana_data_source.loki.uid
  })
  org_id      = "0"
}

resource "grafana_dashboard" "k8s-deployment-running-status" {
  config_json = templatefile("../../dashboard_schemas/k8s-deployment-running-status.json", {
    prometheus_data_source_uid = grafana_data_source.prometheus.uid
  })
  org_id      = "0"
}

grafana_contact_point

Приступим к модулю grafana_alerting. Для начала импортируем contact point-ы:

// file: ./environments/development/main.tf
<other_content_is_hidden>

import {
  id = "Discord Proselyte"
  to = grafana_contact_point.discord
}

import {
  id = "Discord Proselyte (without Resolved)"
  to = grafana_contact_point.discord-without-resolved
}

Выполним импорт конфигурации:

$ terraform -chdir=./environments/development plan -generate-config-out=contact-point.tf

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Missing required argument
│ 
│   with grafana_contact_point.discord,
│   on contact-point.tf line 7:
│   (source code not available)
│ 
│ The argument "discord.0.url" is required, but no definition was found.
╵
╷
│ Error: Missing required argument
│ 
│   with grafana_contact_point.discord-without-resolved,
│   on contact-point.tf line 7:
│   (source code not available)
│ 
│ The argument "discord.0.url" is required, but no definition was found.

Ошибка! Но файл contact-point.tf был создан:

// file: ./environments/development/contact-point.tf

resource "grafana_contact_point" "discord" {
  name = "Discord Proselyte"
  discord {
    avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"
    disable_resolve_message = false
    message                 = null
    settings                = null # sensitive
    url                     = null # sensitive
    use_discord_username    = false
  }
}

resource "grafana_contact_point" "discord-without-resolved" {
  name = "Discord Proselyte (without Resolved)"
  discord {
    avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"
    disable_resolve_message = true
    message                 = null
    settings                = null # sensitive
    url                     = null # sensitive
    use_discord_username    = false
  }
}

Дело в том, что значение discord.0.url (Webhook URL Discord бота) является секретным и поэтому terraform не импортирует его. Следует его указать самим. Хранить секреты в коде не стоит, поэтому для этого создадим переменные и будем назначать их через переменные окружения в .env файле. Но для начала укажем URL Webhook бота прямо в коде, а также удалим ненужные поля:

// file: ./environments/development/contact-point.tf

resource "grafana_contact_point" "discord" {
  name = "Discord Proselyte"
  discord {
    avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"
    disable_resolve_message = false
    url                     = "https://discord.com/api/webhooks/<other_url_part>"
  }
}

resource "grafana_contact_point" "discord-without-resolved" {
  name = "Discord Proselyte (without Resolved)"
  discord {
    avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"
    disable_resolve_message = true
    url                     = "https://discord.com/api/webhooks/<other_url_part>"
  }
}

Выполним terraform apply. Я думаю, что достаточно много раз показал как переносить конфигурацию и состояние из root модуля в другой, поэтому обойдемся без команд.
Теперь вынесем секретные данные (discord.0.url) из кода в переменные окружения. Для начала создадим в модульном variables.tf переменную:

// file: ./modules/grafana_alerting/variables.tf

variable "discord_webhook_url" {
  type = string
}

Помимо этого, необходимо также создать переменную в "рутовом" модуле:

// file: ./environments/development/variables.tf

variable "discord_webhook_url" {
  type = string
}

Теперь необходимо передать переменную из root модуля в модуль grafana_alerting:

// file: ./environments/development/main.tf
<other_content_is_hidden>

module "grafana_alerting" {
  source = "../../modules/grafana_alerting"

  discord_webhook_url = var.discord_webhook_url
}

Значение переменной будет назначаться путем переменной окружения. Для этого необходимо, чтобы переменная окружения начиналась с TF_VAR_:

# file: ./.env.example

export GRAFANA_AUTH=
export GRAFANA_URL=

export TF_VAR_discord_webhook_url=

Добавим также данную переменную в .env, но уже со значением:

# file: ./environments/development/.env

<other_content_is_hidden>

export TF_VAR_discord_webhook_url="https://discord.com/api/webhooks/<other_url_part>"

В contact-point.tf для discord.0.url укажем вместо строковых значении переменные:

// file: ./modules/grafana_alerting/contact-point.tf

resource "grafana_contact_point" "discord" {
  ...
  discord {
    ...
    url = var.discord_webhook_url 
  }
}

resource "grafana_contact_point" "discord-without-resolved" {
  ...
  discord {
    ...
    url = var.discord_webhook_url 
  }
}

Выполним terraform plan, чтобы убедиться, что с инфраструктурой всё в порядке.

grafana_notification_policy

В Terraform политики уведомлений (notification policy) представлены в одном ресурсе. Как указано в документации grafana провайдера, grafana_notification_policy контролирует дерево политик уведомлении. Также, в разделе "Import" указывается, что id ресурса равен "policy". Попробуем импортировать данный ресурс:

// file: ./environments/development/contact-point.tf

import {
  id = "policy"
  to = grafana_notification_policy.policy-tree
}

Выполним terraform plan, terraform apply, переместим конфигурацию и состояние в модуль grafana_alerting и сразу же удалим лишние поля и заменим строковые значения contact_point значениями из ресурсов:

// file: ./modules/grafana_alerting/notification-policy.tf

resource "grafana_notification_policy" "policy-tree" {
  contact_point   = "grafana-default-email"
  group_by        = ["grafana_folder", "alertname"]

  policy {
    contact_point   = grafana_contact_point.discord.name
    group_by        = []
    matcher {
      label = "discord"
      match = "="
      value = "channel"
    }
  }

  policy {
    contact_point   = grafana_contact_point.discord-without-resolved.name
    group_by        = []
    matcher {
      label = "discord"
      match = "="
      value = "channel_resolved_0"
    }
  }
}

grafana_rule_group

И вот мы добрались до самого интересного - алертов! На самом деле у меня их очень много, но для статьи я импортирую только два - для каждого contact point-а. В качестве id указывается uid папки и название группы алертов. В моем случае название группы алертов везде default:
![[Pasted image 20230905220950.png]]
Добавим импорты:

// file: ./environments/development/main.tf

import {
  id = "8Dve31Xcs;default"
  to = grafana_rule_group.discord-alerting
}

import {
  id = "cDq3s12Zs;default"
  to = grafana_rule_group.discord-alerting-loki
}

Импортируем конфигурацию в файл rule-group.tf и сразу перенесем её в модуль grafana_alerting.
У каждого алерта в группе алертов очень много полей. Вот так выглядят импортированные ранее ресурсы:

// file: ./modules/grafana_alerting/rule-group.tf

resource "grafana_rule_group" "discord-alerting" {
  folder_uid       = "8Dve31Xcs"
  interval_seconds = 30
  name             = "default"
  org_id           = "1"
  rule {
    annotations = {
      description      = "site-api status available"
    }
    condition      = "B"
    exec_err_state = "Alerting"
    for            = "3m"
    is_paused      = false
    labels = {
      discord = "channel"
    }
    name          = "site-api status available"
    no_data_state = "NoData"
    data {
      datasource_uid = "d4V1dDwe"
      model          = "{\"datasource\":{\"type\":\"prometheus\",\"uid\":\"d4V1dDwe\"},\"editorMode\":\"builder\",\"expr\":\"kube_deployment_status_replicas_available{namespace=\\\"development\\\", deployment=\\\"site-api\\\"}\",\"interval\":\"\",\"intervalMs\":15000,\"legendFormat\":\"{{deployment}}\",\"range\":true,\"refId\":\"A\"}"
      query_type     = null
      ref_id         = "A"
      relative_time_range {
        from = 300
        to   = 0
      }
    }
    data {
      datasource_uid = "-100"
      model          = "{\"conditions\":[{\"evaluator\":{\"params\":[1],\"type\":\"lt\"},\"operator\":{\"type\":\"and\"},\"query\":{\"params\":[\"A\"]},\"reducer\":{\"params\":[],\"type\":\"last\"},\"type\":\"query\"}],\"datasource\":{\"type\":\"__expr__\",\"uid\":\"-100\"},\"expression\":\"A\",\"hide\":false,\"refId\":\"B\",\"type\":\"classic_conditions\"}"
      query_type     = null
      ref_id         = "B"
      relative_time_range {
        from = 0
        to   = 0
      }
    }
  }
}

resource "grafana_rule_group" "discord-alerting-loki" {
  folder_uid       = "cDq3s12Zs"
  interval_seconds = 20
  name             = "default"
  org_id           = "1"
  rule {
    annotations = {
      description      = "office-api ERROR logs count MORE THAN 25 for one minute!"
    }
    condition      = "B"
    exec_err_state = "OK"
    for            = "30s"
    is_paused      = false
    labels = {
      discord          = "channel_resolved_0"
    }
    name          = "office-api ERROR logs count"
    no_data_state = "OK"
    data {
      datasource_uid = "FeVw13D2"
      model          = "{\"datasource\":{\"type\":\"loki\",\"uid\":\"FeVw13D2\"},\"editorMode\":\"code\",\"expr\":\"count_over_time(({namespace=\\\"development\\\", app=\\\"office-api\\\"} |= \\\"ERROR\\\")[1m])\",\"legendFormat\":\"{{app}}\",\"queryType\":\"range\",\"refId\":\"A\"}"
      query_type     = "range"
      ref_id         = "A"
      relative_time_range {
        from = 21600
        to   = 0
      }
    }
    data {
      datasource_uid = "-100"
      model          = "{\"conditions\":[{\"evaluator\":{\"params\":[25],\"type\":\"gt\"},\"operator\":{\"type\":\"and\"},\"query\":{\"params\":[\"A\"]},\"reducer\":{\"params\":[],\"type\":\"last\"},\"type\":\"query\"}],\"datasource\":{\"type\":\"__expr__\",\"uid\":\"-100\"},\"expression\":\"A\",\"hide\":false,\"refId\":\"B\",\"type\":\"classic_conditions\"}"
      query_type     = null
      ref_id         = "B"
      relative_time_range {
        from = 0
        to   = 0
      }
    }
  }
}

Как и с JSON схемами дашбордов, для групп алертов необходимо вынести строковые значения и использовать значения из ресурсов terraform. Так как конфигурация источников данных и папок находятся в модуле grafana_oss их необходимо передать в модуль grafana_alerting в качестве переменных.
Сперва добавим output переменные в grafana_oss:

// file: ./modules/grafana_oss/outputs.tf

output "discord_alerting_folder_uid" {
  value = grafana_folder.discord-alerting.uid
}

output "discord_alerting_loki_folder_uid" {
  value = grafana_folder.discord-alerting-loki.uid
}

output "prometheus_data_source_uid" {
  value = grafana_data_source.prometheus.uid
}

output "loki_data_source_uid" {
  value = grafana_data_source.loki.uid
}

Аналогично добавим input переменные в grafana_alerting:

// file: ./modules/grafana_alerting/variables.tf

...
variable "discord_alerting_folder_uid" {
  type = string
}

variable "discord_alerting_loki_folder_uid" {
  type = string
}

variable "prometheus_data_source_uid" {
  type = string
}

variable "loki_data_source_uid" {
  type = string
}

Передадим переменные из grafana_oss в grafana_alerting:

// file: ./environments/development/main.tf

<other_content_is_hidden>

module "grafana_alerting" {
  source = "../../modules/grafana_alerting"

  discord_webhook_url = var.discord_webhook_url

  discord_alerting_folder_uid      = module.grafana_oss.discord_alerting_folder_uid
  discord_alerting_loki_folder_uid = module.grafana_oss.discord_alerting_loki_folder_uid
  prometheus_data_source_uid       = module.grafana_oss.prometheus_data_source_uid
  loki_data_source_uid             = module.grafana_oss.loki_data_source_uid
}

Вернемся к группам алертов. Отредактируем файл с ресурсами:

// file: ./modules/grafana_alerting/rule-group.tf

resource "grafana_rule_group" "discord-alerting" {
  folder_uid = var.discord_alerting_folder_uid
  ...
  rule {
    ...
    data {
      datasource_uid = var.prometheus_data_source_uid
      model          = "{\"datasource\":{\"type\":\"prometheus\",\"uid\":\"${var.prometheus_data_source_uid}\"},\"editorMode\":\"builder\",\"expr\":\"kube_deployment_status_replicas_available{namespace=\\\"development\\\", deployment=\\\"site-api\\\"}\",\"interval\":\"\",\"intervalMs\":15000,\"legendFormat\":\"{{deployment}}\",\"range\":true,\"refId\":\"A\"}"
      ...
    }
    data {
      ...
    }
  }
}

resource "grafana_rule_group" "discord-alerting-loki" {
  folder_uid = var.discord_alerting_loki_folder_uid
  ...
  rule {
    ...
    data {
      datasource_uid = var.loki_data_source_uid
      model          = "{\"datasource\":{\"type\":\"loki\",\"uid\":\"${var.loki_data_source_uid}\"},\"editorMode\":\"code\",\"expr\":\"count_over_time(({namespace=\\\"development\\\", app=\\\"office-api\\\"} |= \\\"ERROR\\\")[1m])\",\"legendFormat\":\"{{app}}\",\"queryType\":\"range\",\"refId\":\"A\"}"
      ...
    }
    data {
      ...
    }
  }
}

Я заменил folder_uuid, rule.datasource_uid, а также uid в rule.data.model.

Заключение

В итоге были импортированы все ресурсы из Grafana и теперь можно управлять инфраструктурой через код. Это обеспечит её сохранность, а также возможность без лишних проблем и трат времени развернуть аналогичные ресурсы на других окружениях. В конечном итоге структура проекта выглядит следующим образом:

.
├── dashboard_schemas
│   ├── k8s-deployment-running-status.json
│   └── k8s-logging.json
├── environments
│   └── development
│       ├── main.tf
│       ├── terraform.tfstate
│       └── variables.tf
└── modules
    ├── grafana_alerting
    │   ├── contact-point.tf
    │   ├── main.tf
    │   ├── notification-policy.tf
    │   ├── outputs.tf
    │   ├── rule-group.tf
    │   └── variables.tf
    └── grafana_oss
        ├── dashboard.tf
        ├── data_source.tf
        ├── folder.tf
        ├── main.tf
        ├── outputs.tf
        └── variables.tf
Источник: https://habr.com/ru/articles/759348/


Интересные статьи

Интересные статьи

Привет, Хабр! Меня зовут Анзор Кардан, я руководитель продукта Teamplanner в Х5 Tech. В статье я поделюсь собственным опытом выбора инструмента планирования проектов, через какие стадии мы прошли и с ...
Я преподаю английский десять лет, но скажу честно: если вы разработчик, тестировщик, дизайнер – это значит, что вы сможете выучить английский сами. Конечно, нужны партнёры для общения, но ими могут бы...
Как у организации, создающей вручную несколько качественных сайтов в год, у нас быстро возникла необходимость извлечения максимальной выгоды из определенного набора базовых средств. Несмотря на то, чт...
Предлагаем вашему вниманию краткий список 34 достопримечательностей Луны (фото+местоположение). Ад, Москва, Альпы. Коперник и Шрёдингер. Лава, водовороты, призраки, цепочки, разломы и ...
Как не убить критикой, но и не скатиться в поверхностное ревью? Жирный PR против кучи мержей — что где выгоднее в зависимости от типа и размера проекта? А вот оно вообще правда нужно?...