tfmigrate + Atlantis でTerraformリファクタリング機能をCI/CDに組み込む

この記事は [クラウドワークス Advent Calendar 2021](https://qiita.com/advent-calendar/2021/crowdworks) の 6日目の記事です。

# はじめに

Terraform職人の @minamijoyo です。

Terraform職人のみなさんは「ぼくのかんがえたさいきょうのTerraformディレクトリ構造」の話題が大好きです。新規プロジェクトのディレクトリ構造を考えるのは楽しいですよねー。はい。一方、既存のプロジェクトのディレクトリ構造を後から変更するのは大変です。

例えば以下のような、簡単な例を考えてみましょう。 

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

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

既に `dir1` と `dir2` にそれぞれリソースがあります。その後、責務や変更頻度などを考えた結果、 `dir1` にある `foo1` を `dir2` に移動したいとします。

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

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

なにも考えずにこの変更を適用すると、一旦 `dir1`  で `foo1` を削除し、 `dir2` で `foo1` を再作成することになるでしょう。この例では説明を単純化するため `null_resource` になっていますが、実際にはこれがproduction環境のネットワークやデータベースだったりします。当然ですが、既存production環境のインフラは単純に壊して作り直すことができず、結果的にリソース定義であるtfファイルの修正だけではなく、インフラの状態記録ファイルであるtfstateも調整する必要があります。具体的には `terraform state mv` コマンドを使うことで、既存のリソースを再生成することなく辻褄を合わせることができます。

しかしながらチーム開発の場合、tfファイルはGitで管理し、tfstateはs3などリモートのクラウドストレージに保存してチーム内で共有しているでしょう。もしリファクタリングのため、terraform state系のコマンドでtfstateをその場で書き換えてしまうと、これはGitのデフォルトブランチのtfファイルの状態と乖離してしまうことを意味します。このタイミングで同僚が、別件の変更をマージし、 terraorm apply すると何が起きるでしょうか？運がよければエラーになるだけかもしれませんが、運が悪ければデータベースが吹っ飛ぶかもしれません。

つまりTerraformのリファクタリング作業にはtfstateの操作が不可欠であるにも関わらず、Gitを使ったPull Requestベースの開発と相性がよくありません。

私はこの問題を解決するため、 tfstateの操作をDBマイグレーションのように宣言的に管理するtfmigrateというツールを開発しています。

この記事では、tfmigrateをTerraform専用のCI/CDツールであるAtlantisに組み込むことで、tfstateの操作もGitにコミットして Pull Requestベースの開発フローに載せる方法を解説します。これにより、既存のTerraformプロジェクトであっても、チーム開発をブロックすることなく、ディレクトリ構造を簡単にリファクタリングできる環境を構築します。この記事ではAtlantisを例に解説しますが、tfmigrate自体はその他の汎用的なCI/CDサービスに組み込むことも可能です。

# 完成イメージ

最終的に何ができるようになるのか、先に完成イメージを貼っておきます。

例えば以下のようにtfファイルの変更と合わせて、マイグレーションファイルを含めたPull Requestを作成すると、

![diff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61152/f6c6d1d6-9de7-a8d6-d249-273a2c4fc45b.png)

CIで自動的にマイグレーション後のplan差分がないことが検証されます。
このタイミングでは一時的なtfstate上で検証が行われ、共有しているリモートのtfstateは変更されません。
（もしマイグレーション後のplan差分が検出されると、エラーとして差分が報告されます）

![plan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61152/d4dd70eb-a88c-941e-2eae-95600c641e32.png)

レビューして問題なければ、 `atlantis apply` とコメントすると、tfstateの変更がリモートに反映されます。

![apply.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61152/042992c2-a642-c29b-246b-8278b2b5d219.png)

サンプルコードは以下のリポジトリで公開しています。

- Atlantisサーバ設定のサンプル: https://github.com/minamijoyo/tfmigrate-atlantis-example
- Terraformのリポジトリのサンプル: https://github.com/minamijoyo/tfmigrate-atlantis-demo

またサンプルコードは、本稿執筆時点の最新版である、以下のバージョンの組み合わせで稼働確認しました。

- tfmigrate: v0.2.13
- Atlantis: v0.17.5
- Terraform: v1.0.11
- terraform-provider-null: v3.1.0

# tfmigrateとは

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

https://github.com/minamijoyo/tfmigrate

先ほどの例で言うと、以下のようなマイグレーションファイルを `mv_foo1.hcl` として保存し、

```mv_foo1.hcl
migration "multi_state" "foo1" {
  from_dir = "dir1"
  to_dir   = "dir2"
  actions  = [
    "mv null_resource.foo1 null_resource.foo1"
  ]
}
```

`tfmigrate plan mv_foo1.hcl` コマンドで一時的なtfstate上でstate mv後のplan差分がないことを検証し、問題なければ `tfmigrate apply mv_foo1.hcl` で変更をリモートのtfstateに反映できます。

これにより肥大化したtfstateを分割したり、リソースを別のディレクトリに移動したりして、ディレクトリ構造をリファクタリングすることが可能です。

また、tfmigrate自体はリファクタリング専用というわけではなく、この記事で解説するような `state mv` 以外にも `state rm` や `import` などにも使えます。

シンプルですがstate操作がテキストファイルとして管理できることに意味があり、つまりGitにコミットできるので、CI/CDで実行可能です。

日本語の解説記事は、1年前で若干古いですがこちらをどうぞ↓ 
[Terraformのstate操作をgitにコミットしたくてtfmigrateというツールを書いた](https://qiita.com/minamijoyo/items/670eb100552b0834dd5e)

上記の記事では、履歴管理機能は絶賛開発中と書いてありますが、その後、履歴管理機能もv0.2系から実装されています。例えば設定ファイル `.tfmigrate.hcl` に以下のような historyの設定を追加することで、どのマイグレーションまで適用したかをs3などのストレージに保存できます。

https://github.com/minamijoyo/tfmigrate#history-block

```.tfmigrate.hcl
tfmigrate {
  migration_dir = "./tfmigrate"
  history {
    storage "s3" {
      bucket = "tfstate-test"
      key    = "tfmigrate/history.json"
      region = "ap-northeast-1"
    }
  }
}
```

マイグレーションの履歴を記録することで、マイグレーションファイル名を省略して、単に `tfmigrate plan` や `tfmigrate apply` とするだけで、未適用のマイグレーションを適用することが可能になり、よりCI/CDで使いやすくなりました。また履歴モードが有効な場合、 `tfmigrate list --status=unapplied` で未適用のマイグレーションファイルを列挙することも可能です。

いまのところ `storage` の種類は `s3` と `local` しか使えませんが、`s3` 以外のクラウドストレージに保存したい場合は、現状の回避策として `local` ストレージを使用して一旦ローカルファイルとして保存して、`tfmigrate plan / apply` の前後で履歴ファイルを自前で同期することは可能です。最新の状況は上記のREADMEを参照して下さい。

tfmigrate自体は最初からCI/CDに組み込むことを想定して設計しており、GitHub ActionsやCircleCIなど任意のワークフローが組める汎用的なCI/CDに組み込むのはそれほど難しくはないと思いますが、Atlantisへの組み込み方は自明ではない気がするので、この記事ではAtlantisへの組み込み方法を解説します。なんでAtlantisの解説をするかというと、私がAtlantis推しだからですね。

# Atlantisとは

Atlantisとは、Terraform専用のCI/CDツールです。Pull Request上でbotにコメントすることで、terraform planしたり、terraform applyしたりできます。

https://github.com/runatlantis/atlantis

これはTerraform CloudのようなSaaSではなく、OSSなので自前でサーバを立てる必要がありますが、汎用のCI/CDツールとは異なりTerraform専用なので、ビルトインで多くの人が必要な機能は最初から実装されています。カスタマイズが必要な場合も、設定ファイルやカスタムのスクリプトを差し込んで拡張が可能です。

Atlantisは本稿執筆時点でGitHubのスター数は既に4000を超えており、海外では人気の選択肢の1つですが、まだまだ日本語の解説記事は少なく、Atlantisの布教活動のために（？）、なぜAtlantisなのか？Atlantisのおすすめポイントについても、ここであわせて紹介しておきます。

例えば、TerraformのCI/CDを作るとなると、ミニマムはPull RueqestのCIでterraform planし、マージしてデフォルトブランチのCDでterraform applyというかんじになるでしょう。十分にシンプルな構成であればそれでよいかもしれません。

ただ管理するインフラリソースが増え、ディレクトリの数が増えてくるとplanの時間が遅いのが気になってきて、差分のあったディレクトリだけplanしたくなったり、レビューしやすいようにplanの結果をGitHubのPull Requestに自動でコメントしたくなったり、統制のためapproveされたらapply可能というような承認機能を作ったりしたくなるのは想像に難くありません。

このようなよくある「いい感じのワークフロー」を汎用的なCI/CDツールで実装しようとすると、何がしかの作り込みが必要ですが、Atlantisではこのようなよくあるユースケースはビルトインで既に誰かが実装してくれているので、自前で実装する必要はありません。

複雑化したCI/CDの作り込みは往々にして、最初に作った人しかいじれないということがありがちですが、自前で作り込むぐらいなら、OSSとして公式のドキュメントが整備されており、社外のコミュニティに相談先がある方がマシではないでしょうか？というのが私の主張ですが、まぁ異論は認める。

サーバを立てないといけないというのが最大のネックで、簡単とはいいませんが、Terraformをいじってるようなインフラが得意な人なら、サーバを1つ立てるぐらいなら勝手知ったる得意分野というかんじでしょう。逆にインフラをアプリケーションの片手間に管理しているような人には、正直Atlantisは玄人向けのツールで、過剰であるのは否めません。ただこの記事を読んでいるような、Terraform職人のみなさんには、ぜひAtlantisを推していきたいお気持ちです。

メリデメの裏返しですが、自前でサーバを立てるので、強力なAWSなどのクラウドプロバイダのクレデンシャルを外に出したくない場合には有力な選択肢の1つとなるでしょう。というのもTerraformはその特性上、インフラ構成変更が可能な管理者権限を持ったクレデンシャルで実行されることが多く、外部のSaaSに強力なクレデンシャルを渡したくないというのはTerraformのCI/CDを検討する上で気になるポイントではないでしょうか。Terraform Cloudや汎用CI/CDサービスにもSelf-hosted Runnerのような仕組みはありますが、結局Runnerを自分で管理するぐらいなら、もうAtlantisのサーバ立てればよくない？？

ところで最近GitHub ActionsがOIDCプロバイダとして使えるようになったのをご存知の方も多いかもしれません。これによりGitHub Actionsに、例えばAWSなどのクラウドプロバイダのアクセスキーを埋めることなく、ロールベースで認証ができるようになりました。やったね。

https://github.blog/changelog/2021-10-27-github-actions-secure-cloud-deployments-with-openid-connect/

じゃあやっぱりGitHub Actionsでよくない？という声が聞こえてきそうです。正直なところ小規模な構成ならそれでも十分な気はしております。ただ、非常に強力な権限を渡す場合、CIの設定を変更できる人は実質的にそのAWSの権限で任意のコマンドを実行可能であるという意味を理解した上で、結局は誰をどこまで信じるかという話ではあるので、用法用量を守って正しくお使い下さいという認識です。

最後に、そして一番のおすすめポイントですが、Atlantisのよさはワークフローにあります。AtlantisはPull Request上で作業ブランチからapplyするというちょっと特殊なワークフローを採用しており、これが絶妙なバランスです。普通にCI/CDを組むと、作業ブランチでterraform planして、マージしたらデフォルトブランチでterraform applyというワークフローになると思います。Pull Request上で作業ブランチからterraform applyできると何がうれしいのでしょうか？

これは体感してみないとなかなかその恩恵を理解しづらいのですが、作業ブランチからapplyすることで、まずapplyのログがPull Request上に残ります。さらにapplyに失敗した場合、マージ前に試行錯誤ができ、試行錯誤のログも含めて、だれが、いつ、なぜ、なんの変更をして、結果がどうだったのかが1つのPull Request上に残ります。terraform planは通るけど、マージしたらapplyに失敗したというのはTerraformあるあるですが、Atlantisならマージ前に試行錯誤することが可能で、ちゃんとGitHub側でブランチ保護を設定すれば再apply前に再レビューを要求することも可能です。

作業ブランチからapplyすると聞くと、デグレが気になるかもしれません。Atlantisは作業ディレクトリ単位で独自のロック状態を管理しており、同じディレクトリを同時にいじるPull Requestがあるとロックエラーで落とすようになっています。このロック機構のおかげで、apply後に自動でブランチをマージする設定にしておけば、マージの直前にapplyするのと、マージの直後にapplyするのは実質的にほぼ違いがありません。

Pull Request上で作業ブランチからapplyするのはなんだか最初抵抗感があると思いますが、実際にやってみると快適で非常に体験がよいです。Pull Request上ですべて完結するので画面遷移が少ないというのも体験のよさのポイントでしょう。terraform applyの特性だけ考えると、この絶妙なバランスが理想と現実の最適解なかんじがしております。

もちろんこのようなディレクトリのロックの仕組みも理論上は汎用的なGitHub Actionsでも実装できるかもしれませんが、それを自分で再実装したいかというと、もうAtlantis使えばいいじゃん？？？

# Atlantisサーバの構築

前置きが長くなりましたが、そろそろみんなAtlantisを使いたくなってきたと思うので、Atlantisサーバを立てましょう。

立てましょうと言いましたが、AtlantisはGitHub以外のVCSプロバイダもサポートしており、またデプロイ先も自由なので、選択肢の組み合わせがいろいろありここでは網羅しきれません。詳細は公式ドキュメントをよく読みましょう。

https://www.runatlantis.io/docs/installation-guide.html

ここではGitHubを例に、雰囲気が伝わるようざっくりポイントだけ説明します。また私が普段AWSを使っていることもあり、説明の随所にAWSの例が出てきますが、もちろんAWS以外でもデプロイ可能なので、各自お使いのクラウドプロバイダーなどで適宜読み替えて下さい。

と言っても何もコードがないと、なるほどわからんというかんじになると思うので、Atlantisサーバの設定のサンプルコードを以下のリポジトリで公開しています。

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

このサンプル自体は、docker-composeでローカルで起動可能なようにしてあります。（※環境変数の設定は `.envrc.sample` 内の説明を参考にして下さい）

GitHubからのWebフックを手元で受け取るためにngrokを使っており、インターネットanyに公開するのはさすがに憚られるので、nginxでIPアドレスのアクセス制限をかけてあります。またtfstateの保存先としてs3のモックをlocalstackで作ってあり、後述のtfmigrateの組み込みも含んでいます。実際に手元で動かすことができるので、構成の一例として参考にしてみて下さい。

## botユーザの作成とトークン発行

AtlantisはPull Request上でコメントに反応するbotとして機能するので、GitHubに専用のbotユーザを作成し、Personal Access Tokenを発行します。ミニマムはrepoスコープのトークンがあれば十分です。

またbotユーザのPersonal Access Tokenとして発行する方法の他に、GitHub Appとして登録する方法もありますが、登録時のコールバックを受け取るために一旦Atlantisの起動が必要で、ちょっと試すには面倒です。

https://www.runatlantis.io/docs/access-credentials.html#git-host-access-credentials

とりあえず検証で試してみたいということであれば、自分のアクセストークンをそのまま使ってもいいですが、実運用に載せるなら専用のbotユーザを作るとよいでしょう。

※ 前述のdocker-composeのサンプルでは、repoスコープのトークンとは別に、admin:repo_hookスコープのトークンも払い出す必要があります。これは `update_webhook` という作業スクリプトの中で使用されているだけで、Atlantisそのもので必要なものではありません。ngrokの無料プランではエンドポイントのURLが固定できず、毎回変わってしまって、都度Webフックを手動で再登録しなおすのが面倒なので、このスクリプトにより再登録を自動化しています。 

## Webフックのシークレット生成

AtlantisはGitHub上のイベントをWebフックで受け取ります。関係ないリポジトリからの悪意のあるイベントを無視するため、Webフックにシークレットが設定できます。適当な長さの乱数を生成しておきましょう。これはあとでWebフックを登録するときに使います。

https://www.runatlantis.io/docs/webhook-secrets.html#generating-a-webhook-secret

## デプロイ先
デプロイ先もどこでもいいですが、例えばAWSのリソースを管理するなら、Fargateでコンテナとしてデプロイすると管理がラクかもしれません。ECS Fargateにデプロイする公開モジュールが以下にあります。そのまま使わなくてもどのようなリソースを作ればよいのか参考になるでしょう。

https://registry.terraform.io/modules/terraform-aws-modules/atlantis/aws/latest

Atlantisのサーバのプロセス自体はビルド済みのバイナリをダウンロードしてきて、適切なクレデンシャルを渡して起動するだけです。最低限GitHubなどからWebフックを受け取るWebサービスとして公開できればなんでも構いません。

バイナリはGitHubのリリースで配布されています。
https://github.com/runatlantis/atlantis/releases

公式のDockerイメージもあります。
https://hub.docker.com/r/runatlantis/atlantis/tags

WebフックをHTTPSでうけとるために、AWSならALB＋ACM+Route53なども必要ですが、各自の手に馴染んだ方法で構築するとよいでしょう。

### 補足: Webフックのアクセス制限

Webフックを受け取るためにインターネットに晒すのが若干抵抗がある人もいると思いますが、GitHubの場合は、Webフックを送信してくるIPアドレスレンジは公開されています。
https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-githubs-ip-addresses

普通にcurlでAPIからも取れますが、

```bash
$ curl -sSL https://api.github.com/meta | jq .hooks
[
  "192.30.252.0/22",
  "185.199.108.0/22",
  "140.82.112.0/20",
  "143.55.64.0/20",
  "2a0a:a440::/29",
  "2606:50c0::/32"
]
```

同じものは、公式のGitHubプロバイダの `github_ip_ranges ` データソースからも取得できるので、アクセス元のIPアドレスを絞りたい場合は、この値を使うとよいでしょう。
https://registry.terraform.io/providers/integrations/github/latest/docs/data-sources/ip_ranges#hooks

余談ですが、デプロイ先のネットワークがIPv6に対応しておらず、IPv6のアドレスを除外したかったのですが、いい感じに除外する方法が分からなくて、 雑に `:` が含まれる場合は除外するという小細工を使っています。

```.tf
data "github_ip_ranges" "meta" {}

locals {
  github_hooks_ip = [for ip in data.github_ip_ranges.meta.hooks : ip if length(regexall(":", ip)) == 0]
}
```

またAtlantisにはWebフックを受け取るエンドポイントの他に、Web管理画面があります。いまのところWeb管理画面からできることはロックの手動開放ぐらいで、applyはできません。最低限社内IPなどに制限しておけばそれほど気にならないかもですが、最近Basic認証を設定できるようになりました。ちゃんとやるなら間に [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) などで認証を挟むとよいかもしれません。

### 補足: 中間ファイル

Atlantisは状態を保存するのにDBなどは不要ですが、ロックやキャッシュなどの状態をファイルとして保持しています。これは `data-dir` の設定で場所を変更できますが、デフォルトは `~/.atlantis` になっています。
https://www.runatlantis.io/docs/server-configuration.html#flags

コンテナとしてデプロイする場合は、デプロイのたびに状態が吹っ飛ばないように、`data-dir` を変更して外部ボリュームなどに切り出しておくとよいでしょう。Fargateの場合はEFSなど永続化できる場所にマウントするとよいでしょう。

## Webフックの登録

tfファイルを管理しているリポジトリにWebフックを登録して、Atlantisサーバに向けます。事前に生成したWebフックのシークレットを設定して下さい。反応するイベントの種類はAtlantisのバージョンアップにより変わるかもしれませんので、ドキュメントの記載通り設定して下さい。

https://www.runatlantis.io/docs/configuring-webhooks.html#github-github-enterprise

※前述のdocker-composeのサンプルの場合、Webフックのエンドポイントはngrokで発行されます。初回のみngrokの起動ログから発行されたドメイン名を確認し、末尾に `/events` のパスを付与したURLを登録して下さい。（例: `https://xxxxx.ngrok.io/events`)

## プロバイダのクレデンシャル

たとえばAWSのリソースを管理するなら、AWSのクレデンシャルが必要です。Atlantisサーバのデプロイ先によりますが、IAMロールに適切な権限を付けておけばよいでしょう。AWSのマルチアカウントを1つのAtlantisサーバからapplyする場合は、 `/home/atlantis/.aws/config` にprofileの定義を書いておけば、tfファイル側のproviderブロックなどで参照できます。ECSタスクロールからAssumeRoleをchainさせる場合は、以下のように  `credential_source=EcsContainer` を設定しておきます。

```/home/atlantis/.aws/config
[profile prod]
credential_source=EcsContainer
role_arn=arn:aws:iam::11111:role/atlantis

[profile dev]
credential_source=EcsContainer
role_arn=arn:aws:iam::22222:role/atlantis
```

```.tf
provider "aws" {
  region  = "ap-northeast-1"
  profile = "dev"
}
```

# Atlantisサーバの設定

Atlantisは設定でいろいろカスタマイズできます。後方互換性を維持するためか、たとえばapply後に自動でPull Requestをマージする `automerge` など有用そうなオプションもデフォルトで無効化されてたりするので、一度設定項目一覧を眺めてみるとよいでしょう。

https://www.runatlantis.io/docs/configuring-atlantis.html#flags

それぞれの設定項目は説明し始めるとキリがないので、ここでは最低限知っておくべき設定の構造だけ説明しておきます。

Atlantisには以下3種類の設定ファイルがあります。

https://www.runatlantis.io/docs/configuring-atlantis.html

- サーバ設定 (config.yaml)
- サーバ側のリポジトリ共通設定（repos.yaml）
- リポジトリ設定(atlantis.yaml) ※ 各リポジトリに配置

※Atlantisの仕様としてファイル名が固定なのは `atlantis.yaml` だけで、 `config.yaml` と `repos.yaml` はサンプルコード上のファイル名ですが、ここでは説明が分かりやすいように記載しています。

またサーバ設定は以下3箇所で設定でき、以下の優先順位で解釈されます。

1. 起動時の引数
2. 環境変数
3. 設定ファイル

環境差分のないもののみ設定ファイルに記載し、環境差分は環境変数に設定するのがよいでしょう。
起動時の引数は設定ファイルのパスのみ指定して、基本的には未使用としておくのがわかりやすいでしょう。

またサーバ側で設定できるリポジトリ共通設定(repos.yaml)と、各リポジトリに配置するリポジトリ設定(atlantis.yaml)の両方で設定できる項目もありますが、セキュリティに関連する設定はサーバ側で強制し、リポジトリ側での上書きを禁止しておくとよいでしょう。

そもそもリポジトリ側に atlantis.yamlを配置する場合、plan対象ディレクトリ（プロジェクト）の自動検出機能が抑止されてしまうことに注意が必要です。atlantis.yamlを配置する場合、すべてのディレクトリをプロジェクトとして事前に定義する必要があり、大量のディレクトリがある場合はなんらかの仕組みで自動生成する必要があるでしょう。配置しない場合は、Pull Requestで変更したファイルから動的にプロジェクトが検出されます。

https://www.runatlantis.io/docs/autoplanning.html#example

## 補足: apply_requirements
ほとんどの設定はドキュメントを読めば大体わかると思いますが、apply可能な条件を制御する `apply_requirements` の挙動を理解するのは若干難しいので、補足しておきます。というのもこの機能を正しく使うには、リポジトリ側のブランチ保護の仕組みと組み合わせて理解する必要があるからです。

https://www.runatlantis.io/docs/apply-requirements.html#intro

特定の人やチームがレビューしないとapplyできないようにする、というよくあるユースケースを実現するには、 `approved` と `mergeable` を指定します。

- approved: (Pull Requestの作成者以外の誰かが) approveしていること
- mergeable: マージ可能（ステータスチェック・レビューなどのブランチ保護ルールをパスする）な状態であること

まず、GitHubではそのリポジトリにread権限をもつ人であればapproveできることに注意する必要があります。実質的に誰がその変更をapproveできるかを制御するには、GitHubでブランチ保護を有効化し、マージの条件を調整することで、mergeableによって制御する必要があります。

ただ開発者に作業ブランチをpushできるようにwrite権限を与えている場合、write権限によるapproveでは不十分かもしれません、特定の人やチームしかapproveできないようにするには、CODEOWNERによるapproveを強制する必要があります。

https://docs.github.com/en/github/administering-a-repository/about-protected-branches

また `apply_requirements` が満たされた場合、apply自体はread権限があれば誰でも実行可能です。applyを発動できる人自体も制限したいというissueもありますが、今のところできません。

https://github.com/runatlantis/atlantis/issues/308

ただ、ブランチ保護の設定で `dismiss_stale_reviews` を有効化することで、approve後にコードを変更すると再レビューを強制することはできますので、approveした変更しかapplyできないという制約は実現できます。

## 補足: ベースブランチの設定
前述の通り `apply_requirements` はブランチ保護の仕組みに大きく依存しているので、 `apply_requirements` を設定する場合は、反応するベースブランチを制限する `branch` の設定も有効化しましょう。

https://www.runatlantis.io/docs/server-side-repo-config.html#reference

そうしないと、Pull Requestのベースブランチをずらすことで、ブランチ保護を回避できてしまいます。

ちなみに `branch` の設定は正規表現で書きますが、 `main` と `master` ブランチがリポジトリにより混在している場合は、簡単に `main|master` とは書かず、リポジトリごとにデフォルトブランチを明示するようにしましょう。そうしないと、例えば `main` ブランチがデフォルトブランチとして保護されているリポジトリに、さらにブランチ保護されていない `master` ブランチを作成することで、ブランチ保護に依存したapply制限が回避できてしまうからです。Atlantisの設定ファイルのYAMLはアンカーとエイリアスを解釈するので、デフォルトの共通設定をしておき、一部のリポジトリだけ上書きするなどの書き方も可能です。

```repos.yaml
repos:
- &shared
  id: /.*/
  branch: /^main$/

- <<: *shared
  id: github.com/minamijoyo/tfmigrate-atlantis-demo
  branch: /^master$/
```

# tfmigrateをAtlantisに組み込み

ようやく本題です。

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

1. tfmigrateをインストール
2. tfmigrate用のカスタムワークフローの定義
3. pre_workflow_hooksでtfmigrate用のatlantis.yamlを動的に生成

## tfmigrateのインストール

tfmigrateのインストールはビルド済みのバイナリをGitHubにリリースしているので、ダウンロードしてきてPATHが通っている場所に置くだけです。例えばAtlantisの公式Dockerイメージ内にインストールするならこんなかんじです。

atlantis/Dockerfile

```Dockerfile
FROM runatlantis/atlantis:v0.17.5

# Install jq for parse JSON in custom scripts
RUN apk add --no-cache jq

# Install tfmigrate
ENV TFMIGRATE_VERSION 0.2.13
RUN curl -fsSL https://github.com/minamijoyo/tfmigrate/releases/download/v${TFMIGRATE_VERSION}/tfmigrate_${TFMIGRATE_VERSION}_linux_amd64.tar.gz \
  | tar -xzC /usr/local/bin && chmod +x /usr/local/bin/tfmigrate

# Create a mount point for data-dir in Atlantis configuration
RUN mkdir -p /var/lib/atlantis && chown atlantis:atlantis /var/lib/atlantis

# Copy configuration
RUN mkdir -p /etc/atlantis
COPY . /etc/atlantis/

# Add custom scripts to $PATH
ENV PATH $PATH:/etc/atlantis/hooks/
```

## tfmigrate用のカスタムワークフローの定義

Atlantisはカスタムワークフローという仕組みで、planやapplyフェーズの処理を独自にカスタマイズできます。

https://www.runatlantis.io/docs/custom-workflows.html

サーバ側とリポジトリ側どちらでも定義できますが、任意のコマンドを実行可能という特性上、普通はサーバ側で定義して、リポジトリ側での上書きは禁止しておくのがよいでしょう。

ここでは、tfmigrateという独自のワークフローを定義しておき、後述のpre_workflow_hooksでこれを利用します。

```atlantis/repos.yaml
# https://www.runatlantis.io/docs/server-side-repo-config.html
repos:
- id: /.*/
  branch: /^master$/
  # テストしやすいようにサンプルコードではapply条件チェックは無効化してます
  # apply_requirements: [approved, mergeable]

  # リポジトリ設定(atlantis.yaml)でワークフローの指定を許可する。
  # allowed_overridesでworkflowの上書きを許可しているのは、
  # tfmigrateを組み込むためにpre_workflow_hooksで動的にatlantis.yamlを生成しており、
  # tfmigrate用のworkflowに差し替えているので、指定する必要がある。
  # allow_custom_workflows=falseであれば、自由にワークフローが定義できるわけではなく、
  # サーバ側でallowed_workflowsで定義したワークフローしか利用できない。
  allowed_overrides: [workflow]
  allowed_workflows: [tfmigrate]
  allow_custom_workflows: false

  # pre_workflow_hooksでatlantis.yamlを生成して処理をカスタマイズする
  # https://www.runatlantis.io/docs/pre-workflow-hooks.html
  pre_workflow_hooks:
    - run: pre_workflow_hooks.sh

# https://www.runatlantis.io/docs/custom-workflows.html
workflows:
  # tfmigrate用のワークフロー
  # Pull Requestにマイグレーションファイルが含まれる場合、pre_workflow_hooks.shで
  # tfmigrate用のワークフローを利用したatlantis.yamlを動的に生成している。
  tfmigrate:
    plan:
      steps:
        # tfmigrateで実行するterraformコマンドを特定バージョンに差し替え
        - env:
            name: TFMIGRATE_EXEC_PATH
            command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"'
        # tfmigrateはリポジトリルートで実行する必要があることに注意
        # またAtlantisはapply対象のプロジェクトをplanファイルの存在有無から判定している。
        # しかしながらtfmigrate applyは実際にterraform applyしているわけではない。
        # atlantis applyした場合に、tfmigrate applyが発動するように
        # ダミーのplanファイルを生成しておく。
        - run: cd $(git rev-parse --show-toplevel) && tfmigrate plan && touch $PLANFILE
    apply:
      steps:
        - env:
            name: TFMIGRATE_EXEC_PATH
            command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"'
        - run: cd $(git rev-parse --show-toplevel) && tfmigrate apply
```

インラインでコメントを書いていますが、ポイントを補足しておくと、

まずplanフェーズで `tfmigrate plan` を実行しますが、複数のディレクトリを跨ったマイグレーションを考慮し、 リポジトリルートに移動してから実行しています。

またAtlantisはapplyステップの処理対象をplanファイルの存在有無から判断しているので、ダミーのplanファイルを生成することで、applyステップが発動するようにしています。

環境変数 `TFMIGRATE_EXEC_PATH` は `tfmigrate` が実行する `terraform` コマンドのパスです。これを差し替えているのは、Atlantisは `required_version` を解釈して適切な `terraform` コマンドのバージョンを切り替えていますが、実行ファイルは `terraform1.0.11` のようにバージョン番号のsuffixが付いたファイル名として保存されているので、バージョンを検出して差し替えるようにしています。

1点注意点として、この例ではすべてのディレクトリが同じTerraformバージョンを利用していることを仮定していますが、 `${ATLANTIS_TERRAFORM_VERSION}` には tfmigrateのワークフローが発動するディレクトリ、つまりマイグレーションファイルが保存されているディレクトリのTerraformバージョンがセットされます。もしリポジトリ内で複数のTerraformバージョンが混在している場合は、ラッパーのスクリプトを差し込むなどとして、なんらか動的にTerraformバージョンを切り替える必要があるでしょう。

applyフェーズもほぼ同様ですが、リポジトリルートで `tfmigrate apply` します。

## pre_workflow_hooksでtfmigrate用のatlantis.yamlを動的に生成

Atlantisはpre_workflow_hooksという仕組みで、ワークフローの起動直前に処理を差し込むことが可能です。このタイミングでリポジトリ側のatlantis.yamlを動的に生成することで、対象のプロジェクトやワークフローを切り替えたりできます。

https://www.runatlantis.io/docs/pre-workflow-hooks.html

ここでは `tfmigrate list --status=unapplied` で未適用のマイグレーションが残っている場合に、tfmigrateプロジェクトを生成し、先ほど定義したtfmigrateワークフローを呼び出すatlantis.yamlを動的に生成します。

```atlantis/hooks/pre_workflow_hooks.sh
#!/bin/bash

set -euo pipefail

# pre_workflow_hooksのカスタマイズ
# https://www.runatlantis.io/docs/pre-workflow-hooks.html
# このスクリプトはplan / applyのワークフローが発動したタイミングで
# git clone後にPull Requestのブランチのリポジトリルートで呼び出される。
# 標準出力は正常終了した場合は捨てられるが、
# 異常終了した場合はAtlantisのログにERRORレベルで出力される。
# このスクリプト内では exit 1 で異常終了しても、後続のplanなどは抑止されないことに注意。

echo "start pre_workflow_hooks"

# 未適用のマイグレーションが残っている場合、
# tfmigrate用のワークフローを利用したatlantis.yamlを動的に生成する。
# tfmigrate用のワークフローはサーバ側のリポジトリ共通設定（repos.yaml）で定義されている。
MIGRATION_FILES=$(tfmigrate list --status=unapplied)
echo "MIGRATION_FILES: $MIGRATION_FILES"

# 未適用のマイグレーションが残っている場合、tfmigrate用のatlantis.yamlを動的に生成する。
# この例ではマイグレーションファイルはtfmigrateディレクトリに保存されていると仮定しています。
# Atlantisは1ディレクトリ(厳密にいうと1ワークスペース)を1プロジェクトとして扱うが、
# tfmigrateはmulti_state mvで複数のディレクトリを触ることがあるので、
# 便宜上tfmigrateはtfmigrateディレクトリに紐づくプロジェクトとして扱う。
# atlantis.yamlを配置するとprojectsの自動検出が抑止されるので、
# 結果的にtfmigrateディレクトリ以外のautoplanは抑止される。
if [[ -n "${MIGRATION_FILES}" ]] ; then
  echo "generate an atlantis.yaml for tfmigrate"
  cat << EOF > atlantis.yaml
version: 3
projects:
- name: tfmigrate
  dir: tfmigrate
  autoplan:
    when_modified: ["*.hcl"]
    enabled: true
  workflow: tfmigrate
EOF
else
  echo "skip migration"
fi

echo "end pre_workflow_hooks"
```

ここもインラインでコメントしてありますが若干補足しておくと、 `tfmigrate list --status=unapplied` は未適用のマイグレーションファイルを列挙するコマンドです。これはtfmigrateの履歴モードが有効でないと使えませんが、前述の通り、現状  `s3` と `local` しか対応していません。もし履歴の保存にs3以外のクラウドストレージを使うには、 `local` ストレージを使って自前でhistoryを同期するという案があります。

他には、例えば以下のようにGitHubのAPIを叩けば、Pull Requestに含まれている差分ファイル名を取得できるので、これを判定条件に使うことも可能です。ただし履歴モードが無効な場合は、 `tfmigrate plan / apply` の引数にマイグレーションファイル名を渡す必要があることに注意して下さい。

```bash
MIGRATION_FILES=$(curl -sS \
  -H "Accept: application/vnd.github.v3+json" \
  -H "Authorization: token ${ATLANTIS_GH_TOKEN}" \
  "https://api.github.com/repos/${BASE_REPO_OWNER}/${BASE_REPO_NAME}/pulls/${PULL_NUM}/files?per_page=100" \
 | jq -r '.[] | select(.filename | startswith("tfmigrate/")) | .filename')
```

※厳密に言うとこのGitHubのAPIは100ファイル以上の差分がある場合はページングする必要があります。

https://docs.github.com/en/rest/reference/pulls#list-pull-requests-files

CIでgit差分ファイル検出をよく書いてる人なら、gitコマンドから差分ファイルが検出できるのでは？と思うかもしれませんが、AtlantisはPull Requestを作業ディレクトリ内にチェックアウトするときに、 `git clone --depth=1` しているのでHEAD以外の履歴がなく、gitの履歴からは判定できません。tfmigrateの履歴モードが使えない環境で、もし1 Pull Requestに含まれる差分が100ファイル以内であるという仮定をおいてよけば、上記のようなAPI呼び出しでも代用可能です。

# 完成

これでtfmigrateのマイグレーションファイルをPull Requestに含めると、自動でtfmigrate planが実行され、レビュー後にtfmigrate applyが実行できるようになりました。これでリファクタリング作業がし放題ですね。やったね。

完成イメージは最初に貼ったものと同じなので、ここでは割愛します。

# おわりに

この記事では、tfmigrateをTerraform専用のCI/CDツールであるAtlantisに組み込むことで、tfstateの操作もGitにコミットして Pull Requestベースの開発フローに載せる方法について説明しました。

既にAtlantisをお使いの方には簡単にtfmigrateが組み込めることが分かったのではないでしょうか。説明の都合上、Atlantisを使ったことがない人にも分かるように、Atlantisの説明を厚めに書いた結果、なぜだかAtlantis職人入門のようになってしまった感は否めませんが、tfmigrate自体はAtlantis以外の汎用的なCI/CDツールにも簡単に組み込めるので、ぜひ試してみて下さい。

また前述のとおり、tfmigrate自体はstate mv以外にもimportもできます。つまり既存リソースのimport作業もCI/CDに組み込むことができ、レガシーなインフラのコード化も捗ります。

ところで、現在開発が進められている次のTerraform 1.1では、`moved` ブロックというリファクタリング関連の機能が公式に追加される予定です。これにより `state mv` を宣言的にtfファイル内に定義できるようになります。

https://discuss.hashicorp.com/t/request-for-feedback-config-driven-refactoring/30730

これまさにtfmigrateでわ（？）と思った人もいるかもしれないので、ついでにここで軽く補足しておくと、これは現状tfmigrateがカバーするユースケースの一部しか対応していません。というのも、この機能は1つのtfstate内でのリソースアドレスを変更する機能です。つまりリソースアドレスのリネームやモジュールへの切り出しはできますが、複数のtfstateをまたいだリソースの移動はできず、またimportもできません。しかしながらリファクタリング機能が公式にサポートされるという意義は大きく、今後の展開は楽しみです。

将来的にplan可能なimportは公式でなんとかしてくれそうな予感がしておりますが、複数のディレクトリをまたぐリファクタリングはちょっと難しそうな気もします。というのも現在のTerraformのアーキテクチャは、単一のディレクトリで動作することを想定しているので、複数ディレクトリ対応がすぐに実装されるかというと個人的には懐疑的です。Terraform公式でサポートされる日が来るかもしれませんが、その日が来るまでは引き続きtfmigrateのようなツールが必要になるでしょう。

もし気に入ったらスター☆してくれると、今後の開発の励みになります |ω・`）ﾁﾗｯ

https://github.com/minamijoyo/tfmigrate