Digger + GitHub Actionsで作るTerraform/OpenTofuのCI/CD

# はじめに

この記事は [Terraform Advent Calendar 2024](https://qiita.com/advent-calendar/2024/terraform) の 23日目の記事です。

TerraformのCI/CDどうしてますか？私は長年[Atlantis](https://github.com/runatlantis/atlantis)を愛用していました。Atlantisは、Terraform/OpenTofu用のCI/CDサーバで、自分で使う分にはどちゃクソ便利なんですが、サーバを自前で建てないといけないのがネックで、なかなか初心者や小規模な環境にはオススメしづらいのが悩ましいところです。

なんかよい代替手段はないかしら？と思って最近使ってみているのが[Digger](https://github.com/diggerhq/digger)です。DiggerはGitHub Actionsをランナーとして使うことで、サーバレスでAtlantis風のApply-Before-MergeスタイルのCI/CDが手軽に実現できます。本稿執筆時点で、DiggerのGitHubスター数は4k以上あるものの、国内での採用事例はまだあまり聞かないので、日本語の解説記事を書いてみることにしました。

一応Atlantisわからん人のために補足しておくと、Pull Requestを作成すると自動で変更したディレクトリのplan結果がコメントされるので、誰かにレビューしてもらってApproveをもらい、Pull Requestにdigger applyとコメントするとapplyが実行され、成功すると自動でマージされるというようなワークフローを簡単に組むことができます。

既にアプリケーション開発などでGitHub Actionsに馴染がある人なら、素朴にGitHub ActionsだけでPull Request作成時にplanして、デフォルトブランチへのマージでapplyというようなCI/CDを組むこともできるとは思います。ただTerraformあるあるですが、planは成功しても、applyはいろんな理由で失敗します。試行錯誤のたびにPull Requestを作成するのは、作業履歴が散らばりがちです。

また、いろんな人に話を聞いていると、planは自動化したが、applyは怖くて自動化できてないという話もよく聞きます。DiggerはAtlantisと同じく、Pull Request上で作業ブランチからapplyするApply-Before-Mergeというちょっと特殊なワークフローを採用しており、これが手元からapplyする体験に近いかつ、planも保存できるので、確実にレビューした内容だけをapplyできます。

どうせデフォルトブランチでapplyに失敗してコードと環境の不整合が発生するのなら、レビューした上でapplyしてから速やかにマージするのと何が違うのでしょうか？インフラのCI/CDの理想と現実のバランスを取ったApply-Before-Mergeスタイルのよさは、実際に体験してみないと伝わりづらいので、まだやってみたことない人は、一度試してみてほしいです。

この記事では、Diggerの概要や設定方法などについて解説します。とりあえず動かすだけであれば簡単なんですが、production環境で運用しようとすると、細かい設定を調整したくなるので、便利な設定などもいくつか紹介します。また実運用ではディレクトリ構造をリファクタリングするのに[tfmigrate](https://github.com/minamijoyo/tfmigrate)も欲しくなるところですが、組み込み方が自明ではなかったので、カスタムワークフローの使い方説明も兼ねて後半で解説します。

本稿執筆時点の各ツールのバージョンは以下のとおりです。

- Digger: v0.6.79
- Terraform: v1.10.3
- OpenTofu: v1.8.7
- Terraform provider aws: v5.82.1
- Terraform provider null: v3.2.3
- tfmigrate: v0.4.1

最終的なサンプルコードは、以下のリポジトリに置いておきました。

https://github.com/minamijoyo/tfmigrate-digger-example

例として、ありがちなGitHub Actions + AWSマルチアカウント運用を想定したコードになってるので、雑多なファイルがいろいろ置いてありますが、本題であるGitHub ActionsのワークフローのYAMLは60行ぐらいしかないです。高度に発達したYAML職人は不要なので安心してください（？）

# Diggerとは
Diggerとは、Terraform/OpenTofu用のCI/CDツールです。HCP Terraform(旧Terraform Cloud)に代表されるいわゆるTACOS (Terraform Automation and Collaboration Software) の1つです。GitHub Actionsをランナーとして使うことで、サーバレスでAtlantis風のApply-Before-MergeスタイルのCI/CDが手軽に実現できます。

公式サイト: https://digger.dev/
リポジトリ: https://github.com/diggerhq/digger

Diggerの提供形態はざっくり言うと有償版のSaaSと、無償で使えるOSS版がありますが、いくつかバリエーションがあります。
https://docs.digger.dev/readme/pricing

有償版はDigger TeamプランとEnterpriseプランがあり、機能とお値段が異なります。
https://digger.dev/pricing

OSS版のDigger Community Editionの中にも、diggerコマンドのCLIとしてスタンドアロンで動作するBackendlessモードと、状態をDiggerサーバ側で持つOrchestratorモードがあります。

BackendlessモードはただのCLIツールなので、telemetryも無効化すれば、GitHub Actions単体で完結します。OrchestratorモードのDiggerサーバは、自前でセルフホストすることも可能ですが、Diggerの開発チームが管理しているサーバを使うこともできます。これは実質SaaSの無料プランとして提供されています。

当然Orchestratorモードの方が高機能なのですが、plan結果やエラーメッセージなどをサーバ側に送信するので、セキュリティの要件が厳しい環境で利用する場合は注意が必要かもしれません。とはいえGitHub Actionsをランナーに使っているので、クラウドプロバイダのクレデンシャルはDiggerサーバに共有する必要はありません。競合他社のSaaSと比べるのであれば、セルフホストランナー相当が標準であるとも言えるので、なにがどこまで許容できるか次第で選択するとよいでしょう。

Backendlessモードはいくつか機能制限があり、特にAtlantisと比較すると、並列planやPull Requestロックをできないのがちょっと物足りない感じです。とはいえ変更の頻度が少ない小規模な環境であれば、サーバレスでお手軽にCI/CDを導入するのに十分ありな選択肢だと思います。

Diggerでは、Terraform/OpenTofuに特化したロジックはdiggerコマンドの中に作り込まれているので、GitHub Actions職人が高度に発達したYAMLを作り込む必要もありません。いくつかの設定ファイルを配置し、plan/applyするクラウドプロバイダの権限だけ調整すれば使い始められるので、圧倒的に導入の敷居が低いです。また、DiggerのOSS版であればランニングコストは、実質GitHub Actionsのコンピューティングコストしかかからないので、安価に運用できます。

この記事では、OSS版のDigger Community EditionをBackendlessモードで使います。

# OpenTofuとは

Diggerの設定に入る前に、OpenTofuわからん人のために、OpenTofuについても補足しておきます。

Terraformはv1.6以降HashiCorp BSL(Business Source License)で配布されており、OSSではありません。OpenTofuはライセンス変更前のMPL2のコードベースからforkされた、OSSのインフラ構成管理ツールです。Linux Foundation傘下のプロジェクトとして、引き続きMPL2ライセンスで開発が進められています。

公式サイト: https://opentofu.org
リポジトリ: https://github.com/opentofu/opentofu

OpenTofuはTerraform v1.6.0-alpha相当からforkされたので、ここ最近1年ぐらいで追加されたTerraform v1.6以降の新機能を使っていなければ、今のところはほぼ同じです。

TerraformとOpenTofuの違いについては以下の記事にまとめています。

https://qiita.com/minamijoyo/items/2738b9ad5f6754b68400

と自分で言いつつ、上記の記事を読み始めると、長くて帰ってこれなくなるので、あとで読んでもらうとして、ここではとりあえず、terraform init / plan / applyを、tofu init / plan / applyに読み替えればほぼ同じと思って下さい。

なんでOpenTofuの話をしているかと言うと、HashiCorp BSLはHCP Terraform(旧Terraform Cloud)の競合サービスに対して、Terraformの商用利用を制限しているからです。

HashiCorp BSLの最新版のライセンスの全文は、以下で確認できます。
https://www.hashicorp.com/bsl

ライセンス文言は、厳密にはリリースバージョンごとに管理されています。
https://github.com/hashicorp/terraform/blob/v1.10.3/LICENSE

Diggerのコンテキストに限定してざっくり言うと、Diggerの有償版でTerraformを使うのはアウトで、無償版を社内利用するだけであればセーフな認識ですが、私は法律の専門家ではないので、ライセンスの解釈については各自の責任で判断して下さい。

またライセンス的に問題ないかという話もあるんですが、Diggerの開発チームの立場としては、Terraform v1.6以降を公式にはサポートしていません。
https://docs.digger.dev/readme/faq

> Digger does not officially support Terraform versions 1.6 and above (any version released after Hashicorp’s switch to BSL, announced in August 2023). We recommend using OpenTofu instead, a community fork of Terraform in response to Hashicorp’s license change.

公式にサポートしていないと言いつつ、後方互換性を維持するためか（？）、本稿執筆時点では、明示的にOpenTofuの設定をopt-inしないと、デフォルトではTerraformの最新版つまりv1.6以上が使用されるという若干矛盾した状態になっています。そのうちデフォルトもOpenTofuになるんじゃないかと思ってますが、ご注意下さい。

この記事では、OpenTofuを使います。

# Diggerの設定

前置きが長くなりましたが、Diggerの設定をしていきます。

ここでは段階的に設定項目の意味を説明していく順番の都合で、断片的なコードで説明しますが、全体感や最終形は前述のサンプルコードのリポジトリを参照して下さい。

またGitHub Actionsの使い方そのものは解説しないので、GitHub Actions自体が初心者の人は、先にGitHub Actionsに入門してから戻ってきて下さい。

## Backendlessモードの基本設定

Diggerを導入するには、まず `.github/workflows/digger_workflow.yml` に以下のようなGitHub Actionsのワークフローファイルの配置します。

Backendlessモードだけで使うのであれば、ファイル名は任意ですが、Orchestratorモードで使う場合は、workflow_dispatchイベントでファイル名が `digger_workflow.yml` であることを暗黙に仮定しているので、こだわりがなければこのファイル名に揃えておくと、あとでOrchestratorモードを試したくなったときに無駄にハマらなくてよいです。 

```.github/workflows/digger_workflow.yml
name: Digger Workflow

on:
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize ]
  issue_comment:
    types: [created]

jobs:
  digger-job:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write      # required to merge PRs
      pull-requests: write # required to post PR comments
      issues: read         # required to check if PR number is an issue or not
      statuses: write      # required to validate combined PR status

    steps:
      - uses: diggerhq/digger@v0.6.79
        with:
          no-backend: true
          disable-locking: true
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

Backendlessモードで拾うべきイベントは、pull_requestとissue_commentです。Pull Requestの作成/更新で自動でplanを実行し、Pull Requestにdigger applyとコメントすると、applyを実行するので、両方のイベントを拾っておきます。

先にハマりポイントの補足ですが、GitHub Actionsのワークフローファイルはpull_requestイベントの場合は作業ブランチのものが使われますが、issue_commentイベントの場合はデフォルトブランチのものが使われるので、issue_commentから発動するパターンはマージしないと稼働確認できません。Diggerの設定をいじる場合に忘れてハマりがちなので、注意して下さい。

イベントに応じてどのようなコマンドを実行するかは、`github` コンテキスト変数を環境変数 `GITHUB_CONTEXT` 経由でdiggerコマンドに丸投げすると判断してくれます。diggerコマンドは公式で提供されているdiggerhq/diggerアクション経由で実行します。このモノリシックなアーキテクチャのおかげで、シンプルなワークフローで豊富な機能が提供されます。

Backendlessモードで使うには、diggerhq/diggerアクションの入力パラメータに、 `no-backend: true` と `disable-locking: true` を指定しておきます。

また、diggerはplan結果をPull Requestにコメントしたりするので、必要な権限を `permissions` で付与したGitHubトークンを環境変数 `GITHUB_TOKEN` 経由で渡しています。

Diggerの設定はアクションの入力パラメータに指定するものと、設定ファイルに指定するものがあります。ここではとりあえず、以下のようなdigger.ymlをリポジトリルートに配置し、プロジェクト設定をしておきます。

```digger.yml
projects:
  - name: dev_dir1
    dir: envs/dev/dir1
  - name: dev_dir2
    dir: envs/dev/dir2
```

Diggerでは、apply可能なディレクトリの単位であるrootモジュールを、プロジェクトという単位で管理しています。この例では2つのディレクトリを登録しています。プロジェクトを動的に検出する方法はすぐ後で説明します。

これだけで最低限Pull Request上でplanとapplyができるようになりました。稼働確認するだけであれば、例えば以下のように、適当なnull_resourceを作成してみればよいでしょう。

```envs/dev/dir1/main.tf
resource "null_resource" "foo1" {}
resource "null_resource" "foo2" {}
```

```envs/dev/dir2/main.tf
resource "null_resource" "foo3" {}
resource "null_resource" "foo4" {}
```

まだこの段階ではクラウドプロバイダの設定などをしてないので、実質null_resourceぐらいしか作れませんが、導入の敷居の低さがおわかりいただけただろうか。

とはいえ、ちょっと実運用を考えるといろいろ足りてないので、ここから設定を調整していきます。

## 設定のカスタマイズ
### Diggerの設定

Diggerの設定ファイルdigger.ymlのドキュメントは以下にあります。
https://docs.digger.dev/ce/reference/digger.yml

もう少し実用的な例として、digger.ymlを以下のように変更しておきます。

```digger.yml
telemetry: false
auto_merge: true
traverse_to_nested_projects: true
generate_projects:
  blocks:
    - block_name: envs
      include: "envs/**"
```

`telemetry: false` はテレメトリの無効化です。送信される情報には統計情報以外にエラーメッセージなども含まれるので、気になる人はオプトアウトできます。

`auto_merge: true` はapply成功後の自動マージ設定です。apply後に手動マージだと忘れがちなので有効化しておきましょう。

`generate_projects` はプロジェクトの自動生成設定です。プロジェクト設定をカスタマイズしたい場合は、明示的に `projects` キーでプロジェクトごとにディレクトリを列挙し、それぞれ設定をカスタマイズすることもできます。ただあらかじめプロジェクト一覧を定義する方法は、ディレクトリ数が多いと煩雑です。ここでは `generate_projects` を使って、envs/ディレクトリ配下のrootモジュールを自動で検出する設定をしています。また `traverse_to_nested_projects: true` を指定することで、サブディレクトリも再帰的に探索します。 `generate_projects` の `blocks` はprojectのテンプレートとなっており、後述のOpenTofuの設定などプロジェクト設定をカスタマイズするのに使います。

一点補足として、modules/配下に汎用モジュールを置いている場合、プロジェクト設定で `include_patterns: ["./modules/**"]` のように指定することでrootモジュール以外の変更も検出することができますが、Atlantisのようにどのモジュールに依存しているかまではいいかんじに検出してくれません。依存していないモジュールの変更でもplanが実行されるのは無駄なので、サンプルコードでは、 `modules/digger/gha_role/v1` のようにモジュールのディレクトリで簡易バージョニングしています。これにより、バージョンを上げると参照先のrootモジュール側のtfファイルにも変更が必要となり、結果的にplan対象となるようにしています。

### GitHub Actionsのワークフロー設定

GitHub Actionsのワークフローも調整します。

Diggerの公式GitHub Actionsの入力パラメータのドキュメントは以下にあります。
https://docs.digger.dev/ce/reference/action-inputs

このアクションの実体は、ただのcompositeアクションなので、各パラメータの意味が気になる場合は、ソースを読んだほうが早いかもしれません。
https://github.com/diggerhq/digger/blob/v0.6.79/action.yml

あと、Digger関係ないですが、GitHub Actionsのワークフローファイルのドキュメントは以下にあります。
https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions

```.github/workflows/digger_workflow.yml
name: Digger Workflow

on:
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize ]
    paths:
      - 'envs/**'
  issue_comment:
    types: [created]

concurrency:
  group: digger-run

jobs:
  digger-job:
    if: ${{ github.event_name == 'pull_request' || github.event_name == 'issue_comment' && startsWith(github.event.comment.body, 'digger') }}
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write      # required to merge PRs
      pull-requests: write # required to post PR comments
      issues: read         # required to check if PR number is an issue or not
      statuses: write      # required to validate combined PR status

    steps:
      - uses: diggerhq/digger@v0.6.79
        with:
          no-backend: true
          disable-locking: true
          cache-dependencies: true
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

まず、diggerhq/diggerアクションの入力パラメータに `cache-dependencies: true` を指定しておきます。この設定は、ダウンロードしたプロバイダのファイルをGitHub Actionsのキャッシュに保存します。例えばAWSプロバイダは数百MBぐらいあったりしてまぁまぁ大きいので、キャッシュしておくとよいでしょう。

次にGitHub Actionsが不要なイベントに無駄に反応しないようにしておきます。BackendlessモードはGitHub Actions単体で動くので、issueのコメントのたびにワークフローが起動してしまい若干非効率です。これに対して、OrchestratorモードではGitHubのWebフックイベントをDiggerサーバに送信し、サーバ側からworkflow_dispatchイベントでGitHub Actionsがキックされます。

Backendlessモードでは、仕組み上ワークフローの起動自体は不可避ですが、ジョブの先頭でイベントの種類がpull_requestかissue_commentをチェックし、issue_commentの場合は、`digger` という文字列から始まるものだけ反応するようにフィルタリングしておくと無駄がないです。

また、pull_requestイベントに関しては、ファイルのパスでフィルタしておくとよいでしょう。サンプルコードでは、envs/ディレクトリ配下にrootモジュールが配置されていると仮定し、planを発動すべきファイル差分を絞り込んでいます。

最後に、GitHub Actionsのconcurrency group設定で同じワークフローの同時起動を抑止し、直列実行するようにしておきます。Pull Requestロックの完全な代替にはなりませんが、もし同じディレクトリを変更する2つのPull Requestを同時にapplyした場合は、早いものがちで後者は待たされてstaled planとなり、applyが失敗するはずです。関係ないディレクトリの場合も無駄に待たされるので、若干非効率ではありますが、最低限の事故防止として安全側に倒せてます。更新頻度が少ない小規模な構成であれば、運用でカバーとして許容できる範囲ではないかと思います。

Orchestratorモードで使う場合は、Pull Requestロックはサーバ側で管理され、プロジェクト単位で並列にワークフローが起動されるので、同時起動を抑止する必要はありません。ドキュメント上はBackendlessモードでも、DynamoDBを使ってPull Requestロックができるような記載もありますが、試してみたかんじ機能しているようには見えませんでした。

## OpenTofuの設定

本稿執筆時点では、Diggerが使うIaCツールのデフォルトがTerraformになっているので、OpenTofuを使う場合には追加で設定が必要です。

GitHub Actionsのワークフローファイルを調整します。

```.github/workflows/digger_workflow.yml
name: Digger Workflow

on:
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize ]
    paths:
      - 'envs/**'
  issue_comment:
    types: [created]

concurrency:
  group: digger-run

jobs:
  digger-job:
    if: ${{ github.event_name == 'pull_request' || github.event_name == 'issue_comment' && startsWith(github.event.comment.body, 'digger') }}
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write      # required to merge PRs
      pull-requests: write # required to post PR comments
      issues: read         # required to check if PR number is an issue or not
      statuses: write      # required to validate combined PR status

    steps:
      - uses: actions/checkout@v4
        with:
          ref: refs/pull/${{ github.event.issue.number }}/merge
        if: ${{ github.event_name == 'issue_comment' }}
      - uses: actions/checkout@v4
        if: ${{ github.event_name != 'issue_comment' }}
      - id: opentofu_version
        run: echo "value=$(sed -n 's/^opentofu \(.*\)$/\1/p' .tool-versions)" >> $GITHUB_OUTPUT
      - uses: diggerhq/digger@v0.6.79
        with:
          no-backend: true
          disable-locking: true
          cache-dependencies: true
          configure-checkout: false
          setup-opentofu: true
          opentofu-version: ${{ steps.opentofu_version.outputs.value }}
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

ポイントは以下のとおりです。

まず、 diggerhq/digger アクションの入力パラメータに `setup-opentofu: true` を指定して、opentofuをインストールします。このとき `opentofu-version` パラメータでバージョンを指定することもできますが、値をハードコードせずに、`.tool-versions` ファイルから読み込むようにしています。

`.tool-versions` は 依存ツールのバージョンを管理する[asdf](https://github.com/virtualroot/asdf-opentofu)用の設定ファイルですが、中身は以下のとおりで、ツール名とバージョンがスペース区切りで書かれており、これをsedでパースして、 `opentofu-version`に渡しています。

```
opentofu 1.8.7
```

もしOpenTofuのバージョン管理に他のツール、例えば [tenv](https://github.com/tofuutils/tenv) を使っており、`.tofu-version` ファイルで指定している場合は、sedではなく単にcatするなど、適宜読み替えて下さい。

バージョンファイルを分けているのは、ローカルとCI/CDでバージョンを揃えたいという意図もありますが、OpenTofuのバージョンアップ時にplanとapplyのバージョンが不一致にならないようにという意味もあります。前述の通り、pull_requestイベントは作業ブランチで発動しますが、issue_commentイベントはデフォルトブランチで発動します。もし `opentofu-version` をワークフローファイルにハードコードしており、バージョンアップのPull Requestで書き換えた場合、自動planは新しいバージョンで実行されますが、digger applyすると古いバージョンでapplyされてしまいます。これを回避するのに、発動したワークフローファイルを実行中に書き換えることはできないものの、別ファイルから情報を読み込むことは可能です。というわけで、バージョン番号はワークフローファイルにベタ書きせずに、別ファイルに書いています。

ところで、 diggerhq/digger アクションは、デフォルトで actions/checkout を内部で実行していますが、先に `.tool-versions` を読み込むには、自前でactions/checkoutを実行する必要があります。ただ単純にactions/checkout を実行するだけだと、issue_commentイベントの場合にデフォルトブランチで発動してしまうので、pull_requestイベントの場合と同じrefをcheckoutするように調整しています。

自前でチェックアウトした場合は、diggerhq/diggerアクションではcheckoutが不要なので、`configure-checkout: false` を指定しておきます。

これで適切なバージョンが選択できるようになりました。 そもそも理想的には公式の opentofu/setup-opentofu アクションがtfファイルに書いた `required_version` を認識してくれれば、本来こんなめんどくさいことはしなくてよいはずなんですが、現状はこのようなひと手間が必要です。

https://github.com/opentofu/setup-opentofu/issues/40

最後に、digger.ymlのプロジェクト設定に `opentofu: true` を追加します。

```digger.yml
telemetry: false
auto_merge: true
traverse_to_nested_projects: true
generate_projects:
  blocks:
    - block_name: envs
      include: "envs/**"
      opentofu: true
```

以上で、TerraformではなくOpenTofuが使われるようになりました。

## AWS認証の設定

ここまで特定のクラウドプロバイダに依存した設定をしていませんでしたが、現実的なユースケースではなんらかのクラウドプロバイダのAPIを叩いたりするでしょう。ここでは例として、AWSの認証設定をしてみます。

GitHub ActionsからAWS認証するには、diggerhq/diggerアクションで、`setup-aws: true` すると内部的に `aws-actions/configure-aws-credentials` が呼ばれます。最低限 `aws-region`, `aws-access-key-id`, `aws-secret-access-key` を設定すればAWS認証はできます。

ただせっかくGitHub Actionsを使っているので、ここでは、よりセキュアなOIDC認証を使って、アクセスキーなしで認証できるように設定します。これには以下のようなワークフローを設定します。

```.github/workflows/digger_workflow.yml
name: Digger Workflow

on:
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize ]
    paths:
      - 'envs/**'
  issue_comment:
    types: [created]

concurrency:
  group: digger-run

jobs:
  digger-job:
    if: ${{ github.event_name == 'pull_request' || github.event_name == 'issue_comment' && startsWith(github.event.comment.body, 'digger') }}
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write      # required to merge PRs
      id-token: write      # required for workload-identity-federation
      pull-requests: write # required to post PR comments
      issues: read         # required to check if PR number is an issue or not
      statuses: write      # required to validate combined PR status

    steps:
      - uses: actions/checkout@v4
        with:
          ref: refs/pull/${{ github.event.issue.number }}/merge
        if: ${{ github.event_name == 'issue_comment' }}
      - uses: actions/checkout@v4
        if: ${{ github.event_name != 'issue_comment' }}
      - id: opentofu_version
        run: echo "value=$(sed -n 's/^opentofu \(.*\)$/\1/p' .tool-versions)" >> $GITHUB_OUTPUT
      - uses: diggerhq/digger@v0.6.79
        with:
          no-backend: true
          disable-locking: true
          cache-dependencies: true
          configure-checkout: false
          setup-opentofu: true
          opentofu-version: ${{ steps.opentofu_version.outputs.value }}
          setup-aws: true
          aws-region: ap-northeast-1
          aws-role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/digger-gha-aws
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          AWS_CONFIG_FILE: ${{ github.workspace }}/.aws/config
```

OIDC認証するには、diggerhq/diggerアクションで `setup-aws: true` を指定し、`aws-region` と `aws-role-to-assume` を指定します。またpermissionsで `id-token: write` を許可することでIDトークンが発行されます。

GitHub ActionsからAWSにOIDC認証するには、AWS側であらかじめいくつか設定が必要です。最低限、 `aws_iam_openid_connect_provider` でGitHub ActionsをIDプロバイダとして登録し、IAMロールでIDトークン発行元のGitHubリポジトリを検証し `sts:AssumeRoleWithWebIdentity` を許可する必要があります。

サンプルコードでは `envs/ops/base/main.tf` と `modules/digger/gha_role/v1/main.tf` に実装されていますが、設定の意味を解説し始めるとそこそこ長くなる、かつDiggerの解説の本題からそれるので、ここでは詳細には立ち入らず割愛します。

diggerhq/diggerアクションの `aws-role-to-assume` で指定したIAMロールは、内部的に `aws-actions/configure-aws-credentials` アクション の `role-to-assume` に渡されています。`aws-actions/configure-aws-credentials` アクションを使ったことがある人ならたぶん意味は分かるでしょう。

逆にもしGitHub ActionsでOIDC認証を一度も設定したことがない人は、一旦シンプルなAWSアクセスキーでの認証で設定してから、あとでOIDC認証を完全理解してから戻ってきた方がよいかもしれません。GitHub ActionsからAWSへのOIDC認証の設定方法は、GitHub Actionsの公式ドキュメントが以下にありますし、Web上にも既に多くの解説があるので、適当にググって下さい。
https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services

DiggerでGitHub ActionsからOIDC認証するIAMロールに必要となる権限を分類すると、tfplanファイルを保存するのにS3への読み書き、tfstateをs3バックエンドに保存するのにS3とDyanamoDBへの読み書き、AWSプロバイダを使ったリソースの読み書き、の3種類が必要です。1つのIAMロールにすべての権限を付与することもできますが、サンプルコードでは、AWSをマルチアカウントで運用することを想定し、IAMロールも以下のように分割しています。

- digger-gha-aws: GitHub ActionsからOIDC認証する用+tfplan用
- digger-tfstate-aws: tfstate用
- digger-provider-aws-ops/dev/prod: 各AWSアカウントのプロバイダ用

tfplanファイルの保存設定については後述しますが、tfplanの読み書き権限はdiggerコマンドから直接アクセスできる必要があるので、GitHub ActionsからOIDC認証する用に含めています。また、GitHub ActionsからOIDC認証したIAMロールから、各AWSアカウントのプロバイダ用やtfstate用のIAMロールにAssumeRoleできる必要があります。これはIAMロールの権限で許可しつつ、AWSのprofile参照で、 `credential_source=Environment` としてAssumeRoleすることで実現します。

これには、以下のような `.aws/config` を作成します。AWSアカウントIDは適宜読み替えて下さい。

```.aws/config
[profile minamijoyo-tfstate]
credential_source=Environment
role_arn=arn:aws:iam::xxxxxxxxxxxx:role/digger-tfstate-aws

[profile minamijoyo-ops]
credential_source=Environment
role_arn=arn:aws:iam::xxxxxxxxxxxx:role/digger-provider-aws-ops

[profile minamijoyo-dev]
credential_source=Environment
role_arn=arn:aws:iam::yyyyyyyyyyyy:role/digger-provider-aws-dev

[profile minamijoyo-prod]
credential_source=Environment
role_arn=arn:aws:iam::zzzzzzzzzzzz:role/digger-provider-aws-prod
```

AWSプロファイル設定はデフォルト `~/.aws/config` ですが、環境変数 `AWS_CONFIG_FILE` で変更可能です。diggerhq/diggerアクションに渡す環境変数で`AWS_CONFIG_FILE: ${{ github.workspace }}/.aws/config` に設定しておくと、リポジトリにコミットしたファイルを読み込んでくれます。

環境変数 `AWS_CONFIG_FILE` はaws-sdk-go-v2で追加されたので、Terraform/OpenTofu v1.6.0以降、Terraform provider aws v4.0.0以降、tfmigrate v0.4.0以降が必要です。

## tfplanファイルの保存

CI/CDでapplyする場合、レビューしたplanを確実にapplyするには、plan時点でtfplanファイルを保存して、applyではそのtfplanファイルを指定してapplyします。もし保存しないとapply -auto-approveとして処理されるので、レビュー時点と異なるplanをノールックapplyしてしまう可能性があり危険です。

Diggerはデフォルトではtfplanファイルを保存しませんが、設定でGitHub Artifacts, S3 (AWS)、GCS (GCP)に保存することができます。
https://docs.digger.dev/ce/howto/store-plans-in-a-bucket

GitHub Artifactsに保存するのが一番お手軽だったのですが、本稿執筆時点では、オススメできなくなってしまいました。というのも、内部実装が非推奨な `actions/upload-artifact@v3` 相当の非公開APIに依存する実装になっており、このAPIは近日中に廃止予定です。もともと2024年11月末廃止予定という話でしたが、2025年1月末まで延命されています。Diggerの実装は、単純にactions/upload-artifactの公式アクションを呼び出しているのではなく、公式アクションが使っているnpmモジュールの実装をリバースエンジニアリングしてGoで再実装しており、現行のv4相当に移行するのが簡単ではなさそうです。

https://github.com/diggerhq/digger/issues/1702

というわけで、この問題が解決するまでは、tfplanファイルの保存先は現状S3 or GCSのどちらかになるでしょう。tfstateをS3に置くのであれば、いずれにせよAWS認証は必要なので、tfplan用のS3バケットを作って置くのはそれほど手間ではありません。

設定自体は、diggerhq/diggerアクションで `upload-plan-destination: aws` を指定し、`upload-plan-destination-s3-bucket` にtfplanの保存先のバケット名を指定するだけです。

```.github/workflows/digger_workflow.yml
name: Digger Workflow

on:
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize ]
    paths:
      - 'envs/**'
  issue_comment:
    types: [created]

concurrency:
  group: digger-run

jobs:
  digger-job:
    if: ${{ github.event_name == 'pull_request' || github.event_name == 'issue_comment' && startsWith(github.event.comment.body, 'digger') }}
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write      # required to merge PRs
      id-token: write      # required for workload-identity-federation
      pull-requests: write # required to post PR comments
      issues: read         # required to check if PR number is an issue or not
      statuses: write      # required to validate combined PR status

    steps:
      - uses: actions/checkout@v4
        with:
          ref: refs/pull/${{ github.event.issue.number }}/merge
        if: ${{ github.event_name == 'issue_comment' }}
      - uses: actions/checkout@v4
        if: ${{ github.event_name != 'issue_comment' }}
      - id: opentofu_version
        run: echo "value=$(sed -n 's/^opentofu \(.*\)$/\1/p' .tool-versions)" >> $GITHUB_OUTPUT
      - uses: diggerhq/digger@v0.6.79
        with:
          no-backend: true
          disable-locking: true
          cache-dependencies: true
          configure-checkout: false
          setup-opentofu: true
          opentofu-version: ${{ steps.opentofu_version.outputs.value }}
          setup-aws: true
          aws-region: ap-northeast-1
          aws-role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/digger-gha-aws
          upload-plan-destination: aws
          upload-plan-destination-s3-bucket: minamijoyo-digger-tfplan-aws
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          AWS_CONFIG_FILE: ${{ github.workspace }}/.aws/config
```

tfplanファイルを長期で保存する必要がなければ、S3のライフサイクルルールで古いファイルを定期的にゴミ消しできるように、tfstateと別のバケットにした方がよいでしょう。

ここまで設定すると、一通りAWSでplan/applyができるようになります。面倒なのは実質AWS認証のためのIAM権限の調整などで、これはどのようなツールでCI/CDを実装しても何某か必要になる部分です。Digger自体の設定は一度作ってしまえば、他のリポジトリにコピペするのは簡単です。

# tfmigrateの組み込み

最後に応用編として、Diggerのカスタムワークフローを使って、tfmigrateを組み込んでみます。カスタマイズ方法が分かれば、他のツールを組み込むこともできるようになるでしょう。

tfmigrateとは、tfstateの操作をDBマイグレーションのように、宣言的に管理するツールです。

https://github.com/minamijoyo/tfmigrate

tfmigrate planコマンドは、リモートのtfstateを書き換えることなく、一時的なtfstate上でterraform state mvなどの操作を行い、tfstate操作後のterraform planに差分がないことを検証します。問題なければ tfmigrate applyコマンドを実行し、変更をリモートのtfstateに反映できます。

Terraform v1.1で追加されたmovedブロックを使えば十分ではないかと思うかもしれませんが、tfmigrateは複数のディレクトリ間でリソースを移動することも可能です。これにより肥大化したtfstateを分割したり、ディレクトリ構造をリファクタリングすることが可能となります。

tfmigrateをCI/CDに組み込むことで、tfstate操作がレビュー可能となり、安全にリファクタリングができます。

tfmigrateをDiggerに組み込むには、以下の設定が必要です。

- tfmigrateの設定ファイルの作成
- tfmigrate用のdigger.ymlの作成
- 前処理スクリプトで、tfmigrateのインストールとdigger.ymlをtfmigrate用に差し替え
- GitHub Actionsのワークフローに組み込み

## tfmigrateの設定ファイルの作成

リポジトリルートに `.tfmigrate.hcl` を作成します。

```.tfmigrate.hcl
tfmigrate {
  migration_dir = "./tfmigrate"

  history {
    storage "s3" {
      region  = "ap-northeast-1"
      bucket  = "minamijoyo-digger-tfstate-aws"
      key     = "tfmigrate/history.json"
      profile = "minamijoyo-tfstate"
    }
  }
}
```

マイグレーションファイル用のディレクトリ `migration_dir` は `./tfmigrate` に指定しています。もしディレクトリ名を変更したい場合は、後述のdiggerの設定ファイルと前処理スクリプトも適宜読み替えて下さい。

マイグレーション履歴の保存先は、tfstateと同じs3バケットを流用しています。s3バケット名やAWSプロファイル名などは、適宜読み替えてください。

各設定項目の意味は、tfmigrateのドキュメントを参照して下さい。
https://github.com/minamijoyo/tfmigrate/tree/v0.4.1#configuration-file

## tfmigrate用のdigger.ymlの作成

tfmigrate用のDiggerの設定ファイルとして、リポジトリルートにdigger_tfmigrate.ymlを作成します。

```digger_tfmigrate.yml
telemetry: false
auto_merge: true
projects:
  - name: tfmigrate
    dir: tfmigrate
    opentofu: true
    workflow: tfmigrate
workflows:
  tfmigrate:
    plan:
      steps:
        - run: cd $(git rev-parse --show-toplevel) && tfmigrate plan 2>&1 | tee -a $DIGGER_OUT
        - run: touch dummy.tf
        - init
        - plan
    apply:
      steps:
        - run: cd $(git rev-parse --show-toplevel) && tfmigrate apply 2>&1 | tee -a $DIGGER_OUT
```

`tfmigrate` プロジェクトの `dir` は、先ほどの `.tfmigrate.hcl` の `migration_dir` と合わせて下さい。

Diggerはカスタムワークフローを定義して、処理をカスタマイズすることが可能です。ここでは `tfmigrate` という名前のワークフローを定義しています。

ちょっと複雑なことをしているので、順番に見ていきます。

まず、digger planが呼び出されたときに、`cd $(git rev-parse --show-toplevel)` でリポジトリルートに移動して、 `tfmigrate plan` コマンドを実行し、標準出力と標準エラー出力を `tee` コマンドで `$DIGGER_OUT` にも追記しています。`$DIGGER_OUT` はDiggerの組み込みの環境変数で、ここに書き込んだ内容は、Pull Requestコメントの`Additional output` の欄に表示されます。

次に、`touch dummy.tf` してから `init` と `plan` を実行するというトリッキーなことをしています。これは、Diggerでtfplanファイルを保存する設定をしている場合、planステップで有効なtfplanファイルを保存する必要があります。しかし `tfmigrate plan` コマンドはtfplanファイルを生成しません。そこで回避策として、tfmigrate/ディレクトリ内で、ダミーのtfplanファイルを生成しています。ちょっとしたハックですが、他によい方法が思いつきませんでした。

このようにダミーのtfplanファイルを使っている都合上、マイグレーションを含むPull Requestコメント上のdigger planの出力は、常にNo changesになってしまうという副作用があることは認識しておくべきでしょう。とは言え、tfmigrate planコマンドはplan差分がある場合はexitコード0以外を返し、その時点でカスタムワークフローのステップが異常終了します。つまりPull RequestのCIのステータスもエラーになります。tfmigrateで明示的にforceモードを有効化しない限り、マイグレーション結果にplan差分がある状態でapplyはできないので、まぁ許容可能かなと思ってます。

最後に、applyステップは単にtfmigrate planの代わりに、tfmigrate applyを実行しているだけです。

## 前処理スクリプトの作成

次に、前処理スクリプトとして、以下のような `.github/scripts/pre_workflow_hooks.sh` を作成します。このスクリプトは後ほど、GitHub Actionsのワークフローに組み込みます。

```.github/scripts/pre_workflow_hooks.sh
#!/bin/bash

set -euo pipefail
#
# pre_workflow_hooks.sh
#
# This script should be run before the digger is executed.
#
# This script does the following:
# - If a pull request includes a migration file, install tfmigrate and replace digger.yml.
#
echo "Start pre_workflow_hooks"

# The following variables are set as built-in variables in GitHub Actions.
echo "GITHUB_REPOSITORY: ${GITHUB_REPOSITORY:?"GITHUB_REPOSITORY is not set."}"
# The following variables must be passed as environment variables.
echo "PR_NUMBER: ${PR_NUMBER:?"PR_NUMBER is not set."}"

TFMIGRATE_VERSION=0.4.1
TFMIGRATE_DOWNLOAD_URL="https://github.com/minamijoyo/tfmigrate/releases/download/v${TFMIGRATE_VERSION}/tfmigrate_${TFMIGRATE_VERSION}_linux_amd64.tar.gz"

echo "Check migration files"
MIGRATION_FILES=$(
  gh api "/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate |
    jq -r '.[] | select((.status != "removed") and (.filename | startswith("tfmigrate/"))) | .filename'
)

if [[ -n "${MIGRATION_FILES}" ]] ; then
  echo "Install tfmigrate"
  curl -fsSL "$TFMIGRATE_DOWNLOAD_URL" \
    | tar -xzC /usr/local/bin && chmod +x /usr/local/bin/tfmigrate

  echo "tfmigrate version: $(tfmigrate --version)"

  cp digger_tfmigrate.yml digger.yml
else
  echo "Skip migration"
fi

echo "End pre_workflow_hooks"
```

このスクリプトは、Pull Requestにマイグレーションファイルが含まれる場合は、tfmigrateをインストールし、digger.ymlを差し替えています。

tfmigrateのインストールはビルド済みのバイナリをGitHubで配布しているので、ダウンロードしてきてPATHが通っている場所に置くだけです。ただPull Requestの差分にマイグレーションファイルが含まれない場合にも毎回インストールするのは無駄なので、差分ファイルをチェックして、関係ない場合はスルーしておくとよいでしょう。

また、tfmigrate用のdigger_tfmigrate.ymlをdigger.ymlに上書きすることで、tfmigrate用のワークフローを発動します。

## GitHub Actionsのワークフローに組み込み

最後に、前処理のスクリプトをGitHub Actionsのワークフローに組み込みます。

```.github/workflows/digger_workflow.yml
name: Digger Workflow

on:
  pull_request:
    branches: [ main ]
    types: [ opened, synchronize ]
    paths:
      - 'envs/**'
      - 'tfmigrate/**'
  issue_comment:
    types: [created]

concurrency:
  group: digger-run

jobs:
  digger-job:
    if: ${{ github.event_name == 'pull_request' || github.event_name == 'issue_comment' && startsWith(github.event.comment.body, 'digger') }}
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write      # required to merge PRs
      id-token: write      # required for workload-identity-federation
      pull-requests: write # required to post PR comments
      issues: read         # required to check if PR number is an issue or not
      statuses: write      # required to validate combined PR status

    steps:
      - uses: actions/checkout@v4
        with:
          ref: refs/pull/${{ github.event.issue.number }}/merge
        if: ${{ github.event_name == 'issue_comment' }}
      - uses: actions/checkout@v4
        if: ${{ github.event_name != 'issue_comment' }}
      - id: opentofu_version
        run: echo "value=$(sed -n 's/^opentofu \(.*\)$/\1/p' .tool-versions)" >> $GITHUB_OUTPUT
      - name: Run pre_workflow_hooks
        run: .github/scripts/pre_workflow_hooks.sh
        env:
          PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
          GH_TOKEN: ${{ github.token }}
      - uses: diggerhq/digger@v0.6.79
        with:
          no-backend: true
          disable-locking: true
          cache-dependencies: true
          configure-checkout: false
          setup-opentofu: true
          opentofu-version: ${{ steps.opentofu_version.outputs.value }}
          setup-aws: true
          aws-region: ap-northeast-1
          aws-role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/digger-gha-aws
          upload-plan-destination: aws
          upload-plan-destination-s3-bucket: minamijoyo-digger-tfplan-aws
        env:
          GITHUB_CONTEXT: ${{ toJson(github) }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          AWS_CONFIG_FILE: ${{ github.workspace }}/.aws/config
          TFMIGRATE_EXEC_PATH: tofu
```

ポイントは以下3箇所です。

まず、pull_requestイベントで拾うpathにtfmigrate/配下も含めます。

次に、diggerのアクションを呼び出す前に、前処理のスクリプトを差し込みます。Pull Request作成時とissueにコメントした場合で、GitHub Actionsのイベントが異なるので、Pull Request番号の取得方法の差分を吸収する必要があることに注意して下さい。

最後に、diggerアクションを呼び出す際に、環境変数 `TFMIGRATE_EXEC_PATH` を `tofu` にセットしておきます。これはtfmigrateでOpenTofuを使うのに必要です。

以上でtfmigrateの組み込みが完了です。この状態でマイグレーションファイルを含むPull Requestを作成すると、tfmigrate planが実行され、digger applyとコメントすると、tfmigrate applyが実行されます。tfstate操作もCI/CDに組み込めるとリファクタリングが捗りますね。

# おわりに

サーバレスでAtlantis風のApply-Before-MergeなCI/CDが手軽に実現できるDiggerについて紹介しました。

使ってみると、自分はどうしてもAtlantisと比較してしまうので、細かい挙動で気になる箇所はあるものの、YAMLを100行も書いてないことを思い出せば、小規模な環境はもうこれでよくない？という気もしてきます。既にCI/CDが作り込まれてる環境からあえて移行するほどではないと思いますが、まだ未整備のところにシュッと入れるのは、アリよりのアリじゃないでしょうか。