Github ActionsでTerraformのCI/CDを実装する

# はじめに
ソフトウェアシステムを作成する際に、インフラの構築の再現性を確保するためにIaCを行うケースがあります。
さらに、インフラ構築を自動化するためにこのIaCに対してCI/CDを行うこともよく行われるかと思います。
今回は以下のような条件でIaCのCI/CDを行う際に、どのようにしたかについて述べます。

環境は次の通りです。

- インフラはGoogle Cloud
- IaCにTerraformを用いる
- CI/CDは Github Actionsで実施する

またCI/CDでは次のようなことの実現を目指します。

- Terraformのvalidationを行う
- Terraformのlintを行う
- Terraformの単体テストを実施する
- 各種チェックが問題なければapplyしてインフラ構築を実行する


Terraformのフォルダ構成はスタイルガイドのような構成を想定します。
```
.
├── modules
│   ├── function
│   │   ├── main.tf      # contains aws_iam_role, aws_lambda_function
│   │   ├── outputs.tf
│   │   └── variables.tf
│   ├── queue
│   │   ├── main.tf      # contains aws_sqs_queue
│   │   ├── outputs.tf
│   │   └── variables.tf
│   └── vpc
│       ├── main.tf      # contains aws_vpc, aws_subnet
│       ├── outputs.tf
│       └── variables.tf
├── main.tf
├── outputs.tf
└── variables.tf
```

https://developer.hashicorp.com/terraform/language/style#module-repository-names

# ワークフロー構成
今回はGitHub Actionsのワークフローを以下のような構成にしています。
具体的な処理を記述したワークフロー`_ci_cd.yaml`と環境ごとの実行に対応するための`ci_cd_dev.yaml`から成ります。

```
.
├── .github
│   ├── workflows
│   │   ├── _ci_cd.yaml
│   │   └── ci_cd_dev.yaml
~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~
```

# `ci_cd_dev.yaml`

```yaml
name: ci_cd(dev)

on:
  pull_request:
    branches:
      - dev
  push:
    branches:
      - dev
  workflow_dispatch:

jobs:
  call_ci_cd:
    uses: ./.github/workflows/_ci_cd.yaml
    with:
      environment: github-gcp-dev
      env_name: dev
    secrets: inherit

```

## トリガー条件
このワークフローは特定の環境に対応して具体的な処理を起動するためのゲートとなるようなフローです。
`on: ~`で記述されるトリガー条件を見ると、「dev」ブランチに対するプルリクエストの場合と、「dev」ブランチに対するプッシュがあった場合です。

`pull_request:`におけるトリガーは正確には「dev」ブランチへのプルリクエスト作成時、プルリクエストが開かれた状態でソースブランチが更新されたとき、プルリクエストが再オープンされたときです。
`type:`によってその他のタイミングを指定することも可能です。

https://docs.github.com/ja/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request

また、`workflow_dispatch:`もトリガー条件となっています。これによりGitHubのweb画面上でワークフローを実行できるようになります。

## ジョブ
次に、このワークフローの`jobs`を確認します。
ジョブでは、`./.github/workflows/_ci_cd.yaml`つまり具体的な処理を行うフローを呼び出しており、その際に`environment`と`env_name`という値を引き渡しています。`environment`はGitHubリボリトリに対して作成できる環境の名前です。GitHubでは、リポジトリごとに複数の環境を作成することができ、その環境ごとに「Environment secrets」、や「Environment variables」というシークレット値や環境変数の一覧を作成する事ができます。

`environment`は各環境ごとに設定されたシークレット値や環境変数をワークフローで用いる際に、どの環境を参照すればよいのかを指定するために渡す必要があります。

`env_name`は環境の名前でどの環境か区別して命名する際に、参照するために渡す値です。

また、`secrets: inherit`の設定をすることで呼び出し先のワークフローでも呼び出し元で参照可能なシークレットを参照させる事ができるようになります。

https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idsecretsinherit

# `_ci_cd.yaml`

```yaml
name: ci_cd execution

on:
  workflow_call:
    inputs:
      environment:
        description: 'github environment name'
        required: true
        type: string
      env_name:
        description: 'environment name(dev/stg/prd)'
        required: true
        type: string
  workflow_dispatch:
    inputs:
      environment:
        description: 'github environment name'
        required: true
        type: string
      env_name:
        description: 'environment name(dev/stg/prd)'
        required: true
        type: string

jobs:
  ci_job:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    permissions:
      # workload identity連携によるToken発行には以下権限が必要
      id-token: write
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: auth-login-gcp
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up mise
        uses: jdx/mise-action@v2
        with:
          Install: true
          cache: true

      - name: Terraform Format
        run:  terraform fmt -check -recursive

      - name: Init TFLint
        run: tflint --init

      - name: Run TFLint
        run: tflint --recursive -c "$(pwd)/.tflint.hcl"

      - name: Terraform Init
        run:  find . -name "*.tf" -exec dirname {} \; | sort -u | xargs -I {} sh -c 'cd "{}" && terraform init'

      - name: Terraform Validation
        run:  find . -name "*.tf" -exec dirname {} \; | sort -u | xargs -I {} sh -c 'cd "{}" && terraform validate'

      - name: Terraform unit test
        run:  find . -name "*.tftest.hcl" -exec dirname {} \; | sort -u | xargs -I {} sh -c 'cd "{}" && terraform test'

      - name: Terraform Plan
        run:  |
          cd ${{inputs.env_name}}
          terraform plan

  cd_job:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    if : ${{ github.event_name == 'push'}}
    needs: ci_job
    permissions:
      # workload identity連携によるToken発行には以下権限が必要
      id-token: write
      contents: read
      # PR画面でterraform plan結果を投稿させるために以下権限が必要
      pull-requests: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: auth-login-gcp
        uses: google-github-actions/auth@v2
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up mise
        uses: jdx/mise-action@v2
        with:
          Install: true
          cache: true

      - name: Terraform Init
        run:  |
          cd ${{inputs.env_name}}
          terraform init

      - name: Terraform Plan
        run:  |
          cd ${{inputs.env_name}}
          terraform plan

      - name: Terraform Apply
        run:  |
          cd ${{inputs.env_name}}
          terraform apply -auto-approve

```

## トリガー条件
このワークフローは`workflow_call:`で他のワークフローで呼び出された場合と、`workflow_dispatch:`で画面上から呼び出すことで実行できます。この際には、`environment`と`env_name`の値が与えられている必要があります。

## ジョブ
ジョブは`ci_job`と`cd_job`の2️つからなっています

### `ci_job`

`ci_job`はTerraformのテストやチェックを行うCIのためのジョブです。`cd_job`と共通の準備として、リポジトリのチェックアウト、Google Cloudへのログイン、miseによるTerraformとTflint環境のセットアップを行っています。　
CIのジョブで行っていることは以下です。
- terraformのフォーマットチェック
- tflintの初期化
- tflintの実行
- terraform initの実行
- terraform validationの実行
- terraform testの実行
- terraform planの実行

terraformのフォーマットチェックでは、`-recursive`オプションを付けて再帰的にチェックを行っています。
tflintによるlintのチェックでも`-recursive`オプションで再帰的に実行ができます。その際に参照する設定ファイル`.tflint.hcl`が正しく読み込めるように指定する必要があります。


この記事で述べたように、`terraform init`、`terraform validate`、`terraform test`は再帰的に実行する方法がないため例のようにTerraform のフォルダ構成によっては再帰的なコマンドをこちらで作り実行する必要があります。

https://qiita.com/masa-asa/items/2ab5f67d51ac3d4b54ae

最後に、実際に作りたい環境のディレクトリで`terraform plan`を実行してエラーが無いかを確認しています。

# `cd_job`
このジョブは実行にあたり以下のような条件が指定されています。
CDはdevブランチにpushがあったときのみ動かしたい（例えば、プルリクが開かれた直後のCI成功後にはCDは動かしたくない）ので、このような条件と、CIのジョブが成功した場合のみ実行するような制限を設けています。
```yaml
    if : ${{ github.event_name == 'push'}}
    needs: ci_job
```

また、Terraformのスタイルガイドのようなフォルダ構成ではある環境に対してインフラを構築したい場合、その環境名のディレクトリで`terraform plan`、`terraform apply`をする必要があります。

そのために、このジョブではワークフローが事前に受け取った`env_name`の値を参照して該当する環境のディレクトリに移動してコマンドを実行するようにしています。

CIについては、前述のジョブで成功しているため、ここでは単純にinit、plan、applyのみを行っています。

# 所感
CIとCDそれぞれでジョブを分けて記載することで、利便性の高いワークフローになるかと思います。
また、実際の処理の部分と環境ごとのゲート部分のワークフローを分けることで、環境ごとの設定がやりやすくなっているかと感じます。（この方法だと１環境ごとにワークフローが１つ増えてしまう課題がありますが...）

詳細なトリガー条件については他にもっと良いやり方があると思うので、より良い方法をご存知の方はご教示くださればありがたいです！