CI и деплой
Два приёма, которые переносятся в любой раннер: выдать доступ и прочитать значения. Делается это CLI, прямыми запросами к API или готовым action — он composite и одинаково работает в GitHub Actions, Gitea Actions и GitVerse Actions.
Action лежит в aistastudio/env-action.
Выдать доступ: enrollment-токен вместо постоянного секрета
Классическая схема «положить client secret в секреты CI» работает, но у
неё плохая цена утечки: секрет из лога или форка работает до отзыва.
Альтернатива — identity в режиме enrollment: пайплайн выпускает
одноразовый enrollment-токен непосредственно перед деплоем, а развёрнутый
сервис при первом старте меняет его на постоянные учётные данные. После
обмена токен бесполезен, а его повторное предъявление поднимает тревогу
enrollment.reuse-detected в журнале и вебхуком.
Шаг выпуска (нужен пользовательский токен с ролью admin в проекте):
ENROLLMENT_TOKEN=$(curl -s $BASE/services/$SERVICE/identities \
-H "authorization: Bearer $ENVMASTER_TOKEN" -H 'content-type: application/json' \
-d '{"name":"deploy '"$GITHUB_RUN_ID"'","environmentId":"'$ENV'","mode":"enrollment"}' \
| jq -r .enrollmentToken)
Шаг обмена — уже на машине, куда деплоите:
PUB=$(openssl genpkey -algorithm X25519 | \
openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64)
curl -s $BASE/machine/enroll -H 'content-type: application/json' \
-d "{\"enrollmentToken\":\"$ENROLLMENT_TOKEN\",\"publicKey\":\"$PUB\"}"
# -> { clientId, clientSecret, accessToken, expiresAt }
Токен живёт 10 минут: выпускайте его в том же джобе, который деплоит, а не заранее. Полный разбор режима — на странице Сервисы и identity.
Прочитать значения в джобе
Для самого CI (не для развёрнутого сервиса) заведите отдельную identity с
минимальным allowedKeys и, если адреса раннеров известны, allowedCidrs.
Учётные данные — в секретах CI:
envmaster run --project billing-api --env production --mask -- ./deploy.sh
# или, если файл неизбежен:
envmaster export --project billing-api --env production --file deploy.env
run предпочтительнее: значения уходят дочернему процессу и не остаются
на диске, а --mask фильтрует их из вывода. Подробности — в CLI.
Action: получить значения одним шагом
- uses: aistastudio/env-action@v1
with:
url: ${{ vars.ENVMASTER_URL }}
project: billing-api
environment: production
client-id: ${{ secrets.ENVMASTER_CLIENT_ID }}
client-secret: ${{ secrets.ENVMASTER_CLIENT_SECRET }}
keys: DB_URL,DB_PASSWORD # пусто — все доступные identity
export-to-env: true # положить в переменные следующих шагов
file: "" # или записать dotenv с правами 0600
Значения маскируются в логах раннера. export-to-env отключают, когда
значения нужны только в файле: переменные окружения видны через /proc и
наследуются дочерними процессами.
| Вход | По умолчанию | Что делает |
|---|---|---|
url, project, environment | — | куда идти и за чем |
client-id, client-secret | — | учётные данные identity из секретов CI |
keys | все доступные | сузить выгрузку; расширить права нельзя |
required | — | ключи, без которых шаг падает |
required-from-file | — | взять этот список из env.schema.yml репозитория |
check-empty | false | считать пустое значение отсутствием |
validate | false | сверить окружение со схемой на сервере |
export-to-env | true | класть значения в переменные следующих шагов |
file | — | дополнительно записать dotenv с правами 0600 |
version | latest | версия @env-master/sdk; проверка требует 0.1.4+ |
Выходы: keys (имена выгруженных ключей), count, validated.
Как пользоваться значениями дальше по пайплайну
Три способа, и выбор между ними — это выбор, где значение окажется.
В переменных следующих шагов (export-to-env: true, по умолчанию). Самый
короткий путь; значение живёт до конца джоба и наследуется каждым процессом,
который эти шаги запускают.
- uses: aistastudio/env-action@v1
with:
url: ${{ vars.ENVMASTER_URL }}
project: billing-api
environment: production
client-id: ${{ secrets.ENVMASTER_CLIENT_ID }}
client-secret: ${{ secrets.ENVMASTER_CLIENT_SECRET }}
keys: DATABASE_URL,REDIS_URL
- name: Миграции
run: ./migrate.sh # DATABASE_URL уже в окружении шага
- name: Тот же ключ выражением
run: echo "длина ключа ${#DATABASE_URL}"
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
Файлом (file:), когда инструмент читает dotenv сам. Файл пишется с
правами 0600 и остаётся в рабочем каталоге раннера до конца джоба:
- uses: aistastudio/env-action@v1
with:
# ...учётные данные...
export-to-env: false # в переменные не класть
file: .env.deploy
- name: Поднять стек
run: docker compose --env-file .env.deploy up -d
export-to-env: false здесь не перестраховка: переменные окружения видны
через /proc, наследуются дочерними процессами и попадают в crash-дампы.
Если значение нужно одному инструменту, который умеет читать файл, — пусть
только там и будет.
Одним процессом (envmaster run), когда шаг — это одна команда. Значения
уходят дочернему процессу и не остаются ни в файле, ни в переменных джоба:
- run: |
npx envmaster run --project billing-api --env production --mask -- ./deploy.sh
env:
ENVMASTER_URL: ${{ vars.ENVMASTER_URL }}
ENVMASTER_CLIENT_ID: ${{ secrets.ENVMASTER_CLIENT_ID }}
ENVMASTER_CLIENT_SECRET: ${{ secrets.ENVMASTER_CLIENT_SECRET }}
Что можно и чего нельзя делать со значением в шаге
Маскирование работает, но оно не броня. Action регистрирует каждое
значение как маску раннера, и прямая печать превращается в ***. Но маска
сверяет строки целиком: echo "${DATABASE_URL:0:8}", base64 и URL-кодирование
её обходят. Правило простое: значение не печатают, а не «печатают, потому что
замаскируется».
Не передавайте значения в сборку образа аргументом. --build-arg остаётся
в истории слоёв и виден любому, кто скачает образ:
# так — нет
- run: docker build --build-arg DB_PASSWORD=$DB_PASSWORD .
# так — да: секрет виден только на время RUN, в слои не попадает
- run: docker build --secret id=db,env=DB_PASSWORD .
Выходы шага — только имена и счётчик, значений среди них нет:
- uses: aistastudio/env-action@v1
id: secrets
with: { ... }
- run: echo "выгружено ${{ steps.secrets.outputs.count }}: ${{ steps.secrets.outputs.keys }}"
Не запускайте шаг на пул-реквестах из форков. pull_request из чужого
форка не должен получать доступ к боевому окружению — ограничьте условием:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
Для каждого пайплайна — своя identity с узким allowedKeys, а если адреса
раннеров известны, ещё и allowedCidrs. Тогда утёкшие учётные данные CI не
открывают всё окружение, а журнал доступа показывает, что читал именно
пайплайн, а не «кто-то из прода».
Две проверки, и они не заменяют друг друга
validate: true сверяет окружение со схемой, сохранённой на сервере, —
это контракт оператора: каким он хочет видеть окружение.
required и required-from-file проверяют список, который приносит
приложение из своего репозитория:
required: DB_URL,DB_PASSWORD,REDIS_URL
# или списком из репозитория — ключи с required: true:
# required-from-file: env.schema.yml
Шаг падает с перечнем недостающих ключей. В лог уходят имена и статусы, значения — никогда. Имена печатать безопасно: они и так перечислены в самом workflow и в схеме репозитория.
Запасных значений у action нет намеренно: подставить умолчание на шаге
сборки значит выкатить артефакт со значениями, о которых на проде никто не
знает. Умолчания и запасной .env — свойство приложения в рантайме, см.
SDK.
Проверка полноты до деплоя
env.schema.yml описывает, какие ключи обязательны и в каких окружениях:
version: 1
keys:
- name: DB_PASSWORD
kind: secret
required: true
environments: [staging, production]
- name: LOG_LEVEL
kind: var
required: false
envmaster validate сверяет схему с тем, что реально лежит в окружении, и
останавливает пайплайн, если ключа не хватает, — до рантайма, а не после.
Проверка идёт по именам, значения не раскрываются.
Тот же список читает и приложение — из кода, а не из пайплайна:
await em.assertRequirements(); // упасть на старте, если ключа нет
Это два разных момента одной проверки: пайплайн ловит нехватку до выкатки,
приложение — до того, как начнёт обслуживать запросы на неполном конфиге.
Список берётся из того же env.schema.yml, поэтому расходиться им негде;
подробности — в SDK.
Что показывать в логах
Ничего. Если очень нужно убедиться, что ключ доехал, печатайте длину или префикс отпечатка, но не значение — маскирование спасает от случайности, а не от намеренного вывода.