У пості GitHub Actions: деплой Dev/Prod оточень з Terraform я вже описував те, як можна реалізувати CI/CD для Terraform з GitHub Actions, але в тому рішенні є один суттєвий недолік: немає можливості зробити review змін перед тим, як їх застосувати з terraform apply.
GitHub Actions має можливість використання Reviewing deployments для approve/reject, проте ця можливість доступна тільки на GitHub Enterprise.
Тож як ми можемо вирішити цю проблему?
Звісно, ми взагалі можемо використати рішення на кшталт Atlatis або Gaia – але коли на проекті всього 4-5 репозиторіїв з Terraform, то такі утілити будуть трохи overkill.
Як варіант – продовжити використання GitHub Actions з hashicorp/setup-terraform, але робити terraform plan під час створення Pull Request, а його output виносити в коментарі до PR. Тоді перед тим, як підтвердити Merge – ми зможемо подивитись на те, які зміни будуть застосовані, і виконувати terraform apply після того, як feature/fix branch буде вмержено в master.
Хоча це теж не ідеальне рішення – бо між створенням Pull Request і виконанням Terraform Plan та мержем і виконанням Terraform Apply може пройти час, за який в інфраструктурі щось зміниться, і той Plan вже буде не актуальний – тож треба розібратись і з цим.
Отже, що будемо сьогодні робити:
- створимо тестовий код Terraform
- створимо два GitHub Actions Workflow:
- test on Pull Request created
- deploy on Pull Request merged
- і подивимось на варіанти того, як можна вирішити проблему з неактуальним Terraform Plan
Зміст
Підготовка
Створимо проект Terraform, потім підготуємо репозиторій GitHub.
Terraform – тестові ресурси
В тестовому репозиторії створюємо каталог terraform, і в ньому файл terraform.tf:
terraform {
backend "s3" {
bucket = "tf-state-backend-atlas-test"
key = "atlas-test.tfstate"
region = "us-east-1"
encrypt = true
}
}
terraform {
required_version = "~> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.14"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
component = "devops"
created-by = "terraform"
environment = "test"
project = "atlas-test"
}
}
}
Для тесту будемо створювати S3-бакет – додаємо main.tf:
resource "aws_s3_bucket" "example" {
bucket = "atlas-test-bucket-ololo"
tags = {
Name = "atlas-test-bucket-ololo"
}
}
Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket – AuthorizationHeaderMalformed
Трохи забігаючи наперед, і трохи “оффтопу”: вже коли робив terraform apply, то зловив помилку:
Error: creating S3 Bucket (atlas-test-bucket): operation error S3: CreateBucket, https response error StatusCode: 400, api error AuthorizationHeaderMalformed: The authorization header is malformed; the region ‘us-east-1’ is wrong; expecting ‘us-west-2’
Текст трохи вводить в оману, бо – при чому тут регіони? В provider "aws" у нас явно задано us-east-1, з AIM Role теж в порядку. Звідки взагалі береться us-west-2?
Якщо включити debug з export TF_LOG=debug, то бачимо, що дійсно:
http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-west-2″.
А “лікується” це тим, що – як ми знаємо – ім’я корзини має ж бути унікальним на весь AWS Region, тобто якщо додати отой суфікс “ololo” – то все працює, як треба:
http.response.header.server=AmazonS3 http.response.header.x_amz_bucket_region=us-east-1
Схоже, що чи то сам AWS, чи Terraform намагається зробити корзину в іншому регіоні, якщо в заданому така вже у когось є.
Окей, йдемо далі.
Робимо cd terraform/ && terraform fmt && terraform init && terraform plan:
Тут все готово, можемо переходити до налаштувань GitHub Actions.
GitHub Actions – змінні оточення репозиторію
Для запуску Terraform в GitHub Actions нам потрібна одна змінна, в якій ми будемо передавати AWS IAM Role, за допомогою якої GitHub зможе виконувати дії в нашому AWS акаунті – див. Configuring OpenID Connect in Amazon Web Services.
Переходимо в Settings > Actions secrets and variables, переходимо в Variables:
В Repository variables додаємо нову змінну TF_AWS_ROLE – вона буде використовуватись в aws-actions/configure-aws-credentials:
І в принципі наразі це все, що нам потрібно – можемо починати створювати наші GitHub Actions Workflow.
GitHub Actions – створення Workflow
Для того, щоб Actions зміг запустити наш код Terrform, нам потрібно:
- виконати checkout коду на GitHub Runner, на якому буде запускатись флоу: використаємо actions/checkout
- виконати аутентифікацію в AWS: використаємо aws-actions/configure-aws-credentials – він виконає AssumeRole зі змінної
TF_AWS_ROLEта створить змінні оточення з ключами таAWS_SESSION_TOKEN - запустити
terraform initта інші: використаємо hashicorp/setup-terraform – він додасть сам Terraform
Тут в принципі можна використати той же підхід з окремими Actions для кожного step, як описувалось в Створення test-on-push Workflow, але зараз для наочності та простоти все опишемо прям у файлі workflow.
Workflow з Terraform Validate та Plan
Першим створимо Workflow, який буде запускатись при створенні PR та буде виконувати перевірки з terraform validate, tflint і т.д, а потім буде виконувати terraform plan, результат якого буде додавати в коментарі до Pull Request, який тригернув цей workflow.
В корні репозиторію створюємо каталог .github/workflows, і в ньому файл terraform-test-on-pr.yml:
name: "Terraform: test on PR open"
# set other jobs with the same 'group' in a queue
concurrency:
group: deploy-test
cancel-in-progress: false
on:
# can be ran manually
workflow_dispatch:
# run on pull-requests
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# only if changes were in the 'terraform' directory
paths:
- terraform/**
permissions:
# create OpenID Connect (OIDC) ID token
id-token: write
# allow read repository's content by steps
contents: read
# allow adding comments in a Pull Request
pull-requests: write
jobs:
terraform:
name: "Test: Terraform"
runs-on: ubuntu-latest
# to avoid using GitHub Runners time
timeout-minutes: 10
defaults:
run:
# run all steps in the 'terraform' directory
working-directory: ./terraform
steps:
# get repository's content
- name: "Setup: checkout"
uses: actions/checkout@v4
# setup 'env.AWS_*' variables to run Terraform
- name: "Setup: Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TF_AWS_ROLE }}
role-session-name: github-actions-terraform
role-duration-seconds: 900
aws-region: us-east-1
# terraform formatting check
- name: "Test: Terraform fmt"
id: fmt
run: terraform fmt -check -no-color
# do not throw error, just warn
continue-on-error: true
# use TFLint to check the code
- name: "Setup: TFLint"
uses: terraform-linters/setup-tflint@v3
with:
tflint_version: v0.48.0
- name: "Test: Terraform linter"
run: tflint -f compact
shell: bash
# use official Action
- name: "Setup: Terraform"
uses: hashicorp/setup-terraform@v3
# get modules, configure backend
- name: "Test: Terraform Init"
id: init
run: terraform init -no-color
# verify whether a configuration is syntactically valid
- name: "Test: Terraform Validate"
id: validate
run: terraform validate -no-color
# create a Plan to see what will be changed
- name: "Test: Terraform Plan"
id: plan
run: terraform plan -no-color
В concurrency забороняємо одночасний запуск кількох workflows, див. Using concurrency, і це нам знадобиться далі, коли будемо робити terraform apply.
Для команд Terraform задаємо параметр -no-color, щоб потім в коментарях до Pull Request не мати зайвих символів:
Додаємо .gitignore:
**/.terraform/*
Див. повний приклад в Terraform.gitignore.
Комітимо всі зміни, пушимо в репозиторій, якщо зміни робили в окремому бранчі – то мержимо в master, щоб GitHub Actions “побачив” новий Workflow файл, і маємо запущений Workflow:
Додавання Terraform Plan в коментарі до Pull request
Далі використаємо actions/github-script, який через GitHub API може додавати коментарі до нашого PR.
А в коментарі використаємо outputs з нашого steps.plan.
Додаємо ще один step:
...
# generate comment to the Pull Request using 'steps.plan.outputs'
- name: "Misc: Post Terraform summary to PR"
uses: actions/github-script@v6
if: github.event_name == 'pull_request'
env:
PLAN: "${{ steps.plan.outputs.stdout }}"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
#### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
<details><summary>Validation Output</summary>
\`\`\`\n
${{ steps.validate.outputs.stdout }}
\`\`\`
</details>
#### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
<details><summary>Show Plan</summary>
\`\`\`\n
${process.env.PLAN}
\`\`\`
</details>
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
Тут:
- виконуємо
stepякщоgithub.event_name == 'pull_request' - створюємо змінну оточення
PLANзі змістом ізsteps.plan.outputs.stdout - запускаємо
actions/github-script, якому:- передаємо
GITHUB_TOKENдля аутентифікації (використаєpermissions.pull-requests: write) - передаємо аргумент
script, в якому:- створюємо
const output, де ми формуємо текст для коментаря - викликаємо функцію
github.rest.issues.createComment, яка:- в
bodyпередає значенняconst output - передає значення з GitHub Actions
context, який формується з webhook payload – див.context.tsта Webhook payload object for pull_request
- в
- створюємо
- передаємо
Мене трохи засмутило, що викликається github.rest.issues.createComment – бо чому issues, коли ми маємо справу з Pull Request?
Але в документації Sending requests to the GitHub API сказано, що “issues and PRs are treated as one concept by the Octokit client“.
І в документації самого клієнта:
You can use the REST API to create comments on issues and pull requests. Every pull request is an issue, but not every issue is a pull request.
При потребі, можна отримати всі дані з контекстів так:
...
- name: Dump Job Context
env:
JOB_CONTEXT: ${{toJson(github)}}
run: echo "$JOB_CONTEXT"
- name: View context attributes
uses: actions/github-script@v7
with:
script: console.log(context)
...
Окей.
Пушимо зміни, чекаємо, поки виконається джоба (пам’ятаємо, що тригер у нас – зміни в каталозі terraform):
І маємо новий коментар в Pull Request:
Що тут ще можна “затюнити” – це замість створення нового коментаря в Pull Request оновлювати вже існуючий.
Для цього можна використати функцію github.rest.issues.listComments, за допомогою якої знайти всі коментарі comment.body.includes(‘Terraform Format and Style’), і якщо такий знайдеться – то виконати github.rest.issues.updateComment, а якщо ні – то github.rest.issues.createComment, як робили вище:
...
- name: "Misc: Post Terraform summary to PR"
uses: actions/github-script@v6
if: github.event_name == 'pull_request'
env:
PLAN: "${{ steps.plan.outputs.stdout }}"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// 1. Retrieve existing bot comments for the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
})
const botComment = comments.find(comment => {
return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
})
// 2. Prepare format of the comment
const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
#### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
<details><summary>Validation Output</summary>
\`\`\`\n
${{ steps.validate.outputs.stdout }}
\`\`\`
</details>
#### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
<details><summary>Show Plan</summary>
\`\`\`\n
${process.env.PLAN}
\`\`\`
</details>
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Workflow: \`${{ github.workflow }}\`*`;
// 3. If we have a comment, update it, otherwise create a new one
if (botComment) {
github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: output
})
} else {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
}
Хоча тут кому-як, бо може більш інформативно буде мати історію планів.
Окей.
Тепер ми маємо GitHub Actions Workflow, який виконує перевірки Terraform, і постить результат Plan в коментарі до Pull Request.
Єдине, що ще треба на увазі – це ліміт на кількість символів в коментарі до Pull Request – 65535 – і деякі Plan можуть не влізти.
Останнім нам треба додати Workflow, який буде виконувати Terraform Apply, коли PR змержено.
Workflow з Terraform Apply
Створюємо файл terraform-deploy-on-pr-merge.yml:
name: "Terraform: Apply on push to master"
# set other jobs with the same 'group' in a queue
concurrency:
group: deploy-test
cancel-in-progress: false
on:
# run on PR merge, e.g. 'push' changes to the 'master'
push:
branches:
- master
# only if changes were in the 'terraform' directory
paths:
- terraform/**
permissions:
# create OpenID Connect (OIDC) ID token
id-token: write
# allow read repository's content by steps
contents: read
jobs:
deploy:
name: "Deploy: Terraform"
runs-on: ubuntu-latest
# to avoid using GitHub Runners time
timeout-minutes: 30
defaults:
run:
# run all steps in the 'terraform' directory
working-directory: ./terraform
steps:
# get repository's content
- name: "Setup: checkout"
uses: actions/checkout@v4
# setup 'env.AWS_*' variables to run Terraform
- name: "Setup: Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TF_AWS_ROLE }}
role-session-name: github-actions-terraform
role-duration-seconds: 900
aws-region: us-east-1
# use TFLint to check the code
- name: "Setup: TFLint"
uses: terraform-linters/setup-tflint@v3
with:
tflint_version: v0.48.0
- name: "Test: Terraform lint"
run: tflint -f compact
shell: bash
# use official Action
- name: "Setup: Terraform"
uses: hashicorp/setup-terraform@v3
# get modules, configure backend
- name: "Setup: Terraform Init"
id: init
run: terraform init -no-color
# verify whether a configuration is syntactically valid
- name: "Test: Terraform Validate"
id: validate
run: terraform validate -no-color
# create a Plan to use with the 'apply'
- name: "Deploy: Terraform Plan"
id: plan
run: terraform plan -no-color -out tf.plan
# deploy changes using Plan's file
- name: "Deploy: Terraform Apply"
id: apply
run: terraform apply -no-color tf.plan
Тут все майже те саме, що і попередньому workflow, тільки тригером буде push в master, а результат виконання terraform plan ми зберігаємо у файл tf.plan, і з нього ж потім виконуємо terraform apply.
Пушимо в репозиторій, мержимо – і маємо виконаний apply:
Добре. Виглядає начебто чудово?
Але є проблема.
What if? Outdated Terraform Plan
А проблема полягає в тому, що за той час, коли ми подивились результат terraform plan в коментарях до PR і до моменту, коли він буде змержений і виконаний – пройде якийсь час, за який в ресурсах можливі зміни – чи то хтось запустить deploy-джобу з іншого PR, чи хтось задеплоїть власні зміни зі своєї машини (у нас це поки що допускається).
І тоді той Plan, який ми бачили в результатах виконання workflow Terraform Test вже буде неактуальний.
Друге питання – що в terraform apply виконується не той Plan, який ми бачили в коментарях і який ми апрувнули, а новий, який генерується вже під час виконання деплою.
Тож що ми можемо зробити, щоб запобігти такому?
Terraform: “Saved plan is stale”
Як варіант – це зберігати результати виконання terraform plan в файл, і потім цей же файл передавати в terraform apply.
Тоді, якщо в Terraform State відбулися зміни, то файл з Plan, який ми передамо на apply, сфейлиться, див. Manual State Pull/Push.
Як можемо перевірити те, як воно працює (тобто фейлиться):
- деплоїмо нашу корзину
- додаємо тег
- робимо
terraform plan -out test.plan - додаємо ще один тег
- робимо
terraform apply - а потім ще раз
terraform apply, але вже з файлуtest.plan– симулюємо зміни, які відбулися між запуском наших workflow
Отже – маємо корзину:
resource "aws_s3_bucket" "example" {
bucket = "${var.project_name}-bucket-ololo"
tags = {
Name = "${var.project_name}-bucket"
}
}
Деплоїмо її з terraform apply:
$ terraform apply ... aws_s3_bucket.example: Creating... aws_s3_bucket.example: Creation complete after 3s [id=atlas-test-bucket-ololo] Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Додаємо тег:
resource "aws_s3_bucket" "example" {
bucket = "${var.project_name}-bucket-ololo"
tags = {
Name = "${var.project_name}-bucket"
NewTag = "NewTag"
}
}
Виконуємо terraform plan, результат зберігаємо у файл:
$ terraform plan -out=test.plan ... Plan: 0 to add, 1 to change, 0 to destroy. Saved the plan to: test.plan
Додаємо ще один тег:
resource "aws_s3_bucket" "example" {
bucket = "${var.project_name}-bucket-ololo"
tags = {
Name = "${var.project_name}-bucket"
NewTag = "NewTag"
NewTag2 = "NewTag2"
}
}
Деплоїмо без test.plan:
$ terraform apply ... aws_s3_bucket.example: Modifying... [id=atlas-test-bucket-ololo] aws_s3_bucket.example: Modifications complete after 3s [id=atlas-test-bucket-ololo] Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
А тепер пробуємо задеплоїти з файлу:
$ terraform apply test.plan Acquiring state lock. This may take a few moments... ╷ │ Error: Saved plan is stale │ │ The given plan file can no longer be applied because the state was changed by another operation after the plan was created.
Чудово – наш деплой обламався.
GitHub Actions та Artifacts: передача файлу з Terraform Plan
Тепер спробуємо реалізувати механізм передачі plan-файлу між workflow. Спочатку зробимо його збереження в artifact.
Note: тут ще треба враховувати питання Security, бо в plan можуть бути конфіденційні дані. З іншого боку, якщо хтось отримав доступ до вашого CI – то ви й так маєте проблеми.
Save Plan output as an Artifact
Тож що нам треба зробити:
- при створенні Pull Request виконуємо
terraform plan -out name.tfplan - зберігаємо файл
name.tfplanяк артефакт - при мержі Pull Request завантажуємо цей
name.tfplanна GitHub Runner - і виконуємо
terraform apply name.tfplan
Виглядає наче досить просто? Якби ж то…
Почнемо з вигрузки артефакту після тесту – тут все дійсно просто.
Задля того, щоб при виконанні terraform apply взяти артефакт саме з цього PR – додамо в ім’я файлу номер PR.
Оновлюємо файл terraform-test-on-pr.yml:
...
# create a Plan to see what will be changed
# save it to the file with a PR number
- name: "Test: Terraform Plan"
id: plan
run: terraform plan -no-color -out env-test-${{ github.event.pull_request.number }}.tfplan
# save as an artifact to this workflow run
- name: Upload Terraform Plan
uses: actions/upload-artifact@v4
with:
name: env-test-${{ github.event.pull_request.number }}.tfplan
path: "terraform/env-test-${{ github.event.pull_request.number }}.tfplan"
# throw an error if we can't find the Plan's file
if-no-files-found: error
# replace if an existing one is found
overwrite: true
...
(задаємо тут в path каталог terraform/, бо “upload-artifact action does not use the working-directory setting”, див. No files were found with the provided path: build. No artifacts will be uploaded.)
Пушимо, і перевіряємо джобу:
Use Terraform Plan’s Artifact for the Terraform Apply
Далі, нам треба цей файл використати в іншому workflow, де відбувається деплой – і тут маємо дві проблеми:
- офіційний actions/download-artifact не підтримує завантаження файлів з інших workflow
- наш workflow з
terraform applyзапускається при івентіpush, а неpull_request– і в контекстіgithubу нас вже немаgithub.event.pull_request.number
Першу проблеми ми можемо вирішити за допомогою іншого Action – dawidd6/action-download-artifact, якому можна передати ім’я файлу іншого workflow, в якому буде потрібний артефакт.
А другу проблему можемо вирішити за допомогою Action jwalton/gh-find-current-pr, який виконує запит до GitHub API, і повертає номер PR, з якого був зроблений Merge.
Отже, оновлюємо наш terraform-deploy-on-pr-merge.yml – додаємо permissions.actions: read, permissions.pull-requests: read та два нових step – прибираємо Terraform Plan, отримуємо PR number, та оновлюємо Terraform Apply – передаємо файл з планом:
...
permissions:
# create OpenID Connect (OIDC) ID token
id-token: write
# allow read repository's content by steps
contents: read
# get PR number
pull-requests: read
# allow download artifacts
actions: read
...
# get a PR number used to make the 'push' when merging
- name: "Misc: get PR number"
uses: jwalton/gh-find-current-pr@master
id: findpr
with:
state: all
# download artifact witjh the Terraform Plan file of the 'Terraform Test' workflow
- name: "Misc: Download Terraform Plan"
uses: dawidd6/action-download-artifact@v3
with:
github_token: ${{secrets.GITHUB_TOKEN}}
# the Workflow to look for the artifact
workflow: terraform-test-on-pr.yml
# PR number used to generate the artifact and trigger this workflow
pr: ${{ steps.findpr.outputs.pr }}
# artifact's name
name: env-test-${{ steps.findpr.outputs.pr }}.tfplan
path: terraform/
# ensure we have the file in the workflow
check_artifacts: true
- name: "Deploy: Terraform Apply"
id: apply
run: terraform apply -no-color env-test-${{ steps.findpr.outputs.pr }}.tfplan
Все пушимо, мержимо – і маємо виконаний Terraform Apply з використанням файлу з Terraform Plan попереднього workflow:
Також ще один важливий нюанс – завдяки однаковому значенню в concurrency.group в обох Workflow – наш деплой завжди буде чекати виконання Plan, що корисно, коли PR створюється і тут же мержиться:
... # set other jobs with the same 'group' in a queue concurrency: group: deploy-test cancel-in-progress: false ...
Рішення виглядає цілком робочим, і перевірки з кількома одночасними PR пройшло. Хоча мене трохи напрягає те, що тут маємо “too many moving parts”, ще й покладаємось на thirdparty GitHub Actions.
Використання GitHub Branch protection rule
Є ще один варіант, і можна або використати тільки його – або варіант, писаний вище + цей: це задати Branch protection rule, де буде вимога в бранчі, з якого робиться PR, мати всі зміни, які вже є в master.
Якщо використовувати тільки такий підхід – то прибираємо steps з uploads/download артефакту, і натомість використовуємо terraform plan -out file.tfplan та terraform apply file.tfplan в одній джобі, як робили до рішення з artifacts.
Хоча при такому підході ви все одно покладаєтесь на те, що Plan, який буде створено під час виконання джоби буде == тому плану, який ви дивились в коментарях, бо “запобіжник” з “Saved plan is stale” тут не спрацює.
Втім, налаштувати Branch protection rule все одно буде корисно:
Включаємо “Require status checks to pass before merging” та “Require branches to be up to date before merging“:
Тоді, якщо в master бранч був зроблений merge з іншого бранчу – GitHub не дозволить виконати PR Merge, поки ви не оновите свій бранч, а це викличе ще один запуск workflow з Terraform Test і генерацію нового Plan, який буде додано в коментарі:
Клікаємо Update branch – запускається нова перевірка, генерується новий артефакт з новим Plan в артефакті (якщо комбінуємо обидва рішення):
І тепер можемо мержити, і деплоїти:
Готово.
P.S. І в будь-якому разі завжди майте S3 Bucket Versioning, щоб мати бекап ваших state-файлів.















