Канонический pipeline
Production-pipeline для Terraform выглядит так:
PR opened/updated:
job-1 plan → выгружает plan.tfplan + plan.txt
job-2 lint → fmt, validate, tflint, checkov
job-3 policy → OPA/terraform-compliance над plan.json
[reviewer читает plan.txt из PR-комментария или artifact]
PR merged → main:
job-4 plan-prod → plan.tfplan на prod
[manual approval / required reviewers]
job-5 apply → apply plan.tfplan
Ключевое, apply применяет тот же plan, что reviewer одобрил. Это достигается через сохранение plan-файла как артефакта между jobs.
Зачем именно так
Альтернатива (плохая): apply делает свой fresh plan, потом apply'ит. Между PR-plan и apply-time-plan может пройти час; drift, новый коммит, обновлённая переменная, и apply делает не то, что вы видели в ревью.
Plan-файл фиксирует решение в момент времени. Apply его исполняет.
Если drift произошёл, apply ловит Saved plan is stale и падает,
заставляя сделать новый plan-cycle.
Команды для CI
# plan-job
terraform init -input=false -no-color
terraform plan -input=false -no-color -lock-timeout=2m -out=plan.tfplan
terraform show -no-color plan.tfplan > plan.txt
terraform show -json plan.tfplan > plan.json
# apply-job (отдельный job, скачал plan.tfplan)
terraform init -input=false -no-color
terraform apply -input=false -no-color -lock-timeout=10m plan.tfplan
Что здесь важно:
-input=false, никаких интерактивных prompt'ов. По умолчанию TF может спрашивать переменные с stdin, в CI это hang.-no-color, без ANSI-кодов в логах. Удобнее grep'ать.-lock-timeout, апишник может уже держать lock; даём 2 мин шанса. На apply дольше, потому что apply сам держит lock и параллельный job может прийти.-out=plan.tfplan, БИНАРНЫЙ plan. Нельзя редактировать руками.plan.txtиplan.json, human-readable и machine-readable представления одного и того же.
TF_IN_AUTOMATION=true (env-var), Terraform молчит про «next steps»,
убирает суггестии в CLI-выводе. Это для всего CI.
GitHub Actions: plan-job
jobs:
plan:
runs-on: ubuntu-latest
env:
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
# OIDC instead of access-keys; see tf-oidc-aws
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT:role/tf-runner
aws-region: us-east-1
- run: terraform init -no-color
- run: terraform plan -no-color -lock-timeout=2m -out=plan.tfplan
- run: terraform show -no-color plan.tfplan > plan.txt
- run: terraform show -json plan.tfplan > plan.json
- uses: actions/upload-artifact@v4
with:
name: tf-plan-${{ github.run_id }}path: |
plan.tfplan
plan.txt
plan.json
retention-days: 5
Артефакт включает все три формата: бинарный (для apply), txt (для ревьюера в PR-комментарии), json (для OPA/Checkov/compliance).
Apply-job
apply:
needs: plan
runs-on: ubuntu-latest
environment: prod # gating через GitHub environment
env:
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT:role/tf-runner
aws-region: us-east-1
- uses: actions/download-artifact@v4
with:
name: tf-plan-${{ github.run_id }}- run: terraform init -no-color
- run: terraform apply -no-color -lock-timeout=10m plan.tfplan
environment: prod в GHA, это gate. Можно настроить required reviewers,
ограничить branch, добавить wait-timer. Apply не стартует пока кто-то
из approver'ов не одобрит.
detailed-exitcode для PR-gate
Иногда нужно знать «есть изменения или нет» без application:
terraform plan -detailed-exitcode -out=plan.tfplan
# exit 0, no changes
# exit 1, error
# exit 2, has changes
CI-логика: exit 0 = «No-op», pause pipeline; exit 2 = «есть план, apply'м после approve»; exit 1 = bug, не apply'ить.
- id: plan
run: |
set +e
terraform plan -detailed-exitcode -out=plan.tfplan
code=$?
set -e
echo "exitcode=$code" >> "$GITHUB_OUTPUT"
test "$code" -ne 1
- if: steps.plan.outputs.exitcode == '0'
run: echo "No changes, skipping apply"
- if: steps.plan.outputs.exitcode == '2'
uses: actions/upload-artifact@v4
with:
name: tf-plan-${{ github.run_id }}path: plan.tfplan
PR-комментарий с плана
Помогает ревьюеру:
- name: Post plan to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs'); const plan = fs.readFileSync('plan.txt', 'utf8');const truncated = plan.length > 60000
? plan.slice(0, 60000) + '\n... (truncated)'
: plan;
github.rest.issues.createComment({issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '```hcl\n' + truncated + '\n```',
});
Reviewer видит plan inline в PR, не надо скачивать артефакт. Лимит размера PR-комментария в GitHub, 65k символов, отсюда truncate.
Plan-файл, секретный артефакт
В plan.tfplan лежат:
- Все sensitive значения (variable, output, locals).
- Все computed-атрибуты, в том числе ARN'ы и id'шки приватных ресурсов.
- Окружающие переменные, которые в plan вошли.
Это значит:
- retention минимальный (1-5 дней). После apply артефакт не нужен.
- Не публиковать публично. Private-репо ок; public-репо, катастрофа.
- Не клонировать в неуполномоченные jobs. Audit-job, который ради логирования читает plan и пишет в S3, должен иметь те же IAM привилегии, что плановый.
См. tf-secrets-in-state.
Multi-environment pipeline
Один и тот же pipeline на dev / stage / prod:
jobs:
plan-dev:
uses: ./.github/workflows/tf-plan.yml
with:
env: dev
workdir: envs/dev
apply-dev:
needs: plan-dev
uses: ./.github/workflows/tf-apply.yml
with:
env: dev
workdir: envs/dev
plan-prod:
needs: apply-dev
uses: ./.github/workflows/tf-plan.yml
with:
env: prod
workdir: envs/prod
# ...
Dev сначала, prod после успешного dev. Plan-файлы, разные артефакты, не путать.
Подводные камни
-
apply plan.tfplanбез init после download падает.actions/download-artifactне приносит.terraform/, провайдеры и модули. Сделай init перед apply. -
terraform init требует backend. Apply-job в другом jobs/runner не имеет конфига backend'а локально, он в HCL, init его прочитает. Если backend требует cross-account-роли, OIDC должен это покрывать.
-
Plan-файл privy к версии Terraform.
plan.tfplanот 1.9 не применится 1.8. В CI fix-версию черезhashicorp/setup-terraformодну для plan и apply. -
Cycle: apply создаёт что-то новое, что в plan не было. Это бывает когда provider добавляет default-теги; они появляются «откуда-то» при apply. Plan-as-artifact это не ловит, ловит drift-detection.
-
-targetв CI = красный флаг. Plan с-target, это «делаем подмножество». Если apply'ишь такой plan, остальной граф может оказаться в неконсистентном состоянии. CI не должен использовать-target; это инструмент аварийного recovery. -
-refresh=falseускоряет, но врёт. Plan без refresh пропускает drift с облаком, apply падает позже. Использовать только когда refresh реально дорог, и осознанно.