TerraformをRubyでごにょごにょしてみたメモ

最近になってようやく[Terraform](https://www.terraform.io/)を触り始めた石器時代人ですこんにちは。

# `tfstate` をignoreするの？しないの？問題

`terraform.tfstate` はTerraformで作成されたリソースの状態を保持している巨大なJSONファイルです。

何らかのプロジェクトに使うTerraformリポジトリとこのファイルは一蓮托生になる（はず）なので、このファイルもgitで管理した方が一元管理できていいのでは？と思いますよね！！思いませんか？思いますね？

しかし、例えば[aws_iam_access_key](https://www.terraform.io/docs/providers/aws/r/iam_access_key.html)を使ったりすると `terraform.tfstate` にIAM Userのcredentialsが思いっきり平文で書かれるんですね。これは `git add` したくない！！

書いてる途中に初めて知ったんですが、どうやら `terraform.tfstate` を[S3やAtlasで管理できる](http://stormcat.hatenablog.com/entry/2015/06/01/210500)らしいので、まあそうしろという話なのですが、これをやっている最中の石器時代人は何としてもリポジトリの中で管理したいという夢を捨てきれませんでした。

`terraform.tfstate`にヤバい情報が含まれてるならば、**暗号化して `git add` すればよくね？**という思考に至りました。[ChefのEncrypted Data Bag Item](https://docs.chef.io/data_bags.html#encrypt-a-data-bag-item)的な。

# そうだ、暗号化しよう

```:.gitignore
terraform.tfstate
terraform.tfstate.backup
```

こいつらはアレなのでaddしません。その代わりに `terraform.tfstate.encrypted` をgit管理します。

しかしながら、こうなると当然ながら、**terraform plan/applyを実行する前後に暗号化・復号化をする必要がある**ということになります。

1. `terraform.tfstate.encrypted` → 何かで復号化する → `terraform.tfstate`
2. `terraform plan/apply` を実行
3. `terraform.tfstate` の内容が更新される
4. `terraform.tfstate` → 何かで暗号化する → `terraform.tfstate.encrypted`

この流れが厳かに行われない限り、`terraform.tfstate.encrypted` が常に最新にならず

- 古い状態の `terraform.tfstate` を使って `terraform plan/apply` が行われてしまう
- `terraform.tfstate.encrypted`が最新化されないまま `*.tf` の変更だけが `git commit` されてしまう

といったよろしくない事態が起こります。

# そうだ、 `terraform` を直接叩くのをやめよう

前節の1-4の流れをtaskとして順次実行するようにしてしまえばいいのです。そこで `rake` ですよ。

```ruby:Rakefile
# 略

namespace :terraform do
  # 略

  desc "decrypt terraform.tfstate.encrypted to terraform.tfstate"
  task :decrypt_state do
    # 略
    # terraform.tfstate.encrypted を terraform.tfstate に復号化する
  end

  desc "encrypt terraform.tfstate to terraform.tfstate.encrypted"
  task :encrypt_state do
    # 略
    # terraform.tfstate を terraform.tfstate.encrypted に暗号化する
  end

  desc "terraform plan"
  task :plan do
    # 略
    # terraform plan を実行する
    Rake::Task["terraform:encrypt_state"].invoke
  end

  desc "terraform apply"
  task :apply do
    # 略
    # terraform apply を実行する
    Rake::Task["terraform:encrypt_state"].invoke
  end

  task :plan  => :decrypt_state
  task :apply => :decrypt_state

  # 略
end

task default: ["terraform:plan"]
```

渡りに船ということで、暗号化・復号化もRubyでやってしまいます。[symmetric_encryption](http://rocketjob.github.io/symmetric-encryption/)というgemを使いました。

```shell-session
$ rake -T
rake terraform:apply          # terraform apply
rake terraform:decrypt_state  # decrypt terraform.tfstate.encrypted to terraform.tfstate
rake terraform:encrypt_state  # encrypt terraform.tfstate to terraform.tfstate.encrypted
rake terraform:plan           # terraform plan
```

`apply` するときは `$ rake terraform:apply` を実行します。 `plan` も以下同文ですが、default taskなのでこちらは `rake` だけでもOKです。

**直接terraformを叩くのは禁止。**

# せっかくだからERBテンプレートを使えるようにしよう

Terraformの定義ファイル `*.tf` はあまりプログラマブルではありません。似たようなリソース定義をコピペで増やすのは面倒ですよね。

せっかくRubyを持ち込んだのだから、ERBで*.tfを自動生成するようにしてみます。

```ruby:Rakefile
require "erb"

# 略

namespace :terraform do
  # 略

  desc "build *.tf.erb to .tf"
  task :build do
    Dir.glob("*.tf.erb").each do |path|
      File.open path.sub(/\.erb\z/, ""), "w" do |file|
        file.write ERB.new(File.read(path)).result
      end
    end
  end

  # 略

  task :plan  => :build
  task :apply => :build
end
```

`$ rake terraform:build` で、カレントディレクトリの `*.tf.erb` が全て `*.tf` にビルドされます。もちろん、 `terraform:plan` `terraform:apply` の前に実行されるようにしておきます。

これで次のような記述ができるようになりました。

```erb:iam_users.tf.erb
<%
  user_names = %w(
    user_a
    user_b
    user_c
    user_d
    user_e
    user_f
    user_g
  )
%>

resource "aws_iam_group" "users" {
  name = "users"
  path = "/users/"
}

<% user_names.each do |user_name| %>
  resource "aws_iam_user" "<%= user_name %>" {
    name = "<%= user_name %>"
    path = "/users/"
  }

  resource "aws_iam_access_key" "<%= user_name %>" {
    user = "${aws_iam_user.<%= user_name %>.name}"
  }
<% end %>

resource "aws_iam_group_membership" "users_membership" {
  name  = "users_membership"
  group = "${aws_iam_group.users.name}"

  users = [
    <% user_names.each do |user_name| %>
      "${aws_iam_user.<%= user_name %>.name}",
    <% end %>
  ]
}
```

**読みにくいとか言わないで!!** :sob: 

そして、 `.tf` と `.tf.erb` の内容がズレてgitに登録されないように、

```:.gitignore
*.tf
```

嗚呼、Terraformのリポジトリなのに `.tf` が排除されてしまった。

# せっかくだからTerraform本体もインストールできるようにしよう

Terraformはまだ若いプロダクトで、バージョンアップも頻繁に行われています。当然、同じ `.tf` ファイルでもTerraformのバージョンが違うと挙動が違う、ということがあり得ます。

このリポジトリのTerraformバージョンはこれ！と固定したいですよね？

そこで、**カレントディレクトリ配下に固定されたバージョンのTerraform本体を置けばいいし、そうするtaskも作ればいいじゃん**と考えました。

```ruby:Rakefile
namespace :terraform do
  desc "install terraform to this directory"
  task :install do
    terraform_version = File.read(".terraform_version").chomp
    download_url      = "https://releases.hashicorp.com/terraform/#{terraform_version}/terraform_#{terraform_version}_#{`uname`.match(/darwin/i) ? "darwin" : "linux"}_amd64.zip"

    system "curl -L #{download_url} -o terraform.zip"
    system "unzip terraform.zip -d vendor/"
    system "rm -f terraform.zip"
  end

  # 略
end
```

```:.terraform_version
0.6.8
```

`$ rake terraform:install` すればterraform本体が `vendor/` 以下に置かれます。やったね。

そして `terraform:plan` `terraform:apply` はこのterraformを叩くようにします。

```ruby:Rakefile
# 略

  desc "terraform plan"
  task :plan do
    system "vendor/terraform plan"
    Rake::Task["terraform:encrypt_state"].invoke
  end

# 略
```

# ついでに

[direnv](https://github.com/direnv/direnv) という物がありまして、これが非常によいです。

せっかくだからAWSのcredentialsや、`terraform.tfstate` の暗号化・復号化のために使う鍵ファイルの情報はこれで管理するように**強制**しました。

- [direnvを使おう - Qiita](http://qiita.com/kompiro/items/5fc46089247a56243a62)

# こうなった

- https://github.com/y13i/terraform-ruby-something