tfupdateでTerraform本体/プロバイダ/モジュールのバージョンアップを自動化する

# はじめに

Terraform職人あらためシニアHCLエンジニア（？）の @minamijoyo です。

最近はHCL(HashiCorp Configuration Language)のパーサで遊んでいます。Terraformの設定をパースしていいかんじに書き換えることができるようになると夢が広がりますよね？
そんな個人的な趣味の産物として、Terraform本体/プロバイダ/モジュールのバージョン制約をいいかんじに一括で書き換えてくれる tfupdate というツールを書いたので紹介します。

https://github.com/minamijoyo/tfupdate

これをCIとかジョブスケジューラで流せば、毎日最新版をチェックして、差分があれば自動でバージョンアップのPull Requestを生成したりできます。つまり、「ぼくのかんがえたさいきょうのTerraform版Dependabot」ができるのだ。

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

# 背景

仕事のコードは再現性が大事です。Terraformでは微妙なバージョン違いでplan差分が出ることもあるので、Terraform本体や依存するプロバイダのバージョンを固定する派です。
ただTerraform本体もまだ全然枯れてなくて頻繁にマイナーパッチがリリースされるし、毎日すごい勢いでAWSさんが新機能をリリースしたりするので、面白そうな新機能で遊ぼうと思ったらまずはバージョンを上げねば、ということもTerraformあるあるです。

一方、Terraformではうっかりやらかしたときのダメージを最小限に抑えるために、適当な粒度にディレクトリを分けてtfstateを分割するのもベストプラクティスです。細かく分けるとplanも速くなるし。ただこれの副作用として、バージョン制約がいろんなディレクトリに散らばりがちです。ディレクトリが50個ぐらいに分かれてると、ちょっとTerraformのバージョン上げてなにか新しいことを試そうと思ったときのバージョン制約を書き換えて回る心理的ハードルが高いです。作業自体はfind/xargs/sedでがんばればできなくもないんだけど、あんまりシェル芸をメンテしたくないし、アプリケーションのコードみたいに、TerraformもDependabotさんにいいかんじにバージョンを上げて欲しいお気持ち。

Dependabot is 何？という知らない人のために若干補足しておくと、[Dependabot](https://dependabot.com/)とは、gemとかnpmとかの依存ライブラリのバージョンアップ用のPull Requestを自動で作ってくれる君です。Gemfile.lockとかpackage.jsonとかを見て、最新版が出てたら勝手にPull Requestを投げつけてくるので、CHANGELOG見て、CIパスしてたらマージしてバージョンアップ完了みたいな素敵な開発フローになります。
バージョンアップの差分が小さければCHANGELOGやdiffを見るのも簡単だし、もしバグっても原因が特定しやすいし、常に依存が最新に保たれるし、いいことしかない。毎日TerraformいじってるとDependabotのTerraform版が欲しくなるのが人情です。

あれ、DependabotのTerraform版あるじゃん？

[Dependabot for Terraform](https://dependabot.com/terraform/)

というツッコミができる人は、逆に既に知ってると思うけど、これを書いてる2019/12/2現在、DependabotはTerraformモジュールしか対応してなくて、Terraform本体やプロバイダには対応していません。むしろ欲しいの本体とプロバイダじゃん？って思った人は私だけではないはず。。。さらにTerraform 0.12(HCL2)対応もされてなくて、まだちょっと実用レベルではなさそうです。

[dependabot/dependabot-core#1176: Terraform 0.12 support](https://github.com/dependabot/dependabot-core/issues/1176)

まぁDependabotのTerraform対応はまだアルファ版という位置づけなので、優先度が低いのも仕方ないかもしれません。

というわけで、なければ作ればいいじゃん？というかんじで作ったぞい。

# 作ったもの

https://github.com/minamijoyo/tfupdate

* Terraform本体/プロバイダ/モジュールのバージョン制約を更新
* 指定したディレクトリ配下を再帰的に一括更新
* GitHubのリリースタグから最新バージョンを取得
* Terraform 0.12+対応

これを例えばCircleCIのスケジュールジョブなどに仕込むと、毎日チェックして、最新版がリリースされたらバージョンアップ用のPull Requestを自動生成したりできます。

※本稿執筆時点のtfupdateはv0.3.0です。最新の状況についてはREADMEを参照して下さい。

# インストール

いくつかの方法でインストールできます。

macOSの場合は、簡単にHomebrew経由でインストールできるようにしてあります。

```
$ brew install minamijoyo/tfupdate/tfupdate
```

Linuxで動かしたい場合は、ここからビルド済みのバイナリをダウンロードするか
https://github.com/minamijoyo/tfupdate/releases

それ以外のOSの場合は、ソースコードからビルドして下さい。（Go 1.13以上が必要）

```
$ git clone https://github.com/minamijoyo/tfupdate
$ cd tfupdate/
$ make install
$ tfupdate --version
```

Dockerイメージも用意してあるので、手元の環境を汚さずにちょっと試してみたいとか、CIに組み込む場合はこちらをご利用下さい。

```
$ docker run -it --rm minamijoyo/tfupdate --version
```

# 使い方

READMEほぼそのままですが、簡単な使い方を説明しておきます。

とりあえず `--help` を付けるとヘルプが出ます。

```
$ tfupdate --help
Usage: tfupdate [--version] [--help] <command> [<args>]

Available commands are:
    module       Update version constraints for module
    provider     Update version constraints for provider
    release      Get release version information
    terraform    Update version constraints for terraform
```

サブコマンドに分かれてるので、それぞれ説明していきます。

## terraform

まずはTerraform本体のバージョン制約を更新したい場合について説明します。

```
$ tfupdate terraform --help
Usage: tfupdate terraform [options] <PATH>

Arguments
  PATH               A path of file or directory to update

Options:
  -v  --version      A new version constraint (default: latest)
                     If the version is omitted, the latest version is automatically checked and set.
  -r  --recursive    Check a directory recursively (default: false)
  -i  --ignore-path  A regular expression for path to ignore
                     If you want to ignore multiple directories, set the flag multiple times.
```

例えば以下のような `main.tf` があるとして、

```hcl
terraform {
  required_version = "0.12.15"
}
```

以下のようなコマンドを実行すると、Terraform本体のバージョン制約を指定のバージョンに更新できます。

```
$ tfupdate terraform -v 0.12.16 main.tf
```

以下のように更新されます。

```hcl
terraform {
  required_version = "0.12.16"
}
```

ファイルのパスの部分は、ファイル名の他にディレクトリも指定可能です。
指定のディレクトリ配下を再帰的に一括更新する場合は `-r (--recursive)` オプションを付けて下さい。

```
$ tfupdate terraform -v 0.12.16 -r ./
```

また、 `modules/` 配下は無視したいなど除外したいディレクトリがある場合は `-i (--ignore-path)` で正規表現が指定できます。
複数のディレクトリを無視したい場合は、いいかんじの正規表現を書くか、 単純に `-i` を複数回指定もできます。

```
$ tfupdate terraform -v 0.12.16 -i modules/ -r ./
```

バージョン番号を指定しない場合は、GitHubのリリースタグから自動的に最新版を確認し、バージョン番号をセットします。

```
$ tfupdate terraform -r ./
```

## provider

プロバイダを指定のバージョンに更新したい場合は、大体Terraform本体と同じですが、引数でプロバイダ名の指定が必要です。

```
$ tfupdate provider --help
Usage: tfupdate provider [options] <PROVIDER_NAME> <PATH>

Arguments
  PROVIDER_NAME      A name of provider (e.g. aws, google, azurerm)
  PATH               A path of file or directory to update

Options:
  -v  --version      A new version constraint (default: latest)
                     If the version is omitted, the latest version is automatically checked and set.
                     Getting the latest version automatically is supported only for official providers.
                     If you have an unofficial provider, use release latest command.
  -r  --recursive    Check a directory recursively (default: false)
  -i  --ignore-path  A regular expression for path to ignore
                     If you want to ignore multiple directories, set the flag multiple times.
```

例えば以下のような `main.tf` があるとして、

```hcl
provider "aws" {
  version = "2.39.0"
}
```

awsプロバイダのバージョンを更新したい場合は、以下のようなコマンドを実行します。

```
$ tfupdate provider -v 2.40.0 aws main.tf
```

以下のように更新されます。

```hcl
provider "aws" {
  version = "2.40.0"
}
```

providerブロック以外にも、以下のようなterraformブロック内の `required_providers` の指定にも対応しています。

```hcl
terraform {
  required_providers {
    aws = "2.39.0"
  }
}
```

バージョン番号を省略した場合の最新版の解決は、公式プロバイダのみサポートしています。
というのも実装上、暗黙的に
`https://github.com/terraform-providers/terraform-provider-<PROVIDER_NAME>`
を決め打ちして取得しています。
非公式プロバイダで使いたい場合は、後述の `release latest` コマンドで指定のGitHubリポジトリのリリースタグが取れるので、バージョン指定と組み合わせてご利用下さい。

## module

モジュールを指定のバージョンに更新したい場合は、これもproviderと大体同じですが、引数でモジュール名を指定します。

```
$ tfupdate module --help
Usage: tfupdate module [options] <MODULE_NAME> <PATH>

Arguments
  MODULE_NAME        A name of module
                     e.g.
                       terraform-aws-modules/vpc/aws
                       git::https://example.com/vpc.git
  PATH               A path of file or directory to update

Options:
  -v  --version      A new version constraint (required)
                     Automatic latest version resolution is not currently supported for modules.
  -r  --recursive    Check a directory recursively (default: false)
  -i  --ignore-path  A regular expression for path to ignore
                     If you want to ignore multiple directories, set the flag multiple times.
```

例えば以下のような `main.tf` があるとして、

```hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "2.20.0"
}
```

ここでいうモジュール名は source属性の `terraform-aws-modules/vpc/aws` の部分です。
vpcモジュールのバージョンを更新したい場合は、以下のようなコマンドを実行します。

```
$ tfupdate module -v 2.21.0 terraform-aws-modules/vpc/aws main.tf
```

以下のように更新されます。

```hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "2.21.0"
}
```

moduleブロック内のversion属性以外にも、以下のようなsource属性内の `ref` の指定にも対応しています。

```hcl
module "vpc" {
  source = "git::https://example.com/vpc.git?ref=v2.18.0"
}
```

この場合は、モジュール名としてクエリパラメータを含まない `git::https://example.com/vpc.git` を指定して下さい。

モジュールに関しては現状、バージョン番号を省略した場合の最新版の解決はサポートされていません。
実はmoduleブロックのsource属性っていろんな書き方ができて、これを正確に実装するのは、ちょっと簡単ではなさそうです。

https://www.terraform.io/docs/modules/sources.html

source属性だけ見ても、そのモジュール実装のソースコードが管理されているリポジトリの場所が自明ではないんですよね。
少なくとも [Terraform Registry](https://registry.terraform.io/) からメタデータ取得したりは、将来的にできるようにしたいお気持ちではあります。
モジュールのソースコードが管理されているGitHubのリポジトリが自明であれば、非公式プロバイダ同様に、後述の `release latest` コマンドで指定のGitHubリポジトリのリリースタグが取れるので、バージョン指定と組み合わせることは可能です。

## release

releaseコマンドはバージョン情報のメタデータを取得するための補助機能です。

```
$ tfupdate release --help
Usage: tfupdate release <subcommand> [options] [args]

  This command has subcommands for release version information.

Subcommands:
    latest    Get the latest release version from GitHub Release
```

現状  `release` コマンドの下には、 `latest` コマンドだけあります。
データソースは今のところGitHubのみ対応しています。

```
$ tfupdate release latest --help
Usage: tfupdate release latest [options] <REPOSITORY>

Arguments
  REPOSITORY         A path of the the GitHub repository
                     (e.g. terraform-providers/terraform-provider-aws)
```

こんなかんじで、GitHubのユーザ名/リポジトリ名を指定すると、
最新のリリースタグを出力します。

```
$ tfupdate release latest terraform-providers/terraform-provider-aws
2.40.0
```

# 定期的に実行する
更新すべきファイルがいっぱいあると、tfudpate単体をローカルで流すだけでも便利ですが、
これをCIとかジョブスケジューラで流せば、毎日最新版をチェックできてよさそうです。

cronでもなんでもいいんですが、CircleCIにはSchedule Jobsという機能でcronのようなことができます。
[Using Workflows to Schedule Jobs](https://circleci.com/docs/2.0/workflows/#scheduling-a-workflow)

これを使って毎日tfupdateを定期実行するサンプルを以下に用意しました。

https://github.com/minamijoyo/tfupdate-circleci-example

大体以下のようなことを実装しています。

- 毎日0時(UTC)にTerraform本体とAWSプロバイダの最新版をチェック
- 最新版に更新するPull Requestが既に出ていないか重複チェック
- まだPull Requestがない場合はtfupdateを実行
- 差分があればコミットしてPull Requestを作成

実装詳細は上記のリポジトリの `.circleci/config.yml` を参照して下さい。

やってること自体はそれほど難しくないので、CircleCI以外の他のCIやジョブスケジューラなりに移植するのはそれほど難しくないかなと思います。

CircleCIの設定の意味がわからない場合は、公式リファレンスを参照して下さい。
https://circleci.com/docs/2.0/configuration-reference/

ここでは、ポイントだけ簡単に解説しておきます。

本稿執筆時点のコードの断面はここにあります。
https://github.com/minamijoyo/tfupdate-circleci-example/blob/cd8e5561b7eabb25aa3cd024dfcf5b868c4bda45/.circleci/config.yml

## 全体構造
見通しがよいように `commands` 配下の実装詳細を省略すると、全体的な構造はこうなっています。

```yaml
version: 2.1
executors:
  tfupdate:
    docker:
      - image: minamijoyo/tfupdate:latest

commands:
  git_config:
    (略)

  tfupdate_terraform:
    (略)

  tfupdate_provider:
    (略)

jobs:
  tfupdate:
    executor: tfupdate
    steps:
      - checkout
      - run: tfupdate --version
      - git_config:
          user_name: 'tfupdate-circleci'
          user_email: 'tfupdate-circleci@example.com'
      - tfupdate_terraform
      - tfupdate_provider:
          provider_name: 'aws'

workflows:
  version: 2
  scheduled:
    jobs:
      - tfupdate
    triggers:
      - schedule:
          cron: "0 0 * * *"  # minute hour day month week (UTC)
          filters:
            branches:
              only:
                - master
  tfupdate_test:
    jobs:
      - tfupdate:
          filters:
            branches:
              only:
                - /tfupdate-.*/

```

それぞれの部品を解説していきます。

## executors

```yaml
executors:
  tfupdate:
    docker:
      - image: minamijoyo/tfupdate:latest
```

ジョブを実行するDockerイメージを指定しています。
この `minamijoyo/tfupdate` のDockerイメージはDocker Hubに公開してあります。

https://hub.docker.com/r/minamijoyo/tfupdate

Dockerfileはこちらです。

https://github.com/minamijoyo/tfupdate/blob/v0.3.0/Dockerfile

alpineベースですが、 `tfupdate` のバイナリ以外にもCIであると便利な `git` コマンドや、GitHubのPull Request作成するのに便利な `hub` コマンドなども入ってます。

このサンプルリポジトリはtfupdate自体のテスト的な意味合いもあり、Dockerイメージは `minamijoyo/tfupdate:latest` を使っていますが、これは最新のmasterブランチを自動ビルドしたものです。 `minamijoyo/tfupdate:0.3.0` などバージョンごとのタグもリリース時に自動で打ってるので、tfupdate自体にバージョンを固定したい場合は、Dockerイメージのタグを固定して下さい。

## workflows

```yaml
workflows:
  version: 2
  scheduled:
    jobs:
      - tfupdate
    triggers:
      - schedule:
          cron: "0 0 * * *"  # minute hour day month week (UTC)
          filters:
            branches:
              only:
                - master
  tfupdate_test:
    jobs:
      - tfupdate:
          filters:
            branches:
              only:
                - /tfupdate-.*/

```

`scheduled` と `tfupdate_test` の2種類のワークフローが定義されています。
`scheduled` の方はcron書式で毎日UTCで0時、つまりJSTで朝9時に `master` ブランチで、後述の `tfupdate` ジョブを実行するように指定しています。

`tfupdate_test` の方は、CI設定のテスト用にブランチ名が `tfupdate-` で始まる場合に、同じ `tfupdate` ジョブを実行するようになっており、デバッグ用です。

## jobs

```yaml
jobs:
  tfupdate:
    executor: tfupdate
    steps:
      - checkout
      - run: tfupdate --version
      - git_config:
          user_name: 'tfupdate-circleci'
          user_email: 'tfupdate-circleci@example.com'
      - tfupdate_terraform
      - tfupdate_provider:
          provider_name: 'aws'
```

`tfupdate` のジョブのメイン処理です。 `executor` で使用するDockerイメージとしてさきほど定義した `tfupdate` を指定しており、 `steps` 以降が実際のコマンドです。

`checkout` はCircleCIの組み込みコマンドでソースコードを取得します。
その後、デバッグ用に `run: tfupdate --version` のバージョンを表示しています。

`git_config` , `tfupdate_terraform`, `tfupdate_provider` は後述の独自に定義したコマンドです。
`git_config` にはユーザ名やメールアドレスを引数として渡していますので適宜読み替えて下さい。
`tfupdate_provider` にはここではプロバイダ名として `aws` を渡していますが、こちらもお使いのクラウドプロバイダなどに読み替えて下さい。

## commands
### git_config

```yaml
  git_config:
    description: Setup git config
    parameters:
      user_name:
        description: git user name
        type: string
      user_email:
        description: git user email
        type: string
      github_token:
        description: environment variable name for GitHub access token
        type: env_var_name
        default: GITHUB_TOKEN
    steps:
      - run:
          name: Setup git config
          command: |
            git config --local user.name << parameters.user_name >>
            git config --local user.email << parameters.user_email >>
            mkdir -p $HOME/.config
            echo "https://${<< parameters.github_token >>}:@github.com" > $HOME/.config/git-credential
            git config --local credential.helper "store --file=$HOME/.config/git-credential"
            git config --local url."https://github.com/".insteadOf 'git@github.com:'
```

バージョンアップのPull Requestを自動生成するには、git commitしたり、push先のリポジトリに書き込み権限が必要なので、GitHubのアクセストークンの設定などをしています。

`parameters` のところで、引数として必要な情報を受け取れるようにしてあります。
`user_name` と `user_email` はそのままです。
 `github_token` はデフォルトで環境変数 `GITHUB_TOKEN` から読み込むようにしてありますが、環境変数名は上書き可能です。

GitHubのアクセストークンは、そのリポジトリへの書き込み権限が必要です。
公開リポジトリの場合は `public_repo` 権限、非公開リポジトリの場合は `repo` 権限が必要です。
以下のURLからPersonal access tokensを払い出して下さい。
https://github.com/settings/tokens

払い出したトークンは Circle CIのプロジェクト設定の「Environment Variables」から `GITHUB_TOKEN` という名前で登録します。
公開リポジトリの場合は、fork先から環境変数が参照できないように、「Advanced Settings」から「Pass secrets to builds from forked pull requests」がOFFになっていることも念のため確認して下さい。
https://circleci.com/docs/2.0/oss/#pass-secrets-to-builds-from-forked-pull-requests

git pushするためのアクセストークンの設定方法について、
ググると以下のようにgit remoteのURLに埋めて使う例がいっぱい出てきます。

```bash
$ git remote set-url origin https://your_username:$GITHUB_TOKEN@github.com/owner/repo
```

この方法は正常時は問題ないのですが、ネットワークエラーなどでgit pushに失敗した場合に、エラーメッセージにトークンが表示されてしまうのでよろしくありません。
上記でやってるように `git config --local credential.helper` を使って設定するとよいかと思います。

このへんのトークンの扱いは若干煩雑なので、 CircleCIではなくGitHub Actionsとかを使うともっと楽にできそうな気はしますが、まだ試せていません。

### tfupdate_terraform

```yaml
  tfupdate_terraform:
    description: Update the terraform to latest
    parameters:
      args:
        description: Arguments for tfupdate command
        type: string
        default: "-r ./"
      base_branch:
        description: A base branch name to update
        type: string
        default: master
    steps:
      - run:
          name: Update terraform to latest
          command: |
            VERSION=$(tfupdate release latest hashicorp/terraform)
            UPDATE_MESSAGE="[tfupdate] Update terraform to v${VERSION}"
            if hub pr list -s "open" -f "%t: %U%n" | grep -F "$UPDATE_MESSAGE"; then
              echo "A pull request already exists"
            elif hub pr list -s "merged" -f "%t: %U%n" | grep -F "$UPDATE_MESSAGE"; then
              echo "A pull request is already merged"
            else
              git checkout -b update-terraform-to-v${VERSION} origin/<< parameters.base_branch >>
              tfupdate terraform -v ${VERSION} << parameters.args >>
              if git add . && git diff --cached --exit-code --quiet; then
                echo "No changes"
              else
                git commit -m "$UPDATE_MESSAGE"
                PULL_REQUEST_BODY="For details see: https://github.com/hashicorp/terraform/releases"
                git push origin HEAD && hub pull-request -m "$UPDATE_MESSAGE" -m "$PULL_REQUEST_BODY" -b << parameters.base_branch >>
              fi
            fi
```

最初に最新バージョンをチェックして、コミットメッセージ兼Pull Requestタイトルを組み立てています。

```bash
            VERSION=$(tfupdate release latest hashicorp/terraform)
            UPDATE_MESSAGE="[tfupdate] Update terraform to v${VERSION}"
```

次に重複したPull Requestが既に存在していないかをチェックしています。

```bash
            if hub pr list -s "open" -f "%t: %U%n" | grep -F "$UPDATE_MESSAGE"; then
              echo "A pull request already exists"
            elif hub pr list -s "merged" -f "%t: %U%n" | grep -F "$UPDATE_MESSAGE"; then
              echo "A pull request is already merged"
            else
```

重複していない場合は、ブランチを切って、tfupdateを実行します。

```bash
              git checkout -b update-terraform-to-v${VERSION} origin/<< parameters.base_branch >>
              tfupdate terraform -v ${VERSION} << parameters.args >>
```

tfupdateを流した結果、gitの差分がなければスルーします。

```bash
              if git add . && git diff --cached --exit-code --quiet; then
                echo "No changes"
              else
```

gitの差分があればコミットして、pushし、Pull Requestを生成します。

```bash
                git commit -m "$UPDATE_MESSAGE"
                PULL_REQUEST_BODY="For details see: https://github.com/hashicorp/terraform/releases"
                git push origin HEAD && hub pull-request -m "$UPDATE_MESSAGE" -m "$PULL_REQUEST_BODY" -b << parameters.base_branch >>
```

### tfupdate_provider

```yaml
  tfupdate_provider:
    description: Update a provider to latest
    parameters:
      args:
        description: Arguments for tfupdate command
        type: string
        default: "-r ./"
      base_branch:
        description: A base branch name to update
        type: string
        default: master
      provider_name:
        description: A name of provider
        type: string
    steps:
      - run:
          name: Update terraform-provider-<< parameters.provider_name >> to latest
          command: |
            VERSION=$(tfupdate release latest terraform-providers/terraform-provider-<< parameters.provider_name >>)
            UPDATE_MESSAGE="[tfupdate] Update terraform-provider-<< parameters.provider_name >> to v${VERSION}"
            if hub pr list -s "open" -f "%t: %U%n" | grep -F "$UPDATE_MESSAGE"; then
              echo "A pull request already exists"
            elif hub pr list -s "merged" -f "%t: %U%n" | grep -F "$UPDATE_MESSAGE"; then
              echo "A pull request is already merged"
            else
              git checkout -b update-terraform-provider-<< parameters.provider_name >>-to-v${VERSION} origin/<< parameters.base_branch >>
              tfupdate provider << parameters.provider_name >> -v ${VERSION} << parameters.args >>
              if git add . && git diff --cached --exit-code --quiet; then
                echo "No changes"
              else
                git commit -m "$UPDATE_MESSAGE"
                PULL_REQUEST_BODY="For details see: https://github.com/terraform-providers/terraform-provider-<< parameters.provider_name >>/releases"
                git push origin HEAD && hub pull-request -m "$UPDATE_MESSAGE" -m "$PULL_REQUEST_BODY" -b << parameters.base_branch >>
              fi
            fi
```

基本的にはTerraform本体と同じですが、プロバイダ名を引数で受け取れるようにしてあります。

# 実運用するための工夫

上記のサンプルはミニマム必要な部分ですが、実際に社内で運用しているものは、諸般の事情でもう少し複雑なことをしてます。
社内事情をいろいろ含むので、そのまますべてが当てはまるわけではないとは思いますが、ハマリポイントを共有しておきます。

## tfenv

ローカルでTerraform本体のバージョンを切り替えるのに [tfenv](https://github.com/tfutils/tfenv) を使っており、リポジトリルートに `.terraform-version` ファイルを置いています。

```.terraform-version
0.12.16
```

Terraformのバージョンを上げる場合は、このバージョンも更新する必要があります。

```bash
$ VERSION=$(tfupdate release latest hashicorp/terraform)
$ echo $VERSION > .terraform-version
```

## fmt

別途CircleCIで `terraform fmt` されているかをチェックしていたのですが、このチェックに使うTerraformのバージョンは `.circleci/config.yml` の中で `hashicorp/terraform` のDockerイメージでバージョン指定しており、このバージョン更新も必要でした。

```yaml
  terraform_fmt:
    docker:
      - image: hashicorp/terraform:0.12.16
```

ここで `sed` 使っちゃうの若干負けた気持ちになりますが、 `.circleci/config.yml` の1ファイル決め打ちなのでsedで書き換えました。

```bash
$ sed -i -E "s#hashicorp/terraform:[0-9]+(\.[0-9]+)*(-.*)*#hashicorp/terraform:$VERSION#" .circleci/config.yml
```

どうでもいいですが、CircleCIのジョブの中で、 `.circleci/config.yml` を書き換えるのは個人的にジワジワきますね。YAMLが自分自身を書き換えて進化し、遂には知能を持ち始める日も近い（違

またTerraform本体のバージョンアップをした場合に、fmtそのもののバグ修正などでfmt結果が変わることがあり、fmtのチェックに失敗することがありました。
対応として、Terraform本体のバージョンを上げる場合には、該当バージョンで `terraform fmt` も実行し、差分がある場合は、バージョンアップのPull Requestのコミットに混ぜてfmtのチェックが常にパスするようにしました。
ただtfupdateのDockerイメージの中に任意のバージョンの `terraform` コマンドを入れるのは原理的に無理なので、その場でワンライナーでインストールしてます。ちょっと無理矢理感w

```bash
$ wget -qO- https://releases.hashicorp.com/terraform/${VERSION}/terraform_${VERSION}_linux_amd64.zip \
  | unzip -d /bin - && chmod +x /bin/terraform
$ terraform version
$ terraform fmt -recursive
```

## plan

Pull Requestを作成した場合 `terraform plan` を自動で実行し、結果を [tfnotify](https://github.com/mercari/tfnotify) 経由でPull Requestに貼り付けるということをしているのですが、AWSの強い権限をCircleCIに与えるのに若干心理的な抵抗があって、この部分はAWS CodeBuild上で実行しています。Terraform本体やプロバイダがバージョンアップした場合は、バージョンアップ後のバージョンで `terraform plan` を実行して差分をチェックしたいですよね。プロバイダのバージョンアップは特に問題ありませんが、Terraform本体のバージョンアップの場合はどうしましょう？ここでも最新版の `terraform` コマンドが必要になります。

しかしながらAWS CodeBuildそのものの設定もTerraformで管理しており、こんなかんじでベースイメージに `hashicorp/terraform` のDockerイメージを使っています。

```main.tf
resource "aws_codebuild_project" "terraform_codebuild" {
  environment {
    type            = "LINUX_CONTAINER"
    compute_type    = "BUILD_GENERAL1_SMALL"
    image           = "hashicorp/terraform:0.12.16"
    privileged_mode = false
  }

  (略)
}
```

このバージョンを指定をsedで書き換えればよいのでしょうか？それも必要ですが、話はそれほど単純ではありません。
ここでバージョン指定を書き換えても、 `terraform apply` しないとCodeBuildに反映されないのです。でもCodeBuildだけバージョンアップを先にしないと他のplanが実行できないというような依存は作りたくない。
対応として、CodeBuild内でインストールされているTerraformのバージョンと必要なTerraformのバージョンを比較し、一致しない場合は、CodeBuildのジョブ実行時に必要なバージョンをインストールし直すようにしました。

```bash
INSTALLED_TERRAFORM_VERSION=`terraform --version | head -1 | sed -rn 's/^Terraform v(.+)$/\1/p'`

echo "Installed Terraform version: $INSTALLED_TERRAFORM_VERSION"
echo "Required Terraform version: $TERRAFORM_VERSION"

if [ "$INSTALLED_TERRAFORM_VERSION" = "$TERRAFORM_VERSION" ] ; then
  echo "Terraform v$TERRAFORM_VERSION is already installed"
else
  echo "Install Terraform v$TERRAFORM_VERSION"
  rm /bin/terraform
  wget -qO- https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip | unzip -d /bin - && chmod +x /bin/terraform
  terraform version
fi
```

ここで環境変数 `TERRAFORM_VERSION` は必要なTerraformのバージョンとして、CodeBuildの設定ファイル `buildspec.yml` で以下のように指定しておきます。

```buildspec.yml
version: 0.2

env:
  variables:
    TERRAFORM_VERSION: "0.12.16"
    (略)
```

`buildspec.yml` は `terraform apply` しなくても作業ブランチで変更した場合は、その設定でジョブが実行されます。
つまりこのファイルを、tfupdateでTerraform本体をバージョンアップするときにsedして書き換えれば、バージョンアップ用のブランチでは指定のバージョンの `terraform` コマンドを都度インストールしつつ、普段はベースイメージに入ってる `terraform` コマンドを使うことで都度インストールの無駄を省けるわけです。

```bash
$ sed -i -E "s/TERRAFORM_VERSION:\s*\"[0-9]+(\.[0-9]+)*(-.*)*\"/TERRAFORM_VERSION: \"$VERSION\"/" buildspec.yml
```

あとはすべてのtfstateをループしながらplanの差分があったり、新規の警告が増えた場合に、CIを落としてやればOKです。

社内のディレクトリ構造を決め打ちにしている箇所があるので、そのまま使えないと思いますが、何もないよりかはマシだと思うので参考までに貼っておきます。

```bash
#!/bin/bash

# tfstateファイルが存在するディレクトリ一覧
echo "backend s3 bucket: $BACKEND_S3_BUCKET"
ALL_TFSTATE_DIRS=$(find . -type f -name 'config.tf' | xargs grep -l $BACKEND_S3_BUCKET | xargs -I {} dirname {} | sed -e "s/.\///" )

echo "plan_dirs = ${ALL_TFSTATE_DIRS}"

# 変更があった場合でもすべてのディレクトリをplanしてtfnotifyで通知した上で最後にCIを落としたいので、
# なんらかの変更があったかを記録するフラグ
CHANGED=0

# 警告があった場合でもすべてのディレクトリをplanして最後にCIを落としたいので、
# なんらかの警告があったかを記録するフラグ
WARNING=0

# planを実行
for plan_dir in ${ALL_TFSTATE_DIRS}
do
  cd ${CODEBUILD_SRC_DIR}/${plan_dir}
  echo "plan ${plan_dir}"
  terraform init -input=false -no-color
  # -detailed-exitcode でplan差分があった場合のexitコードを区別しつつ、
  # tfnotifyで差分を通知するために -out でplan結果をファイルに保存する。
  # https://www.terraform.io/docs/commands/plan.html
  terraform plan -input=false -no-color -detailed-exitcode -out=terraform.tfplan | tee plan.log

  # teeしてるので$?ではなくplanのexitコードはPIPESTATUSをチェックする
  ret=${PIPESTATUS[0]}
  case $ret in
    0) echo "no changes" ;;
    1) echo "failed to plan"
       exit $ret ;;
    2) echo "plan succeeded, there is a diff"
       CHANGED=1
       # 差分をtfnotifyで通知する
       # tfnotify に terraform showに相当するサブコマンドがないが、試してみたところ
       # terraform showでplanfileを表示する場合はtfnotify planでパース可能。
       terraform show -no-color terraform.tfplan \
         | tfnotify --config ${CODEBUILD_SRC_DIR}/tfnotify.yml plan --message ${plan_dir} ;;
    *) echo "unexpected return status: $ret"
       exit $ret ;;
  esac

  # deprecatedな属性の警告を検知したいのでplanの標準出力をteeでファイルにも保存しチェックする。
  # 本来的にはこのようなチェックはCircleCI上でterraform validateでチェックしたいのだが、
  # terraform_remote_stateを使っているとterraform validateがAPIコールしてしまうバグがある。
  # see: https://github.com/hashicorp/terraform/issues/22163
  # 仕方がないのでplanのタイミングでついでにチェックする。
  # grepの条件がこれで過不足ないのか不明なので、取りこぼしや誤検知があれば適宜調整して下さい。
  if grep -i 'warning' plan.log ; then
    echo "detect warnings"
    WARNING=1
  else
    echo "no warnings"
  fi
done

# 最終的に CHANGED or WARNING が0以外の場合はCIで落とす
if [ $CHANGED -ne 0 ] || [ $WARNING -ne 0 ] ; then
  exit 1
fi
```

plan差分や警告の有無までCIでチェックしておくと、圧倒的な安心感があります。

補足として、CodeBuildのベースイメージとして使っている `hashicorp/terraform` のバージョン変更は `aws_codebuild_project` リソースの変更を伴うため、 必ず `terraform plan` の差分となってしまいます。対応として、通常のTerraform本体のバージョンアップとはブランチを分けてPull Requestを分けるようにしました。このあたりはブランチ名のネーミングルールなどで、planの差分を許容するかどうかに応じて、別途CodeBuild内で呼び出す処理を変えたりして、本来plan差分がないはずの部分で意図しない差分が発生していないかを簡単に区別できるようにしています。

# まとめ

Terraform版のDependabotが欲しくてtfupdateというツールを書きました。
tfupdateでTerraformのバージョンアップを自動化することで、

1. 最新版が出てたら勝手にPull Requestを投げつけてくる（自動）
2. CHANGELOG見て、CIパスしてたらマージ（開発者）

=> 最新版にバージョンアップ完了。

という控えめに言って最高の開発体験がTerraformの世界でも実現できました。

まぁここまで最初から完全な自動化を作り込まなくても、
とりあえずミニマムは手元のPCで複数ファイルを一括で書き換えるだけでも十分便利なので、
Terraform本体/プロバイダ/モジュールのバージョンアップだるいなーと思ったことある人は試してみてね。

# 余談

tfupdateのアイデアを思いついた当初、HCLのパーサの実装を読んでたら hclwriteというHCLを編集するライブラリが生えていることに気づきました。で、さっそく簡単なPoC (Proof of concept)のコードを書いてみたらすぐに、必要な機能が足りないことに気づきました。詳細は割愛しますが、Terraform本体はHCL読むだけで書く必要ほとんどないので(fmt/0.12upgrade除く)、意外とそのへんまだ未成熟で実験的なかんじの実装になってます。そんなわけでtfupdateの開発はHCLに足りないAPIを生やすところから始まったのでした。そんなことを言うとなんかすごいことをしてる風ですが、パズルの最後のピースが足りてなかったのを埋めただけです。

[hashicorp/hcl2#126: hclwrite: Allow body to set attributes in blocks](https://github.com/hashicorp/hcl2/pull/126)

で、変更箇所にテストを書いたらテストが通らず、いろいろ調べた結果、既存の実装のバグに気づき追加で治しました。

[hashicorp/hcl2#130: hclwrite: Fix unquoted label to be parsed as *identifier](https://github.com/hashicorp/hcl2/pull/130)

で、そんなことをしているうちに、
 `hashicorp/hcl2` リポジトリの `hashicorp/hcl` の統合作業が始まってしまい、hcl2リポジトリはコードフリーズされてしまいました。

どうしたものかというかんじでしばらく一人でヤキモキしましたが、結果的に最初に出した#126のPull Requestは残念ながらGitHub上でクローズされてしまったものの、スカッシュしてまとめたコミットだけ `hashicorp/hcl` の `hcl2` ブランチに取り込んでもらい、無事にv2.0.0リリースに取り込んでもらえました。どや。

[hashicorp/hcl@9d1235a5b4e8: hclwrite: Allow selecting blocks for updating](https://github.com/hashicorp/hcl/commit/9d1235a5b4e8a8c86f8c7a5a6298d701605a7f12)

わーい、欲しかったAPIが生えた。

そんなところから始まったtfupdateのプロジェクトですが、今後の展望というか、このプロジェクトの最終的な野望は、本物のDependabotのTerraform対応の実装を差し替えることなのです。みんなCIの設定書くのだるいし、よしなにして欲しいし、人類を平和にしたい。
まぁそもそもDependabotの開発チームに受け入れてもらえるかどうかはまた別の話なので、何も約束はできませんが。彼らの中でTerraform対応の優先度低そうだし、途中で飽きて気が変わったらごめんね。気に入ったらスターしてくれると、今後の開発の励みになります |ω・`）ﾁﾗｯ

https://github.com/minamijoyo/tfupdate