Help us understand the problem. What is going on with this article?

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 の2日目の記事です。

背景

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

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

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

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

Dependabot for Terraform

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

dependabot/dependabot-core#1176: Terraform 0.12 support

まぁ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 があるとして、

terraform {
  required_version = "0.12.15"
}

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

$ tfupdate terraform -v 0.12.16 main.tf

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

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 があるとして、

provider "aws" {
  version = "2.39.0"
}

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

$ tfupdate provider -v 2.40.0 aws main.tf

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

provider "aws" {
  version = "2.40.0"
}

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

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 があるとして、

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

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

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

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

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 からメタデータ取得したりは、将来的にできるようにしたいお気持ちではあります。
モジュールのソースコードが管理されている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

これを使って毎日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 配下の実装詳細を省略すると、全体的な構造はこうなっています。

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

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

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-.*/

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

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

jobs

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

  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_nameuser_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に埋めて使う例がいっぱい出てきます。

$ 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

  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タイトルを組み立てています。

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

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

            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を実行します。

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

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

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

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

                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

  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 を使っており、リポジトリルートに .terraform-version ファイルを置いています。

0.12.16

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

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

fmt

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

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

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

$ 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

$ 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 経由で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のジョブ実行時に必要なバージョンをインストールし直すようにしました。

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

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

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

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

#!/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

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

hashicorp/hcl2#130: hclwrite: Fix unquoted label to be parsed as *identifier

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

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

hashicorp/hcl@9d1235a5b4e8: hclwrite: Allow selecting blocks for updating

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

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

https://github.com/minamijoyo/tfupdate

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away