Тепер, як маємо готовий код для розгортання кластеру AWS Elastic Kubernetes Service (див. Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints і наступні частини), прийшов час подумати про автоматизацію, тобто – про створення пайплайнів в CI/CD, які би виконували створення нових енвів для тестування фіч, або деплоїли апдейти на Dev/Prod оточення Kubernetes.
І тут знову поговоримо про менеджмент Dev/Prod оточень з Terraform.
В попередніх записах я перебирав варіанти з Terraform Workspaces, Git-бранчами та розділенням по директоріям (див. Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap та Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям), але поки писав код Terraform, то подумав про те, як зазвичай робиться з Helm-чартами: весь код чарту зберігається в корні репозиторію або окремій директорії, а значення для Dev/Prod передаються з різних файлів values.yaml.
То чому б не спробувати аналогічний підхід з Terraform?
В будь-якому разі те, що описано в цьому пості не треба сприймати як приклад “це робиться саме так”: тут я більше експерементую і пробую можливості GitHub Actions та Terraform. А в якому вигляді воно піде в продакшен – подивимось.
Проте вийшло доволі цікаво.
Зміст
Terraform Dev/Prod – Helm-like “flat” approach
Отже, конфігурація для Terraform може бути такою:
- аутентифікацію та атворизацію робимо локально зі змінною
AWS_PROFILE, в якому виконується AssumeRole, а в GitHub Actions – через OIDC і AssumeRole – таким чином зможемо мати різні параметри для AWS Provider для Dev та Prod оточень - весь код Terraform зберігається в корні
tfvarsдля Dev/Prod в окремих директоріяхenvs/devтаenvs/prod- загальні параметри
backendописуємо у файліbackend.hcl, а ключі і таблицю Дінамо передаємо через-backend-config
А флоу розробки та деплою – таким:
- бранчуємось від мастер-бранчу
- робимо зміни в коді, тестуємо локально або через GitHub Actions, передаючи потрібні параметри через
-var(ім’я енву, якісь версії і т.д.) - як закінчили розробку і тести – пушимо в репозиторій, і робимо Pull Request
- при створенні Pull Request можемо задати спеціиальну лейблу, і тоді GitHub Actions виконують деплой на feature-енв, тобто створюється тимчасова інфрастуктура в AWS
- Dev-оточення, щоб перевірити, що все працює
- після мержу Pull Request можемо додатково задеплоїти на Dev-оточення, щоб перевірити, що зміни між строю версією та новою працюють (такий собі staging env)
- якщо треба задеплоїти на Прод – створюємо Git Release
- вручну або автоматично тригерим GitHub Actions на деплой з цим релізом
- …
- profit!
А що там Terragrunt?
Звісно, для управління оточеннями і бекендами можна було б просто взяти Terragrunt, але я поки не впевнений в тому, яка модель управління інфрастуктурою з Terraform складеться на проекті: якщо цим будуть займатись виключно девопси – то, скоріш за все, візьмемо Terragrunt. Якщо діло зайде і девелоперам, і вони захочуть самі щось менеджити – то, можливо, для управління візьмемо якесь рішення на кштал Atlantis або Gaia.
Тому поки це все тільки починається – робимо все максимально просто і “ванільним” Terraform, а далі подивимось, що і як буде краще.
Terraform layout for multiple environments
Зараз структура файлів і каталогів виглядає так – в environments/dev весь код для Dev, в environments/prod – весь код для Prod (поки пусто):
$ tree terraform/
terraform/
└── environments
├── dev
│ ├── backend.tf
│ ├── configs
│ │ └── karpenter-provisioner.yaml.tmpl
│ ├── controllers.tf
│ ├── eks.tf
│ ├── iam.tf
│ ├── karpenter.tf
│ ├── main.tf
│ ├── outputs.tf
│ ├── providers.tf
│ ├── terraform.tfvars
│ ├── variables.tf
│ └── vpc.tf
└── prod
Однак так як ми тут не використвуємо модулі, то код дуже дублюється, і менеджити його буде важко і як то кажусь, “error prone”.
Можна було б створити модулі, але тоді були б модулі в модулях, бо весь EKS-стек створюється через модулі – VPC, EKS, Subnets, IAM, etc, і ускладнювати код, виносячи код в модулі поки що не хочеться, бо знов-таки – подивимось, як підуть процеси. Поки це все на самому початку – то маємо змогу досить швидко переробити так, як потім буде краще в залежності від того, яку модель і систему управління оточеннями оберемо.
Тож що нам треба зробити:
- перенести файли
.tfв корень директоріїterraform - оновити файл
providers.tf– видалити з ньогоassume_roleта прибрати--profileвargsпровайдерів Kubernetes, Helm та kubectl - створити файл
backend.hclдля common-конфігурації - у файлі
backend.tfлишити тількиbackend "s3" environmentsперейменуємо вenvs- всередені будуть каталоги
devтаprod, в яких будуть зберігатисьterraform.tfvarsдля кожного енва
- всередені будуть каталоги
Providers
providers.tf зараз виглядає так:
provider "aws" {
region = var.aws_region
assume_role {
role_arn = "arn:aws:iam::492***148:role/tf-admin"
}
default_tags {
tags = {
component = var.component
created-by = "terraform"
environment = var.environment
}
}
}
Прибираємо з нього assume_role:
provider "aws" {
region = var.aws_region
default_tags {
tags = {
component = var.component
created-by = "terraform"
environment = var.environment
}
}
}
В інші провайдерах – Kubernetes, Helm та Kubectl – в args прибираємо --profile:
...
provider "kubernetes" {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
}
}
...
Versions
Версії виносимо в окремий файл vesrions.tf:
terraform {
required_version = "~> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.14"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.23"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.11"
}
kubectl = {
source = "gavinbunney/kubectl"
version = "~> 1.14"
}
}
}
Переносимо файли, і тепер структура виглядає так:
$ tree . ├── backend.tf ├── configs │ └── karpenter-provisioner.yaml.tmpl ├── controllers.tf ├── eks.tf ├── iam.tf ├── karpenter.tf ├── main.tf ├── outputs.tf ├── providers.tf ├── variables.tf ├── envs │ ├── dev │ │ └── dev.tfvars │ └── prod ├── versions.tf └── vpc.tf
Backend
В корні створюємо файл backend.hcl:
bucket = "tf-state-backend-atlas-eks" region = "us-east-1" dynamodb_table = "tf-state-lock-atlas-eks" encrypt = true
У файлі backend.tf залишаємо тільки s3:
terraform {
backend "s3" {}
}
Окей – наче все готово? Давайте тестити.
Тестування з terraform init && plan для різних оточень
Маємо AWS Profile в ~/.aws/config:
[profile work] region = us-east-1 output = json [profile tf-assume] role_arn = arn:aws:iam::492***148:role/tf-admin source_profile = work
Перевіряємо існуючий ключ в S3 – бо вже маємо задеплоїний Dev:
$ aws s3 ls tf-state-backend-atlas-eks/dev/ 2023-09-14 15:49:15 450817 atlas-eks.tfstate
Задаємо змінну AWS_PROFILE, і пробуємо terraform init з backend-config:
$ export AWS_PROFILE=tf-admin $ terraform init -backend-config=backend.hcl -backend-config="key=dev/atlas-eks.tfstate" Initializing the backend... Successfully configured the backend "s3"! Terraform will automatically ... Terraform has been successfully initialized!
Наче ОК – модулі загрузились, існуючий стейт-файл знайшло, все гуд.
Пробуємо terraform plan – в коді нічого не мінялось, тож нічого не має змінитись і в AWS:
$ terraform plan -var-file=envs/dev/dev.tfvars
...
Terraform will perform the following actions:
# helm_release.karpenter will be updated in-place
~ resource "helm_release" "karpenter" {
id = "karpenter"
name = "karpenter"
~ repository_password = (sensitive value)
# (28 unchanged attributes hidden)
# (6 unchanged blocks hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
...
Найс! Змінить тільки пароль для AWS ECR, це ОК, бо там токен, що міняється.
Спробуємо зробити теж саме, але для Prod – копіюємо файл dev.tfvars як prod.tfvars в каталог envs/prod, міняємо в ньому данні – тільки VPC CIDR та environment:
project_name = "atlas-eks"
environment = "prod"
component = "devops"
eks_version = "1.27"
vpc_params = {
vpc_cidr = "10.2.0.0/16"
enable_nat_gateway = true
...
І пробуємо новий terraform init з -reconfigure, бо робимо це все локально. В backend-config передаємо новий ключ для стейт-файлу в S3:
$ terraform init -reconfigure -backend-config=backend.hcl -backend-config="key=prod/atlas-eks.tfstate" Initializing the backend... Successfully configured the backend "s3"! Terraform will automatically ... Terraform has been successfully initialized!
Перевіряємо з terraform plan:
$ terraform plan -var-file=envs/prod/prod.tfvars ... Plan: 109 to add, 0 to change, 0 to destroy. ...
Добре! Збирається створити купу нових ресурсів, бо Prod ще не робив, тобто все, як задумано.
Для feature-енвів все теж саме, тільки в -var передаємо ще environment і в backend-config задаємо новий ключ по імені енва:
$ terraform init -reconfigure -backend-config=backend.hcl -backend-config="key=feat111/atlas-eks.tfstate" -var="environment=feat111" ...
Для plan та apply використовуємо параметри з файлу dev.tfvars, бо фіча-енви в принципі більш-менш ~= dev-оточенню:
$ terraform plan -var-file=envs/dev/dev.tfvars -var="environment=feat111" ...
Якщо потрібно задати наприклад нову VPC, яка у нас object, в якому є string vpc_cidr:
...
variable "vpc_params" {
description = "AWS VPC for the EKS cluster parameters. Note, that this VPC will be environment-wide used, e.g. all Dev/Prod AWS resources must be placed here"
type = object({
vpc_cidr = string
enable_nat_gateway = bool
one_nat_gateway_per_az = bool
single_nat_gateway = bool
enable_vpn_gateway = bool
enable_flow_log = bool
})
}
...
Тож її передаємо як -var='vpc_params={vpc_cidr="10.3.0.0/16"}'. Але в такому випадку потрібно передавати всі значення об’єкту vpc_params, тобто:
$ terraform plan -var-file=envs/dev/dev.tfvars -var="environment=feat111" \
> -var='vpc_params={vpc_cidr="10.3.0.0/16",enable_nat_gateway=true,one_nat_gateway_per_az=true,single_nat_gateway=false,enable_vpn_gateway=false,enable_flow_log=false}'
Окей – з цим розібрались, можна переходити до GitHub Actions.
Авторизація і аутентифікація – OIDC та IAM
Для доступу з GitHub Actions до AWS ми використовуємо OpenID Connect – тут GitHub виступає в ролі Identity Provider (IDP), а AWS – Service Prodider (SP), тобто на GitHub ми проходимо аутентифікацю, після чого GitHub “передає” нас до AWS, кажучи, що “це дійсно Вася Пупкін”, а вже AWS виконує авторизацію – тобто, AWS перевіряє, чи може цей Вася Пупкін створювати нові ресурси.
Для авторизації в AWS ми в Terraform виконуємо IAM Role Assume, і вже від імені цієї ролі і IAM Policy, які підключені до неї, ми проходимо авторизацію.
Щоб мати можливість виконувати AsuumeRole цієї ролі використовуючи GitHub Identity Provider – треба змінити її Trusted Policy, бо зараз вона дозволяє AssumeRole тільки для юзерів з AWS акаунту:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::492***148:root"
},
"Action": "sts:AssumeRole"
}
]
}
Документація – Configuring the role and trust policy.
Додаємо token.actions.githubusercontent.com IDP з нашого AWS АІМ:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AccountAllow",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::492***148:root"
},
"Action": "sts:AssumeRole"
},
{
"Sid": "GitHubAllow",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::492***148:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
]
}
GitHub Actions: recall the moment!
Колись писав про них – Github: обзор Github Actions и деплой с ArgoCD, але то було давно, та й сам я Actions майже не користувався, то треба трохи згадати що до чього.
Див. також Understanding GitHub Actions.
Отже, що маємо:
- є
events, як тригерятьjobs - в
jobsмаємоstepsз одним чи кількомаactions actionsвиконують конкретні дії, іactionsоднієїjobмають запускатись на одному GitHub Actions Runner
При цьому в Actions можемо використовувати готові “бібліотеки” з Github Actions Marketplace.
GitHub Environments
GitHub дозволяє налаштувати декілька оточень для роботи, і в кожному мати свій набір Variables та Secrets. Крім того, для них можна налаштувати правила, згідно з якими має відбуватись деплой. наприклад – дозволити деплой на Prod тільки з master-гілки.
Див. Using environments for deployment.
Тож що ми можемо зробити:
- Dev env зі своїми змінними
- Prod env зі свіоми змінними
- і створювати feature-енви динамічно під час деплою
GitHub Actions Reusable Workflow та Composite Actions
З Reusable Workflow ми можемо використати вже описаний Workflow в іншому workflow, а з Composite Actions – створити власну Action, яку потім включимо в steps наших workflow-файлів.
Детальніше про різницю між ними можна почитати тут – GitHub: Composite Actions vs Reusable Workflows [Updated 2023] і чудовий пост Github Not-So-Reusable Actions, але якщо кратко, то:
- при створенні Workflow ви групуєте декілька Jobs в єдиний файл і використовувати тільки в Jobs, тоді як в Actions будуть тільки steps, які групуються в єдиний Action, який потім можна використовувати тільки як step
- Workflow не можуть викликати інші Workflow, тоді як в Actions ви можете викликати інші Actions
Крім того, замість Reusable Workflow можна використати Running a workflow based on the conclusion of another workflow – тобто тригерити запуск воркфлоу після завершення роботи іншого воркфлоу.
Створення тестового workflow для Terraform validate && lint
Спочатку давайте зробимо якийсь мінімальний workflow, щоб подивитись як воно все взагалі працює.
Див. Quickstart for GitHub Actions, документація по сінтаксісу – Workflow syntax for GitHub Actions, і по permissions нашої джоби – Assigning permissions to jobs.
Для Terraform будемо використовувати setup-terraform Action.
Для логіну в AWS – configure-aws-credentials Action.
В репозиторії створюємо директорію:
$ mkdir -p .github/workflows
І в ній створюємо файл воркфлоу – testing-terraform.yaml.
Так як код Terraform лежить в каталозі terraform, то в workflow додамо defaults.run.working-directory та умову on.push.paths, тобто трігерити білд тільки якщо зміни відбулися в каталозі terraform, див. paths.
Описуємо флоу:
name: Test Terraform changes
defaults:
run:
working-directory: terraform
on:
workflow_dispatch:
push:
paths:
- terraform/**
env:
ENVIRONMENT : "dev"
AWS_REGION : "us-east-1"
permissions:
id-token: write
contents: read
Тут:
- в
defaultsзадаємо з якої директорії будуть виконуватись всі степи - в
on– умови, по пушу і тільки для директоріїterraformі додатковоworkflow_dispatchдля можливості ручного запуску - в
env– значення змінних оточення - в
permissions– права джоби на створення токену.
В джобах ми будемо виконувати AssumeRole, давайте її занесемо в змінні оточення репозиторію.
Переходимо в Setting > Secrets and variables > Actions > Variables, і додаємо нову змінну:
Далі, подумаємо які степи в джобі нам треба виконати:
git checkout- логін в AWS
terraform fmt -checkдля перевірки “красоти коду” (див.fmt)terraform initз Dev-оточенням – загрузити модулі і підключитись до state-бекендуterraform validateдля перевірки синтаксису коду (див.validate)
Додаємо джобу з цими степами, тепер файл повністю буде таким:
name: Test Terraform changes
defaults:
run:
working-directory: terraform
on:
workflow_dispatch:
push:
paths:
- terraform/**
env:
ENVIRONMENT : "dev"
AWS_REGION : "us-east-1"
permissions:
id-token: write
contents: read
jobs:
test-terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- name: Terraform fmt
run: terraform fmt -check
continue-on-error: true
- name: "Setup: Configure AWS credentials"
# this step adds `env.AWS_*` variables
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ vars.TF_AWS_ROLE }}
role-session-name: github-actions-terraform
role-duration-seconds: 7200
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init -backend-config=backend.hcl -backend-config="key=${{ env.ENVIRONMENT }}/atlas-eks.tfstate"
- name: Terraform Validate
run: terraform validate
Пушимо зміни в репозиторій, запускаємо вручну, бо змін в коді Terraform не було:
І маємо наш перший ран:
Note: аби з’явилась кнопка для ручного запуску (workflow_dispatch) – зміни в workflow-файлі мають бути змержені з дефолтним бранчем репозиторія
GitHub Actions TFLint step
Тепер давайте додамо степ з Terraform linter – TFLint.
Спочатку глянемо, як воно працює локально:
$ yay -S tflint
$ tflint
3 issue(s) found:
Warning: module "ebs_csi_irsa_role" should specify a version (terraform_module_version)
on iam.tf line 1:
1: module "ebs_csi_irsa_role" {
Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.1/docs/rules/terraform_module_version.md
Warning: module "karpenter" should specify a version (terraform_module_version)
on karpenter.tf line 1:
1: module "karpenter" {
Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.1/docs/rules/terraform_module_version.md
Слушні зауваження – забув додати версії модулів, треба пофіксити.
Далі, додамо його на нашого воркфлоу – використаємо marketplace/actions/setup-tflint, версію беремо на сторінці релізів TFLint:
...
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v3
with:
tflint_version: v0.48.0
- name: Terraform lint
run: tflint -f compact
Запускаємо, і маємо сфейлений білд:
Добре.
Фіксимо проблеми, і йдемо далі до деплоїв.
Планування деплоїв з GitHub Actions
Тепер можна подумати про те, як нам побудувати workflow.
Які ми маємо дії в GitHub-репозиторії, і які дії з Terraform нам можуть знадобитись?
В GitHub:
- Push в будь-який бранч
- створення Pull Request на merge в master
- закриття Pull Request на merge в master
З Terraform:
- тестування:
fmt,validate,lint - деплой:
planтаapply - дестрой:
planтаdestroy
І як ми можемо це все скомбінувати в різні workflow?
- при Push – виконувати тест
- при створенні PR в мастер з лейблою “deploy” – деплоїти feature-енв (тест + деплой)
- при закритті PR – видаляти feature-енв (дестрой)
- вручну мати можливість:
- задеплоїти на Dev будь-який бранч або реліз (тест + деплой)
- задеплоїти на Prod реліз з мастер-бранчу (тест + деплой)
Тобто виглядає, як п’ять воркфлоу, при чому майже в усіх маємо дії, які повторюються – логін, init та test, тож їх має сенс винести в окремі “функції” – використати Reusable Workflow або Composite Actions.
AWS Login та init можемо об’єднати, і додатково створити “функцію” test. Крім того – додати “функції” “deploy” та “destroy”, і потім “модульно” їх використовувати у наших workflow:
- terraform-init:
- AWS Login
- terraform init
- test:
-
terraform validate
-
terraform fmt
-
tflint
-
- deploy:
- terraform plan
- terraform apply
- destroy:
- terraform plan
- terraform destroy
Тож якщо планувати з п’ятьма окремими воркфлоу з використанням GitHub Environments та “функціями”, то це будуть:
- тест-он-пуш:
- запускається на push event
- запускається з environment Dev, де маємо змінні для Dev-оточення
- викликає “функції” terraform-init та test
- деплой-дев:
- запускається вручну
- запускається з environment Dev, де маємо змінні для Dev-оточення
- викликає “функції” terraform-init, test та deploy
- деплой-прод:
- запускається вручну
- запускається з environment Prod, де маємо змінні для Prod-оточення та protection rules
- викликає “функції” terraform-init, test та deploy
- деплой-фіча-енв:
- запускається при створенні ПР з лейблою “деплой”
- запускається з динамічним оточенням (ім’я генерується з Pull Request ID і створюється під час деплою)
- викликає “функції” terraform-init, test та deploy
- дестрой-фіча-енв:
- запускається при закритті ПР з лейблою “деплой”
- запускається з динамічним оточенням (ім’я генерується з Pull Request ID і під час деплою оточення вибирається з існуючих)
- викликає “функції” terraform-init, test та destroy
Як саме робити ці “функції” – з Reusable Workflow чи Composite Actions? В прицнипі, в данному випадку мабуть різниці великої нема, бо будемо мати один рівень вкладеності, але тут є нюанс з GitHub Runners: якщо в Workflow робити задачі “terraform-init”, “test” та “deploy” в різних jobs (а Reusable Workflow мають бути тільки в jobs, більш того – ці jobs не можуть мати інших steps) – то вони скоріш за все будуть запускатись на різних Runners, і в такому випадку для виконання terraform plan && apply && destroy нам доведеться кілька раз виконувати step з AWS Login та terraform init.
Тому виглядає більш правильним запускати всі задачі в рамках однієї job, тобто для створення наших “функцій” використати Composite Actions.
Єдине, що в цьому пості я все ж не буду описувати деплой feature-енвів, бо по-перше – пост і так вже досить довгий і містить багато нвоої (принаймні для мене) інформації, по-друге – створення додаткових тестових кластерів AWS EKS буде скоріш виключенням, і буде робитись мною, а я поки що це можу зробити і без додаткової автоматизації. Втім, скоріш за все опишу створення динамічних оточень, коли буду робити GitHub Actions Workflow для деплоїв Helm-чартів.
Окей – давайте пробувати деплоїти Dev та Prod.
Підготовка деплоїв
Створення Actions secrets and variables
У нас буде декілька змінних, які будуть однакові для всіх оточень – AWS_REGION та TF_AWS_PROFILE, тож їх задамо на рівні всього репозиторію.
Переходимо в Settings – Actions secrets and variables > Variables, і додаємо Repository variables:
Створення GitHub Environments
Додаємо два оточення – Dev та Prod:
Аналогічно для Prod.
Створення test-on-push Workflow
Створення Composite Action “terraform-init”
В директорії .github додаємо каталог actions, в якому створюємо каталог terraform-init з файлом action.yaml, в якому описуємо сам Action.
Див. Creating a composite action.
В inputs приймаємо три параметри, які потім передамо з Workflow:
name: "AWS Login and Terraform Init"
description: "Combining AWS Login && terraform init actions into one"
inputs:
environment:
type: string
required: true
region:
type: string
required: true
iam-role:
type: string
required: true
runs:
using: "composite"
steps:
- uses: hashicorp/setup-terraform@v2
- name: "Setup: Configure AWS credentials"
# this step adds `env.AWS_*` variables
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ inputs.iam-role }}
role-session-name: github-actions-terraform
role-duration-seconds: 7200
aws-region: ${{ inputs.region }}
- name: Terraform Init
run: terraform init -backend-config=backend.hcl -backend-config="key=${{ inputs.environment }}/atlas-eks.tfstate"
shell: bash
working-directory: terraform
Тут є нюанс з working-directory, який в Composite Action треба задавати окремо для кожного step, бо сам Action в workflow запускається в корні репозиторія, а використати defaults, як це можна зробити в workflow не можна, хоча feature request є давно.
Створення Composite Action “test”
В директорії actions створюємо ще один каталог – terraform-test з файлом action.yaml, в якому описуємо другий Action:
name: "Test and verify Terraform code"
description: "Combining all test actions into one"
inputs:
environment:
type: string
required: true
region:
type: string
required: true
iam-role:
type: string
required: true
runs:
using: "composite"
steps:
- uses: hashicorp/setup-terraform@v2
- name: Terraform Validate
run: terraform validate
shell: bash
working-directory: terraform
- name: Terraform fmt
run: terraform fmt -check
shell: bash
working-directory: terraform
continue-on-error: false
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v3
with:
tflint_version: v0.48.0
- name: Terraform lint
run: tflint -f compact
shell: bash
working-directory: terraform
Створення workflow
Далі, в директорії .github/workflows створюємо файл test-on-push.yaml з новим флоу:
name: Test Terraform code
on:
workflow_dispatch:
push:
paths:
- terraform/**
permissions:
id-token: write
contents: read
jobs:
test-terraform:
environment: 'dev'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 'Setup: AWS Login and Terraform init'
uses: ./.github/actions/terraform-init
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
- name: 'Test: check and validate'
uses: ./.github/actions/terraform-test
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
В job: test-terraform задаємо використання environment: 'dev' і його змінних (у нас там вона одна – ENVIRONMENT=dev, а в steps “AWS Login and Terraform init” і “Test and validate” викликаємо наші файли з Actions, які через with передаємо параметри зі значеннями із змінних.
Пушимо зміни в репозиторій, і маємо наш білд:
Можемо переходити до деплою.
Створення deploy-dev Workflow
Отже, що ми хочемо?
- деплоїти на Дев з бранчу або тегу
- деплоїти на Прод вручну з релізу
Давайте почнемо з Дев-деплою.
Створення Composite Action “deploy”
Створюємо ще один каталог для нового Action – actions/terraform-apply, в ньому у файлі action.yaml описуємо сам Action:
name: "Plan and Apply Terraform code"
description: "Combining plan && apply actions into one"
inputs:
environment:
type: string
required: true
region:
type: string
required: true
iam-role:
type: string
required: true
runs:
using: "composite"
steps:
- uses: hashicorp/setup-terraform@v2
- name: Terraform plan
run: terraform plan -var-file=envs/${{ inputs.environment }}/${{ inputs.environment }}.tfvars
shell: bash
working-directory: terraform
- name: Terraform apply
run: terraform apply -var-file=envs/${{ inputs.environment }}/${{ inputs.environment }}.tfvars -auto-approve
shell: bash
working-directory: terraform
Створення workflow
Додаємо новий файл workflow – deploy-dev.yaml:
name: Deploy Dev environment
concurrency:
group: deploy-${{ vars.ENVIRONMENT }}
cancel-in-progress: false
on:
workflow_dispatch:
push:
paths:
- terraform/**
permissions:
id-token: write
contents: read
jobs:
deploy-to-dev:
environment: dev
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 'Setup: AWS Login and Terraform init'
uses: ./.github/actions/terraform-init
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
- name: 'Test: check and validate'
uses: ./.github/actions/terraform-test
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
- name: 'Deploy: to ${{ vars.ENVIRONMENT }}'
uses: ./.github/actions/terraform-apply
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
В умовах on.workflow_dispatch можна додати ще inputs, див. on.workflow_dispatch.inputs.
Усі умови, якими можна тригерити воркфлоу – див. у Events that trigger workflows.
В concurrency задаємо умову “ставити в чергу, а не виконувати паралельно, якщо запущено іншу job, яка використовує такий же concurrency.group“, див. Using concurrency.
Тобто, якщо ми робимо перевірку по group: deploy-${{ vars.ENVIRONMENT }} – то якщо після запуску Dev запустити деплой на Prod – то він буде очікувати, поки не завершиться деплой на Dev:
Пушимо, мержимо в master, щоб з’вилась кнопка “Run workflow“, і перевіряємо:
І після деплою:
Створення deploy-prod Workflow та захист від випадкового запуску
Composite Action ми вже маємо, тож створювати її не треба.
В принципі, тут маємо все аналогчно до деплою на Dev – concurrency, ручний запуск, jobs, едине, що тут має бути інша умова запуску – по тегам замість бранчів і тегів.
Тобто на Дев можемо деплоїти будь-який бранч або тег, а на Прод – тільки тег.
Взагалі я тут намагаюсь уникнути автоматичного деплою, бо це все ж інфрастуктура і все ще в процессі роботи напильником, хоча якщо б у нас був GitHub Enterprise – то можна було б використати Deployment protection rules і Required reviewers в них, а в умові on для запуску воркфлоу деплою на Прод використати створення релізів:
on:
release:
types: [published]
Тобто – деплоїти на Prod тільки коли створено новий GitHub Release, і тільки після того, як, наприклад, деплой апрувнуто кимось з DevOps-тіми.
Ще один варіант забезпечення безпеки Prod-оточення – це дозволяти мерж в master тільки від Code Owners в Branch protection rules:
А Code Owners описати у файлі CODEOWNERS, і дозволити деплой на Prod тільки з мастер-гілки:
Але я хочу тут використати флоу з деплояіми тільки з релізів або тегів, щоб мати щось на кшталт версіонювання.
Тож єдиний варіант – це використати умову if в самій job, див. Using conditions to control job execution:
...
jobs:
deploy-to-prod:
if: github.ref_type == 'tag'
environment: prod
...
Ну і можна комбінувати умови, наприклад – додатково через github.actor або github.triggering_actor перевіряти хто саме запускає деплой, і дозволити тільки певним юзерам. Див. всі типи в github context.
Як додатковий захист від випадкового запуску – можна додати input, до треба явно вказати “yes”:
...
on:
workflow_dispatch:
inputs:
confirm-deploy:
type: boolean
required: true
description: "Set to confirm deploy to the PRODUCTION environment"
...
А потім перевірити в if обидві умови – github.ref_type та github.event.inputs.confirm-deploy.
Тож поки вокрфлоу для Проду буде таким:
name: Deploy Prod environment
concurrency:
group: deploy-${{ vars.ENVIRONMENT }}
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
confirm-deploy:
type: boolean
required: true
description: "Set to confirm deploy to the PRODUCTION environment"
permissions:
id-token: write
contents: read
jobs:
deploy-to-prod:
if: ${{ github.ref_type == 'tag' && github.event.inputs.confirm-deploy == 'true' }}
environment: prod
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 'Setup: AWS Login and Terraform init'
uses: ./.github/actions/terraform-init
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
- name: 'Test: check and validate'
uses: ./.github/actions/terraform-test
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
- name: 'Deploy: to ${{ vars.ENVIRONMENT }}'
uses: ./.github/actions/terraform-apply
with:
environment: ${{ vars.ENVIRONMENT }}
region: ${{ vars.AWS_REGION }}
iam-role: ${{ vars.TF_AWS_ROLE }}
І при деплої – треба по-перше вибрати тег, а не бранч, по-друге – поставити відмітку “Confirm”:
А вже по ходу діла подивимось, як все буде складатись.
Ну і поки що на цьому все.
В цілому – в GitHub Actions зробили дуже багато всього, і коли починав робити ці деплої – прям не очікував, що буде стільки цікавих можливостей.
Так що – далі буде ще.
Пару моментів на додачу, доробити потім:
- Action
setup-terraformприймає інпутterraform_version, але не вміє це робити з versions-файлу; поки варіант робити через міні-костиль з додатковимstep, див. цей коммент - в
jobsмає сенс додати параметрtimeout-minutes, щоб джоби при проблемах не зависали на довгий час, і не вичерпували Runners time















