env-master
ENСоздать аккаунт
Разделы документации

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-emptyfalseсчитать пустое значение отсутствием
validatefalseсверить окружение со схемой на сервере
export-to-envtrueкласть значения в переменные следующих шагов
file—дополнительно записать dotenv с правами 0600
versionlatestверсия @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.

Что показывать в логах

Ничего. Если очень нужно убедиться, что ключ доехал, печатайте длину или префикс отпечатка, но не значение — маскирование спасает от случайности, а не от намеренного вывода.