はじめに
Terraform職人あらためシニアHCLエンジニア(?)の @minamijoyo です。
最近はHCL(HashiCorp Configuration Language)のパーサで遊んでいます。Terraformの設定をパースしていいかんじに書き換えることができるようになると夢が広がりますよね?
そんな個人的な趣味の産物として、Terraform本体/プロバイダ/モジュールのバージョン制約をいいかんじに一括で書き換えてくれる 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版あるじゃん?
というツッコミができる人は、逆に既に知ってると思うけど、これを書いてる2019/12/2現在、DependabotはTerraformモジュールしか対応してなくて、Terraform本体やプロバイダには対応していません。むしろ欲しいの本体とプロバイダじゃん?って思った人は私だけではないはず。。。さらにTerraform 0.12(HCL2)対応もされてなくて、まだちょっと実用レベルではなさそうです。
dependabot/dependabot-core#1176: Terraform 0.12 support
まぁDependabotのTerraform対応はまだアルファ版という位置づけなので、優先度が低いのも仕方ないかもしれません。
というわけで、なければ作ればいいじゃん?というかんじで作ったぞい。
作ったもの
- 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属性っていろんな書き方ができて、これを正確に実装するのは、ちょっと簡単ではなさそうです。
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を定期実行するサンプルを以下に用意しました。
大体以下のようなことを実装しています。
- 毎日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に公開してあります。
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-.*/
scheduled
と tfupdate_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_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に埋めて使う例がいっぱい出てきます。
$ 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イメージを使っています。
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
で以下のように指定しておきます。
version: 0.2
env:
variables:
TERRAFORM_VERSION: "0.12.16"
(略)
buildspec.yml
は terraform 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のバージョンアップを自動化することで、
- 最新版が出てたら勝手にPull Requestを投げつけてくる(自動)
- 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/hcl
の hcl2
ブランチに取り込んでもらい、無事にv2.0.0リリースに取り込んでもらえました。どや。
hashicorp/hcl@9d1235a5b4e8: hclwrite: Allow selecting blocks for updating
わーい、欲しかったAPIが生えた。
そんなところから始まったtfupdateのプロジェクトですが、今後の展望というか、このプロジェクトの最終的な野望は、本物のDependabotのTerraform対応の実装を差し替えることなのです。みんなCIの設定書くのだるいし、よしなにして欲しいし、人類を平和にしたい。
まぁそもそもDependabotの開発チームに受け入れてもらえるかどうかはまた別の話なので、何も約束はできませんが。彼らの中でTerraform対応の優先度低そうだし、途中で飽きて気が変わったらごめんね。気に入ったらスターしてくれると、今後の開発の励みになります |ω・`)チラッ