Привет, Хабр!
Если вы работаете с 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-AfterMulti-environment — production, staging и любые кастомные окружения из одного YAML конфига
Export — скачивает текущие переменные из GitLab в формат
.envDry-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 | Ключ содержит |
protected | Окружение |
file | Ключ содержит |
Переменные с плейсхолдерами (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
Перед применением изменений движок делает:
Парсит локальный
.envфайлПолучает текущие переменные из GitLab (с пагинацией)
Сравнивает по ключу и environment scope
Формирует список изменений: CREATE / UPDATE / DELETE / UNCHANGED / SKIPPED
При
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 или нашли другое решение? Расскажите в комментариях.
