Github ActionsでTerraformコマンドを実行する

# はじめに
[メタップスアドベントカレンダー](https://qiita.com/advent-calendar/2023/metaps)16日目の記事です。

インフラのコードをTerraformで管理しているリポジトリについて、Github Actionsを使ってTerraformのplanやapplyなど各種コマンドを実行できるようにしました。

# Github Actionsの構成
以下のようなことを実行しています。
* Terraformでworkspaceを使う構成となっているため、[workflow_dispatch](https://docs.github.com/ja/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch)でEnvironmentや実行コマンドを選択して実行する。
* Terraformのproviderやworkspaceごとに細かくディレクトリやtfstateが分かれているため、差分検出用のJobとTerraformコマンド実行用のJobを分割している。
* [validate](https://developer.hashicorp.com/terraform/cli/commands/validate)コマンドでTerraformの構文チェックを行う。
* [trivy-action](https://github.com/aquasecurity/trivy-action)を使ってTerraformのコード脆弱性をチェックする。
* [tfnotify](https://github.com/mercari/tfnotify)を使って実行結果をslackに通知させる。

# Github Actionsコードサンプル

### ディレクトリ構成
```
├── .github
│   ├── ISSUE_TEMPLATE.md
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows
│       ├── genova.yml
│       ├── terraform-manual.yml
│       ├── terraform-plan.yml
│       └── trivy.yml
├── .tfnotify
│   └── slack.yml
```

コード内で参照するenvの秘匿値は事前にリポジトリに登録しておいてください。
https://docs.github.com/ja/actions/security-guides/using-secrets-in-github-actions

### terraform-manual.yml
default指定しているdevelopブランチとTerraformコマンド実行対象のbranchとで差分のあるリソースのみを抽出し、実行します。
```yml
name: TerraformManualRelease
run-name: TerraformManualRelease [${{ inputs.environment }}] [${{ inputs.plan_or_apply }}] From [${{ github.ref_name }}]
on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'AWS Environment'
        required: true
        type: choice
        default: '-'
        options:
          - '-'
          - staging
          - production
      plan_or_apply:
        description: 'Terraform plan or apply'
        required: true
        type: choice
        options:
          - plan
          - apply

jobs:
  setup:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    outputs:
      plan_targets: ${{ steps.output-targets.outputs.plan_targets }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Get branch name
        id: branch-name
        uses: tj-actions/branch-names@v6

      - name: diff
        id: diff
        run: |
          diff_files=$(git diff --name-only origin/${{ steps.branch-name.outputs.default_branch }}...origin/${{ steps.branch-name.outputs.current_branch }} -- '*.tf')
          diff_files_json=$(echo "$diff_files" | jq -R . | jq -s . | tr -d '\n' | sed 's/,$//')
          echo "diff_files=$diff_files_json" >> "$GITHUB_OUTPUT"

      - name: Output target dirs
        id: output-targets
        uses: k1LoW/github-script-ruby@v2
        with:
          script: |
            require 'json'
            dirs = []
            Dir.glob('**/*.tf').each do |f|
              dirs.push(File.dirname(f))
            end
            valid_dirs = dirs.uniq

            files = '${{ steps.diff.outputs.diff_files }}'.split
            targets = valid_dirs.select { |v| files.select { |d| d.match?(v) }.size > 0 }
            puts "Target files: #{files}"
            plan_targets = targets.flatten.uniq
            puts "Target dirs: #{files}"
            puts "Plan target dirs: #{plan_targets}"
            core.set_output('plan_targets', plan_targets.to_json)

  terraform:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      id-token: write
      contents: write
      pull-requests: write
    needs: setup
    env:
      TERRAFORM_VERSION: {TERRAFORM_VERSION}
      AWS_ROLE_ARN: {AWS_ROLE_ARN}
      AWS_DEFAULT_REGION: {AWS_DEFAULT_REGION}
      TFSATTE_BUCKET_NAME: {TFSATTE_BUCKET_NAME}
      GITHUB_OWNER: metaps
      GITHUB_USER: {GITHUB_USER}
      GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
      SSH_KEY: ${{ secrets.SSH_KEY }}
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      TF_VAR_app_domain: {TF_VAR_app_domain}
      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
      SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
      SLACK_BOT_NAME: tfnotify
      TFNOTIFY_CONFIG: /home/runner/work/{pass}/.tfnotify/slack.yml
    defaults:
      run:
        working-directory: ${{ matrix.target }}
    strategy:
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.plan_targets) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Env check
        if: ${{ github.event.inputs.environment == '-' && contains(matrix.target, 'providers/aws/application') }}
        run: |
          echo "Error: You are modifying an AWS resource with an Environment, but Environment is not specified"
          exit 1

      - name: Set env to staging
        if: ${{ github.event.inputs.environment == 'staging' }}
        run: |
          echo "ENV=staging" >> $GITHUB_ENV

      - name: Set env to production
        if: ${{ github.event.inputs.environment == 'production' }}
        run: |
          echo "ENV=produciton" >> $GITHUB_ENV

      - name: Set AWS credentials
        uses: aws-actions/configure-aws-credentials@master
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup tfnotify
        run: |
          sudo curl -fL -o tfnotify.tar.gz https://github.com/mercari/tfnotify/releases/download/v0.7.0/tfnotify_linux_amd64.tar.gz
          sudo tar -C /usr/bin -xzf ./tfnotify.tar.gz

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TERRAFORM_VERSION }}

      - name: Set env to staging
        if: ${{ github.event.inputs.environment == 'staging' }}
        run: |
          echo "ENV=staging" >> $GITHUB_ENV

      - name: Set env to production
        if: ${{ github.event.inputs.environment == 'production' }}
        run: |
          echo "ENV=production" >> $GITHUB_ENV

      - name: Terraform Applcation Init
        if: contains(matrix.target, 'providers/aws/application')
        run: |
          export MATRIX_TARGET=$(echo "${{ matrix.target }}" | sed "s/providers\/aws\/application/providers\/aws\/application\/${{ env.ENV }}/g")
          terraform init -reconfigure -backend-config="key=${MATRIX_TARGET}".tfstate
        env:
          GIT_SSH_COMMAND: "echo '${{ secrets.SSH_KEY }}' > id_rsa
            && ssh-keyscan github.com > known_hosts
            && chmod 600 id_rsa known_hosts
            && ssh -i ./id_rsa -o UserKnownHostsFile=./known_hosts"

      - name: Terraform Init
        if: "!contains(matrix.target, 'providers/aws/application')"
        run: |
          terraform init -reconfigure -backend-config="key=${{ matrix.target }}".tfstate
        env:
          GIT_SSH_COMMAND: "echo '${{ secrets.SSH_KEY }}' > id_rsa
            && ssh-keyscan github.com > known_hosts
            && chmod 600 id_rsa known_hosts
            && ssh -i ./id_rsa -o UserKnownHostsFile=./known_hosts"

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

      - name: Run Trivy
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: config
          trivy-config: ../trivy.yml
        continue-on-error: true

      - name: Terraform Plan
        if: ${{  github.event.inputs.plan_or_apply == 'plan' && contains(matrix.target, 'providers/aws/application') }}
        run: |
          terraform plan -lock=false -no-color -var="env=${{ env.ENV }}" >> apply_result.temp
          cat apply_result.temp | tfnotify --config ${{ env.TFNOTIFY_CONFIG }} plan --message "$(date)"
        continue-on-error: true

      - name: Terraform Plan
        if: ${{  github.event.inputs.plan_or_apply == 'plan' && !contains(matrix.target, 'providers/aws/application') }}
        run: |
          terraform plan -lock=false -no-color >> apply_result.temp
          cat apply_result.temp | tfnotify --config ${{ env.TFNOTIFY_CONFIG }} plan --message "$(date)"
        continue-on-error: true

      - name: Terraform Applcation Apply
        if: ${{  github.event.inputs.plan_or_apply == 'apply' && contains(matrix.target, 'providers/aws/application') }}
        run: |
          terraform apply -auto-approve -var="env=${{ env.ENV }}" >> apply_result.temp
          cat apply_result.temp | tfnotify --config ${{ env.TFNOTIFY_CONFIG }} apply --message "$(date)"

      - name: Terraform Apply
        if: ${{  github.event.inputs.plan_or_apply == 'apply' && !contains(matrix.target, 'providers/aws/application') }}
        run: |
          terraform apply -auto-approve >> apply_result.temp
          cat apply_result.temp | tfnotify --config ${{ env.TFNOTIFY_CONFIG }} apply --message "$(date)"

```

### trivy.yml
```yml
exit-code: 1

severity:
  - HIGH
  - CRITICAL
```

### slack.yml
```yml
ci: github-actions
notifier:
  slack:
    token: $SLACK_TOKEN
    channel: $SLACK_CHANNEL_ID
    bot: $SLACK_BOT_NAME
terraform:
  plan:
    template: |
      {{ .Message }}
      {{if .Result}}
      ```
      {{ .Result }}
      ```
      {{end}}
      ```
      {{ .Body }}
      ```
  apply:
    template: |
      {{ .Message }}
      {{if .Result}}
      ```
      {{ .Result }}
      ```
      {{end}}
      ```
      {{ .Body }}
      ```
```

# 実行結果
実行時にEnvironmentと実行コマンドを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252317/56a082eb-75e4-3e38-f33c-64a599f2b6f2.png)


### 通知画面
実行結果をslackに通知させることができます。
![スクリーンショット 2023-12-16 11.31.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252317/b33004ae-e295-c185-f4d5-68c7cb3f18fe.png)

### trivyの実行結果
Github Actionsの中でterraformコードの脆弱性を出力させることができます。
```
git::https:/github.com/terraform-aws-modules/terraform-aws-s3-bucket?ref=v3.15.1/main.tf (terraform)
====================================================================================================
Tests: 10 (SUCCESSES: 8, FAILURES: 2, EXCEPTIONS: 0)
Failures: 2 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 0, CRITICAL: 0)

LOW: Bucket has logging disabled
════════════════════════════════════════
Ensures S3 bucket logging is enabled for S3 buckets

See https://avd.aquasec.com/misconfig/avd-aws-0089
────────────────────────────────────────
 git::https:/github.com/terraform-aws-modules/terraform-aws-s3-bucket?ref=v3.15.1/main.tf:25-34
   via terraform/providers/aws/{pass}/main.tf:334-354 (module.s3)
────────────────────────────────────────
  25 ┌ resource "aws_s3_bucket" "this" {
  26 │   count = local.create_bucket ? 1 : 0
  27 │ 
  28 │   bucket        = var.bucket
  29 │   bucket_prefix = var.bucket_prefix
  30 │ 
  31 │   force_destroy       = var.force_destroy
  32 │   object_lock_enabled = var.object_lock_enabled
  33 │   tags                = var.tags
  34 └ }
────────────────────────────────────────
```

# まとめ
Github Actionsでの各種操作に関するpermissionsについてはまだ改善余地があると思われますが、Github ActionsでTerraformコマンドを実行させることができました。