GitHub Actions: деплой Dev/Prod оточень з Terraform

Автор |  21/09/2023

Тепер, як маємо готовий код для розгортання кластеру 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, і додаємо нову змінну:

Далі, подумаємо які степи в джобі нам треба виконати:

  1. git checkout
  2. логін в AWS
  3. terraform fmt -check для перевірки “красоти коду” (див. fmt)
  4. terraform init з Dev-оточенням – загрузити модулі і підключитись до state-бекенду
  5. 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, а в stepsAWS 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