モノレポ構成のTerraformをGitHub Actionsで自動化する

terraform planなどのTerraformのコマンドがPull Requestごとに自動で実行されるとTerraformの運用が楽になります。
本記事では、GitHub ActionsでTerraformの運用を楽にする自動化をモノレポ構成で実現するための方法を紹介します。
なお、本内容はAWSを使用した例になります。ただ大枠の内容はAWS以外でも参考になるはずです。

## 内容
今回実装したGitHub Actionsのワークフローの内容です。

### Terraformを実行する
GitHub ActionsでTerraformのコマンドを実行します。
以下を参考にして、Terraformのformat,validate,planの結果をPRにコメントするようにしています。

https://developer.hashicorp.com/terraform/tutorials/automation/github-actions#review-actions-workflow

```yaml:./github/workflows/terraform.yml
name: 'Terraform'
on:
  pull_request:
jobs:
...
  terraform:
...
    steps:
    - name: Checkout
      uses: actions/checkout@v3

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        role-to-assume: arn:aws:iam::XXXXXXXXXXXX:role/terraform-role
        aws-region: ap-northeast-1

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v1

    - name: Terraform Init
      id: init
      run: terraform init
      working-directory: ${{ matrix.workdir }}

    - name: Terraform Format
      id: fmt
      run: terraform fmt -check
      continue-on-error: true
      working-directory: ${{ matrix.workdir }}

    - name: Terraform Validate
      id: validate
      run: terraform validate -no-color
      continue-on-error: true
      working-directory: ${{ matrix.workdir }}

    - name: Terraform Plan
      id: plan
      if: github.event_name == 'pull_request'
      run: terraform plan -no-color -input=false
      continue-on-error: true
      working-directory: ${{ matrix.workdir }}

    - name: Update Pull Request
      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 = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
          #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
          #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
          #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
          <details><summary>Show Plan</summary>
          \`\`\`\n
          ${process.env.PLAN}
          \`\`\`
          </details>
          *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
          github.rest.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: output
          })
    - name: Terraform Plan Status
      if: steps.plan.outcome == 'failure'
      run: exit 1
```

`aws-actions/configure-aws-credentials@v1`でAWSの認証情報を取得しています。
`role-to-assume`を使うのがセキュアでオススメです。

各Terraformのコマンドを実行しているStepにある`working-directory`がモノレポ対応に関連します。次で説明します。

### モノレポに対応する
モノレポ内にある複数のTerraform作業ディレクトリごとに上記で用意したTerraformのジョブを実行するようにします。
そのために[dorny/paths-filter](https://github.com/dorny/paths-filter)というActionを使います。
このActionを使用すると、YAMLファイルの定義にしたがって対象ディレクトリ内のファイルに変更があったかどうかを判定します。GitHub ActionsのPath filter機能をジョブ内で実行することができるActionです。

```yaml:./github/workflows/terraform.yml
...
jobs:
  determine-workdir:
    runs-on: ubuntu-20.04
    permissions:
      pull-requests: read
      contents: read
    timeout-minutes: 10
    outputs:
      workdirs: ${{ steps.filter.outputs.workdirs }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: .github/path-filter.yml

      - name: filter
        id: filter
        run: |
          WORKDIRS=$(echo '${{ toJSON(steps.changes.outputs) }}' | jq '. | to_entries[] | select(.value == "true") | .key')
          echo "workdirs=$(echo $WORKDIRS | jq -sc '.')" >> $GITHUB_OUTPUT

  terraform:
    needs: determine-workdir
    runs-on: ubuntu-20.04
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    timeout-minutes: 10
    if: needs.determine-workdir.outputs.workdirs != '[]'
    strategy:
      matrix:
        workdir: ${{ fromJSON(needs.determine-workdir.outputs.workdirs) }}
...
```

ディレクトリ内で変更があったかの条件を定義するYAMLファイルは以下の通りです。
keyの部分で作業ディレクトリ名を記載し、valueの部分で作業ディレクトリに対して変更対象とするPath filterの値を記載します。以下では、account-aディレクトリ配下とmodulesディレクトリ配下で変更があった場合にaccount-aディレクトリで変更があったとなります。
これを複数のディレクトリごとに定義することでモノレポ構成に対応します。

```yaml:.github/path-filter.yml
account-a:
  - 'account-a/**'
  - 'modules/**'
account-b:
  - 'account-b/**'
  - 'modules/**'
account-c:
  - 'account-c/**'
  - 'modules/**'
```

`strategy.matrix`で渡した複数の値ごとに複数回のジョブを実行することができます。
`dorny/paths-filter`で得られた複数の作業ディレクトリ名を`strategy.matrix`の値に指定することでモノレポ内で変更があった作業ディレクトリごとにジョブを実行できます。また、前述の各Terraformコマンドの`working-directory`にmatrixを通して得られた作業ディレクトリ名を指定します。

matrixについては以下のドキュメントに詳しく説明されています。
https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix

ここまででモノレポ構成のTerraformをGitHub Actionsで自動化することができます。

## まとめ

GitHub Actionsのコードの全体は以下の通りです。
公式では自動化などのTerraform向けのプロダクトとしてTerraform Cloudがあります。
Terraform Cloudを使うのが諸々の事情で難しい場合、Terraformの自動化を行う別の方法としてGitHub Actionsを使うのが個人的には良いと思います。

```yaml:./github/workflows/terraform.yml
name: 'Terraform'

on:
  pull_request:

jobs:
  determine-workdir:
    runs-on: ubuntu-20.04
    permissions:
      pull-requests: read
      contents: read
    timeout-minutes: 10
    outputs:
      workdirs: ${{ steps.filter.outputs.workdirs }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: .github/path-filter.yml

      - name: filter
        id: filter
        run: |
          WORKDIRS=$(echo '${{ toJSON(steps.changes.outputs) }}' | jq '. | to_entries[] | select(.value == "true") | .key')
          echo "workdirs=$(echo $WORKDIRS | jq -sc '.')" >> $GITHUB_OUTPUT
  terraform:
    needs: determine-workdir
    runs-on: ubuntu-20.04
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    timeout-minutes: 10
    if: needs.determine-workdir.outputs.workdirs != '[]'
    strategy:
      matrix:
        workdir: ${{ fromJSON(needs.determine-workdir.outputs.workdirs) }}

    steps:
    - name: Checkout
      uses: actions/checkout@v3

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        role-to-assume: arn:aws:iam::XXXXXXXXXXXX:role/terraform-role
        aws-region: ap-northeast-1

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v1

    - name: Terraform Init
      id: init
      run: terraform init
      working-directory: ${{ matrix.workdir }}

    - name: Terraform Format
      id: fmt
      run: terraform fmt -check
      continue-on-error: true
      working-directory: ${{ matrix.workdir }}

    - name: Terraform Validate
      id: validate
      run: terraform validate -no-color
      continue-on-error: true
      working-directory: ${{ matrix.workdir }}

    - name: Terraform Plan
      id: plan
      if: github.event_name == 'pull_request'
      run: terraform plan -no-color -input=false
      continue-on-error: true
      working-directory: ${{ matrix.workdir }}

    - name: Update Pull Request
      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 = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
          #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
          #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
          #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
          <details><summary>Show Plan</summary>
          \`\`\`\n
          ${process.env.PLAN}
          \`\`\`
          </details>
          *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
          github.rest.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: output
          })
    - name: Terraform Plan Status
      if: steps.plan.outcome == 'failure'
      run: exit 1
```

## 参考
以下を参考にさせて頂きました。
- https://developer.hashicorp.com/terraform/tutorials/automation/github-actions
- https://zenn.dev/naofumimurata/articles/tf-monorepo-github-actions