Привет, Хабр!

Если вы работаете с GitLab и у вас больше одного окружения — вы наверняка знаете этот ритуал: открываешь Settings → CI/CD → Variables, начинаешь вбивать переменные вручную, на пятой ошибаешься, на двадцатой теряешь счёт, на пятидесятой начинаешь сочувствовать тем, кто хранит секреты прямо в коде.

Я написал glenv — CLI-инструмент на Go, который синхронизирует .env файлы с GitLab CI/CD переменными через API. Под катом — история о том, почему существующих решений не хватило, как это устроено внутри и несколько примеров использования.


Предыстория

Всё началось с простой задачи: нужно было завести ~80 переменных для нового production-окружения. Существующие переменные жили в .env.production файле, который использовался локально. Оставалось только перенести их в GitLab.

Первая попытка — веб-интерфейс. Медленно, муторно, после двадцатой переменной начинаешь делать опечатки.

Вторая попытка — bash-скрипт на curl:

while IFS='=' read -r key value; do
  [[ "$key" =~ ^#.*$ ]] && continue
  curl -s -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
    --header "PRIVATE-TOKEN: $TOKEN" \
    --form "key=$key" \
    --form "value=$value"
done < .env.production

Работало, пока не перестало. Проблемы обнаруживались по одной: нет обработки masked/protected флагов, нет rate limiting (привет, 429), нет retry при ошибках, нет возможности посмотреть что изменится перед применением. И главное — никакого diff: запустил скрипт, получил непонятный результат, иди разбирайся.

После третьего "а что сейчас в GitLab, то же что в файле?" я решил написать нормальный инструмент.


Что уже есть и почему не подошло

Перед тем как писать своё, поискал готовые решения:

  • glab variable (официальный CLI) — работает с одной переменной за раз. glab variable set KEY value — это не bulk операции, это тот же ручной процесс, только в терминале.

  • nodejs-glabenv — Node.js, базовый import/export без классификации и rate limiting. Требует Node в окружении.

  • gitlab-dotenv — Python, аналогично. Работает, но нет diff, нет умной классификации переменных.

  • Bash + curl — уже описал выше. Хрупко и без обратной связи.

Ни один не делал того, что реально нужно в production: показать diff перед применением, автоматически проставить masked/protected флаги, нормально обработать ошибки API, работать с несколькими окружениями из одного конфига.


Что умеет glenv

Коротко о возможностях:

  • Bulk sync — загружает весь .env файл в GitLab за одну команду

  • Diff перед применением — показывает что создастся, обновится или удалится, ничего не трогая

  • Автоклассификация — сам определяет masked, protected и file-тип по имени ключа и значению

  • Rate limiting — token bucket, общий на всех воркеров; корректно обрабатывает 429 с Retry-After

  • Multi-environment — production, staging и любые кастомные окружения из одного YAML конфига

  • Export — скачивает текущие переменные из GitLab в формат .env

  • Dry-run — показывает что произошло бы, без единого API-вызова

  • Self-hosted — работает с любым инстансом GitLab, настраиваемые лимиты

Написан на Go: статический бинарник, нет зависимостей рантайма, работает на Linux, macOS, Windows.


Установка

# macOS/Linux через Homebrew
brew install ohmylock/tools/glenv

# Через go install
go install github.com/ohmylock/glenv/cmd/glenv@latest

Или скачать бинарник под свою платформу со страницы релизов.


Основной сценарий использования

1. Смотрим что изменится

Сначала всегда стоит запустить diff:

export GITLAB_TOKEN="glpat-xxxxxxxxxxxx"
export GITLAB_PROJECT_ID="12345678"

glenv diff -f .env.production -e production

Вывод:

+ DB_HOST=postgres.internal
+ DB_PORT=5432
~ API_KEY: *** → ***           [masked]
- OLD_DEPRECATED_VAR
= LOG_LEVEL

+ — создастся, ~ — обновится, - — удалится, = — не изменится. Masked-значения показываются как ***.

Только убедившись что всё правильно, применяем:

glenv sync -f .env.production -e production

2. Автоклассификация переменных

GitLab требует чтобы masked-переменные были однострочными, минимум 8 символов, без спецсимволов. Проставлять это руками — боль. glenv делает это автоматически:

Свойство

Условие

masked

Ключ содержит _TOKEN, SECRET, PASSWORD, API_KEY, DSN — и значение однострочное, ≥8 символов

protected

Окружение production И ключ подходит под паттерн секрета

file

Ключ содержит PRIVATE_KEY, _CERT, _PEM — или значение содержит -----BEGIN

Переменные с плейсхолдерами (your_api_key_here, CHANGE_ME, REPLACE_WITH_) автоматически пропускаются — в GitLab не попадут.

Паттерны настраиваются через конфиг. Например, если у вас есть MAX_TOKENS (лимит запросов), его не нужно маскировать:

classify:
  masked_exclude:
    - "MAX_TOKENS"
    - "TIMEOUT"
    - "PORT"

3. Несколько окружений

Создаём .glenv.yml в корне проекта:

gitlab:
  token: ${GITLAB_TOKEN}        # поддерживается подстановка env-переменных
  project_id: "12345678"

rate_limit:
  requests_per_second: 10
  max_concurrent: 5

environments:
  staging:
    file: deploy/.env.staging
  production:
    file: deploy/.env.production

Теперь можно синхронизировать все окружения одной командой:

glenv sync --all

Окружения обрабатываются последовательно (в алфавитном порядке), ошибки агрегируются — если staging завалился, production всё равно попробует отработать, а в конце будет общий отчёт.

4. Export

Выгрузить текущие переменные из GitLab в файл:

glenv export -e production -o .env.production.backup

File-type переменные (сертификаты, PEM-ключи) пропускаются и заменяются комментарием # KEY (file type, skipped) — в .env формат они всё равно не влезут корректно.

5. Использование в GitLab CI

sync-variables:
  image: golang:1.23-alpine
  script:
    - go install github.com/ohmylock/glenv/cmd/glenv@latest
    - glenv sync -f deploy/.env.${CI_ENVIRONMENT_NAME} -e ${CI_ENVIRONMENT_NAME}
  variables:
    GITLAB_TOKEN: ${DEPLOY_TOKEN}
    GITLAB_PROJECT_ID: ${CI_PROJECT_ID}

Немного про устройство изнутри

Rate limiting

GitLab.com пропускает ~2000 запросов в минуту (~33/сек). При 5 воркерах без ограничений легко улететь в 429.

glenv использует token bucket rate limiter, который делится между всеми воркерами. По умолчанию — 10 запросов в секунду. При 429-ответе читается заголовок Retry-After, инструмент ждёт указанное время, затем повторяет попытку с экспоненциальным backoff + jitter. Максимум 3 ретрая на операцию.

Для self-hosted инстансов лимиты настраиваются:

glenv sync -f .env -e production --workers 10 --rate-limit 50

Парсинг .env

Поддерживаются:

KEY=value
QUOTED="value with spaces"
SINGLE_QUOTED='value'

# Многострочные значения
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"

Переменные с интерполяцией (${OTHER_VAR}/path) пропускаются — они скорее всег�� не имеют смысла как GitLab-переменные.

Diff engine

Перед применением изменений движок делает:

  1. Парсит локальный .env файл

  2. Получает текущие переменные из GitLab (с пагинацией)

  3. Сравнивает по ключу и environment scope

  4. Формирует список изменений: CREATE / UPDATE / DELETE / UNCHANGED / SKIPPED

  5. При sync — раздаёт изменения по воркер-пулу с rate limiting


Текущие ограничения

Честно о том, чего пока нет:

  • Только project-level переменные. Group-level — в планах, но пока не реализовано

  • Один проект за раз. Несколько проектов — несколько конфигов и несколько вызовов

  • Нет glenv import для копирования переменных между проектами

  • Интеграционные тесты требуют реального GitLab-инстанса и GITLAB_TEST_PROJECT_ID


Планы

  • Group-level переменные — управление переменными на уровне группы

  • glenv import — копирование переменных между проектами или инстансами

  • Watch mode — отслеживать изменения в .env файле и синхронизировать автоматически

  • Pre-built binary в GitHub Actions — чтобы не делать go install в каждом pipeline


Итого

glenv решает конкретную задачу: синхронизировать .env файлы с GitLab CI/CD переменными без ручной работы и без риска случайно что-то сломать. Diff перед применением, автоматическая классификация masked/protected, нормальная обработка rate limit — то, чего не хватало в существующих решениях.

Исходники: github.com/ohmylock/glenv. Буду рад вопросам, issues и PR-ам.

А как вы управляете GitLab CI/CD переменными в своих проектах? Пишете скрипты, пользуетесь официальным CLI или нашли другое решение? Расскажите в комментариях.