GitHub ActionsでTerraformのCICDパイプライン構築！

## 記事の内容
本記事では、GitHub Actions を使った Terraform のCI/CD パイプライン構築について説明します。
GCP環境を利用します。

## GitHub Actions 導入後のTerraformアクションのフロー
1. main ブランチへのPR作成
1. ファイル変更の検知（ファイルの変更のある環境のみにフィルタリング）
1. ファイル変更を検知した環境にて、``terraform plan``を実行
1. mainにマージをトリガーに、``terraform apply``を実行 

## ディレクトリ構造
```
repo/
├── modules/
│   └── ...
├── environments/
│   └── develop/
│   │    ├── main.tf
│   │    ├── values.tf
│   │    └── backend.tf
│   └── staging/
│        ├── main.tf
│        ├── values.tf
│        └── backend.tf
└── .github/
│   └── workflows/
│        └── terraform.yml
└── .gitignore     
```

## 構築手順
1. GCSバケットを手動作成（Terraformのstate用）
1. TerraformでWorkload Identityリソース一式をapply
1. GitHub Actionsの``.github/workflows/terraform.yml``を作成

1. develop環境でVPC作成PRを作成 → develop環境でplanが自動実行される
1. PRをmainにマージし、applyを実行

## 実際に構築
### 1. GCSバケットを手動作成（Terraformのstate用）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4000068/51d0a456-0efa-4cec-9f7e-caefaec606e4.png)

### 2. TerraformでWorkload Identityリソース一式をapply

状態ファイル保存先を先ほど作成したGCSバケットに``backend``設定の上、下記リソースを作成します。
| リソース名 | 説明 |
|------------|------|
| `google_service_account` | GitHub Actions から使うTerraform用サービスアカウント |
| `google_project_iam_member` | 上記SAに `roles/editor` の権限を付与 |
| `google_iam_workload_identity_pool` | GitHub OIDC用のIDプール |
| `google_iam_workload_identity_pool_provider` | GitHub発行のトークンを受け入れるOIDCプロバイダ |
| `google_service_account_iam_member` | SAとWorkload Identity Federationの紐付け（`roles/iam.workloadIdentityUser`） |

以下、上記のリソースをまとめたファイル内容です。
```
// main.tf
resource "google_project_iam_member" "sa_roles" {
  project = var.project_id
  role    = "roles/editor"
  member  = "serviceAccount:${google_service_account.terraform.email}"
}

resource "google_iam_workload_identity_pool" "github_pool" {
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Actions Pool"
  description               = "OIDC identity pool for GitHub Actions"
}

resource "google_iam_workload_identity_pool_provider" "github_provider" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "github"
  display_name                       = "GitHub OIDC Provider"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }

  attribute_mapping = {
    "google.subject"             = "assertion.sub"
    "attribute.actor"            = "assertion.actor"
    "attribute.repository"       = "assertion.repository"
  }

  attribute_condition = "assertion.repository == '${local.github_repo}'"
}

resource "google_service_account" "terraform" {
  account_id   = "terraform-cicd"
  display_name = "Terraform GitHub Actions"
}

resource "google_service_account_iam_member" "wif_binding" {
  service_account_id = google_service_account.terraform.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_pool.name}/attribute.repository/${local.github_repo}"
}

// values.tf
locals {
  github_repo    = "<org/repo>"
  github_repo    = "qiita-dev-458806"
}

// backend.tf
terraform {
  backend "gcs" {
    bucket = "terraform-state-qiita-dev"
    prefix = "tfstate"
  }
}
```

### 3. GitHub Actionsの``.github/workflows/terraform.yml``を作成
```sh:.github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches:
      - main 
    paths:
      - 'environments/**' #environmentsディレクトリ配下の変更時のみをトリガー
  pull_request:
    paths:
      - 'environments/**' #environmentsディレクトリ配下の変更時のみをトリガー

permissions:
  contents: read #リポジトリの読取りに必要な権限
  pull-requests: write #PRコメントに必要な権限
  id-token: write #GCPログインに必要な権限

env:
  GCP_PROJECT_ID_DEV: qiita-dev-458806 #プロジェクトID
  GCP_PROJECT_NUMBER_DEV: 413242293316 #プロジェクト番号（gcloud projects describe <プロジェクトID> --format="value(projectNumber)" コマンドで確認可能）
  tf_actions_working_dir_dev: environments/develop #developディレクトリ
  GCP_PROJECT_ID_STG: <staging環境のプロジェクトID>
  GCP_PROJECT_NUMBER_STG: <staging環境のプロジェクト番号>
  tf_actions_working_dir_stg: environments/staging
  
jobs:
  detect-changes:
    name: Detect changed environments #環境ごとのファイル変更の検知
    runs-on: ubuntu-latest
    outputs:  #各環境のファイルが変更されたかどうかのoutput。trueかfalseが代入される。
      develop: ${{ steps.changes.outputs.develop }}
      staging: ${{ steps.changes.outputs.staging }}
    steps:
      # https://github.com/actions/checkout/tree/v3/
      - uses: actions/checkout@v3 #GitHubのコードをrunnerにクローン。本stepであるファイル変更チェックに必要。

      # https://github.com/dorny/paths-filter/tree/v2/
      - uses: dorny/paths-filter@v2 
        id: changes
        with:
          filters: |
            develop:
              - 'environments/develop/**'
            staging:
              - 'environments/staging/**'

  terraform-develop: #develop環境のファイルで変更を検知した場合にこのjobを実行
    needs: detect-changes #detects-changes jobの後にこのjobを実行することを指定
    if: needs.detect-changes.outputs.develop == 'true'
    runs-on: ubuntu-latest
    name: Terraform (develop)
    defaults:
      run:
        working-directory: ${{ env.tf_actions_working_dir_dev }} #environments/develop配下で本jobを実行することを指定
    steps:
      # https://github.com/actions/checkout/tree/v3/
      - uses: actions/checkout@v3  # このリポジトリの Terraform コードを runner にチェックアウト

      # https://github.com/hashicorp/setup-terraform/tree/v2/
      - uses: hashicorp/setup-terraform@v2 # 指定バージョンの Terraform をインストールして使えるようにする
        with:
          terraform_version: 1.6.6

      - name: Authenticate to Google Cloud via Workload Identity Federation
      # https://github.com/google-github-actions/auth/tree/v1/
        uses: google-github-actions/auth@v1 #OIDC認証
        with:
          workload_identity_provider: "projects/${{ env.GCP_PROJECT_NUMBER_DEV }}/locations/global/workloadIdentityPools/github-pool/providers/github" #このWorkload Identity Providerを作成したGCPプロジェクトの「プロジェクト番号」を指定
          service_account: "terraform-cicd@${{ env.GCP_PROJECT_ID_DEV }}.iam.gserviceaccount.com" #作成したSA

      - name: Terraform fmt
        id: fmt
        run: terraform fmt -check
        continue-on-error: true

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan -no-color | tee plan.txt
          echo "stdout<<EOF" >> $GITHUB_OUTPUT
          cat plan.txt >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
        continue-on-error: true
        
      - uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"

        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `### Project: **${process.env.GCP_PROJECT_ID_DEV}** 

            #### 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 }}\`, Working Directory: \`${{ env.tf_actions_working_dir_dev }}\`, Workflow: \`${{ github.workflow }}\`*`;
      
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply --auto-approve


  terraform-staging: #staging環境のファイルで変更を検知した場合にこのjobを実行
    needs: detect-changes #detects-changes jobの後にこのjobを実行することを指定
    if: needs.detect-changes.outputs.staging == 'true'
    runs-on: ubuntu-latest
    name: Terraform (staging)
    （下記develop環境と同様に設定）
```
参考：
- [ワークフロー構文](https://docs.github.com/ja/actions/writing-workflows/workflow-syntax-for-github-actions#example-including-branches)
- [チェックアウト](https://github.com/actions/checkout/tree/v3)
- [認証](https://github.com/google-github-actions/auth/tree/v1/)
- [パスフィルター](https://github.com/dorny/paths-filter/tree/v2/)
- [セットアップ-Terraform](https://github.com/hashicorp/setup-terraform/tree/v2/)

#### トリガー条件 ####
```
on:
  push:
    branches:
      - main 
    paths:
      - 'environments/**' #environmentsディレクトリ配下の変更時のみをトリガー
  pull_request:
    paths:
      - 'environments/**' #environmentsディレクトリ配下の変更時のみをトリガー
```
ワークフローのトリガーを「mainブランチにpush時」と「PR作成時」に設定します。

#### runnnerにダウンロード ####
```
    steps:
      # https://github.com/actions/checkout/tree/v3/
      - uses: actions/checkout@v3 #GitHubのコードをrunnerにクローン。本stepであるファイル変更チェックに必要。
```
``actions/checkout`` アクションを使用して、リポジトリのコードをランナーにチェックアウトします。



#### 変更された環境ディレクトリを検知する ####
```
      # https://github.com/dorny/paths-filter/tree/v2/
      - uses: dorny/paths-filter@v2 
        id: changes
        with:
          filters: |
            develop:
              - 'environments/develop/**'
            staging:
              - 'environments/staging/**'
```
``dorny/paths-filter`` アクションを使用して、develop や staging ディレクトリ配下にファイル変更があったかどうかを検知します。それぞれ true / false を output として返します。これにより、対象環境に変更があった場合のみ、対応する Terraform ジョブを実行できます。

#### develop環境の変更があった場合のみTerraformジョブを実行 ####
```
  terraform-develop: #develop環境のファイルで変更を検知した場合にこのjobを実行
    needs: detect-changes #detects-changes jobの後にこのjobを実行することを指定
    if: needs.detect-changes.outputs.develop == 'true'
```
``needs`` × ``if`` を組み合わせて、``environments/develop`` に変更があった場合のみ、このジョブ（``terraform plan``/``apply``）を実行するように定義します。

#### 実行ディレクトリの設定 ####
```
    defaults:
      run:
        working-directory: ${{ env.tf_actions_working_dir_dev }} #environments/develop配下で本jobを実行することを指定
```
``defaults.run.working-directory`` を指定することで、このジョブ内のすべての ``run:`` ステップは ``environments/develop`` ディレクトリ内で実行されます。毎回 ``cd environments/develop`` などと書く必要がなくなり、YAMLがシンプルになります。

#### Terraformの準備とGCPへの認証 ####
```
    steps:
      # https://github.com/actions/checkout/tree/v3/
      - uses: actions/checkout@v3  # このリポジトリの Terraform コードを runner にチェックアウト

      # https://github.com/hashicorp/setup-terraform/tree/v2/
      - uses: hashicorp/setup-terraform@v2 # 指定バージョンの Terraform をインストールして使えるようにする
        with:
          terraform_version: 1.6.6

      - name: Authenticate to Google Cloud via Workload Identity Federation
      # https://github.com/google-github-actions/auth/tree/v1/
        uses: google-github-actions/auth@v1 #OIDC認証
        with:
          workload_identity_provider: "projects/${{ env.GCP_PROJECT_NUMBER_DEV }}/locations/global/workloadIdentityPools/github-pool/providers/github" #このWorkload Identity Providerを作成したGCPプロジェクトの「プロジェクト番号」を指定
          service_account: "terraform-cicd@${{ env.GCP_PROJECT_ID_DEV }}.iam.gserviceaccount.com" #作成したSA
```
- ``actions/checkout``：actions/checkout アクションを使用して、リポジトリのコードをランナーにチェックアウト
- ``hashicorp/setup-terraform``：指定バージョン（ここでは 1.6.6）の Terraform  をインストール
- ``google-github-actions/auth``：GitHub OIDC と GCP の Workload Identity Federation を用いて、認証

#### OIDC認証の設定 ####
```
        with:
          workload_identity_provider: "projects/${{ env.GCP_PROJECT_NUMBER_DEV }}/locations/global/workloadIdentityPools/github-pool/providers/github" #このWorkload Identity Providerを作成したGCPプロジェクトの「プロジェクト番号」を指定
          service_account: "terraform-cicd@${{ env.GCP_PROJECT_ID_DEV }}.iam.gserviceaccount.com" #作成したSA
```
- ``workload_identity_provider``：GitHub OIDC トークンを受け入れる GCP 側の Workload Identity Provider のリソースパスを指定します。
- ``service_account``：Terraform の実行に使用する GCP サービスアカウントのメールアドレスを指定します。このサービスアカウントには必要な権限（例：roles/editor）が付与されている必要があります。

#### terraform fmt～terraform plan ####
```
      - name: Terraform fmt
        id: fmt
        run: terraform fmt -check
        continue-on-error: true

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: |
          terraform plan -no-color | tee plan.txt
          echo "stdout<<EOF" >> $GITHUB_OUTPUT
          cat plan.txt >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
        continue-on-error: true
        
      - uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"

        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `### Project: **${process.env.GCP_PROJECT_ID_DEV}** 

            #### 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 }}\`, Working Directory: \`${{ env.tf_actions_working_dir_dev }}\`, Workflow: \`${{ github.workflow }}\`*`;
      
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
```
このステップでは、Terraform の整形チェック（``fmt``）、初期化（``init``）、構文検証（``validate``）、差分確認（``plan``）を順に実行しています。
差分確認の結果は、PRコメントに出力されるよう設定しており、Terraform の実行結果をレビューしやすくしています。
こちらの実装は、公式：[セットアップ-Terraform](https://github.com/hashicorp/setup-terraform/tree/v2/)をベースにしています。

#### PRがmainブランチにマージされたときのみTerraformを適用 ####
```
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply --auto-approve
```
main ブランチにPRがマージされたタイミングで、``terraform apply`` を実行してインフラ変更を実環境に反映します。
これは push イベントを利用していますが、実際の運用では「マージされたとき」に適用される、という理解で問題ありません。

### 4. develop環境でVPC作成PRを作成 → develop環境でplanが自動実行される
environments/develop配下のファイルでVPC作成コードを記載し、PRを作成します。
ファイル変更を検知しなかったstaging環境はplanの実行をスキップします。

PRのコメントにて、変更を検知したdevelop環境で``terraform plan``が自動実行され、Plan結果が出力されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4000068/ae47433b-f5d7-4ec1-ac44-67b98004148f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4000068/f17a4f57-bd55-402b-844e-10a574c38c3d.png)


### 5. PRをmainにマージし、applyを実行
mainにマージする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4000068/ea965acf-cc18-4085-99e2-fc7d932bda94.png)

Actionsタブにて、Apply結果を確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4000068/5b632867-dfa5-47fb-b61c-80bbd07a91a3.png)

## 構築を終えての感想
GitHub Actionsの作成について、何となく難しいと思っており、抵抗がありましたが、意外と公式ドキュメントを確認しながら手軽に構築できました。
GitHub Actions以外にAtlantisなどのterraformのCICDパイプラインがありますが、昨今、セキュリティ問題が多い中、Atlantisのように自前でサーバーを構築する必要ないことがメリットだと感じました。