У пості 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-файлів.