🦁

Terratestの代替ツールとしてterraform-execを検証

2024/01/27に公開

Terraformのライセンス変更のせいでインフラプロジェクトの自動テストの設計が破綻した話で、バージョン1.6以降のTerraformとGoでTerraformのテストを書くためのフレームワークであるTerratestを併用できなくなることを書いた。

Terraformネイティブのテスト機能(Test, Mock)だけでもある程度のテストは書けそうではあるが、複雑で自由度の高いintegrationテストやe2eテストを自動実行出来るようにしたいのであれば、やはり手続き型言語(インフラなのでGo、次点でTypeScriptか)でテストを書けるようにしたい。

なおTerratestが提供している一番重要な機能は、terraformコマンドの実行をGoのプログラム経由で操作出来る点である。

(以下、TerratestのHello Worldサンプルコード)

https://github.com/gruntwork-io/terratest/blob/master/test/terraform_hello_world_example_test.go

(他にもtest_structureというGoでTerraformのテストを実装するにあたって役に立つ機能がまとめられているパッケージもあるが、ここでは説明を割愛)

よってTerratestの代替=TerraformコマンドをGo経由で実行できるGoモジュールということで、何かないか調べてみたところ、公式であるHashiCorpがterraform-execというGoモジュールを提供していた。

リポジトリを調べると最初のリリースが行われたのが2020年7月31日らしいが、現在のバージョンがv0.20.0とメンテナンスされているか極めて怪しいという印象を受ける。

しかし一番最近のリリースは2023年12月20日で、ソースコードを調べてみると最近出来たばかりのterraform testコマンドも実装されている。

何よりHashiCorp自身が提供しているものなので、Terratestのように突然以後のサポートが消えるという確率も低そう。

しかしドキュメントがREADME.mdしかないようで、使いたければterraform-execのソースコードとテストコードを読んで試行錯誤するしかない点は結構厳しい。

それでも拡張性の高い柔軟な自動テスト環境を構築したいのならば、もう背に腹は変えられない状況なのでとりあえずterraform-execを試してみる。

検証

試しにGCSバケットを生成するTerraformモジュールをterraform-execを使用してテストすることが出来るかサンプルコードを実装していく。

まずテスト可能なTerraformプロジェクトはフォルダ構成がテストしやすい状態となっている必要がある。

(アプリのコードをテスト出来るようにするためには、まずテストしやすい単位で関数やクラスで定義しなければいけないのと同様に)

一般的なTerraformのフォルダ、ファイル構成はだいたい以下のようになる。

$ cd /path/to/your-project
$ tree
.
├── terraform
│   ├── environments
│   │   └── prod
│   │       ├── main.tf
│   │       ├── ...
│   │   └── test
│   │       ├── main.tf
│   │       ├── ...
│   ├── modules
│   │   └── storage
│   │       ├── main.tf
│   │       └── tests
│   │           └── main.tftest.hcl
│   ├── tests
│   │   ├── go.mod
│   │   ├── go.sum
│   │   └── storage_test.go
└── README.md

各フォルダの役割は以下のようになる。

terraform/environments フォルダ

本番環境、開発環境、テスト環境等にapplyするリソースの定義がまとめられるフォルダで、一応Terraformプロジェクトの基本型を構成しているので書いたが、今回のテストでは触れることはないので詳しい説明は省略する。

terraform/modules フォルダ

このフォルダには自作のTerraformモジュールを格納する。

この例ではGCSバケットを作成するstorageモジュールを定義していて、設定は以下のようになる。

(説明の便宜上、providerやvariableなどもまとめてmain.tfに定義している)

terraform/modules/storage/main.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "5.10.0"
    }
  }
  required_version = "1.7.1"
}

provider "google" {
  project = "your-gcp-project"
  region  = "asia-northeast1"
}

variable "env" {
  type = string
}

resource "google_storage_bucket" "example" {
  name          = "${var.env}-example-bucket"
  location      = "ASIA"
  storage_class = "MULTI_REGIONAL"
  versioning {
    enabled = true
  }
  uniform_bucket_level_access = true
  public_access_prevention = "enforced"
}

output "example_bucket_location" {
  value = google_storage_bucket.example.location
}

output "example_bucket_storage_class" {
  value = google_storage_bucket.example.storage_class
}

なおstorageモジュールにはmain.tfの他にtests/main.tftest.hclファイルもあるが、これはterraform testコマンドで実行されるテストが定義されているファイルとなる。

今回はterraform testコマンドでのテストがメインの紹介ではないのでこれ以上説明しないが、以下のようなユニットテストを記述出来る。

terraform/modules/storage/tests/main.tftest.hcl
run "google_storage_bucket_example" {
  command = plan
  assert {
    condition     = google_storage_bucket.example.name == "test-example-bucket"
    error_message = "the bucket name must be 'test-example-bucket'"
  }
}

terraform/modules/storageディレクトリ配下で以下のコマンドを実行するとテストが行われる。

$ cd /path/to/your-project/terraform/modules/storage
$ TF_VAR_env=test terraform test
tests/main.tftest.hcl... in progress
  run "google_storage_bucket_example"... pass
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... pass

Success! 1 passed, 0 failed.

terraform/tests フォルダ

今回の検証のテーマであるterraform-execを使ったGoのテストが格納されるフォルダ。

このフォルダは通常のGoアプリケーションのプロジェクトと同じ要領で、以下のようなコマンドで作成する。

$ cd /path/to/your-project/terraform/tests
$ go mod init tests
$ cat go.mod
module tests

go 1.21.5

なおtestsというフォルダ名はTerraform Testとの関係を連想させるが、実はこのフォルダではまったく関係ないので紛らわしいと感じるなら別の名前をつけたほうがいい。

個人的には格納されているファイルを見るだけである程度意図は分かるのと、このフォルダ直下に専用のREADME.mdを用意してそこで言及すれば問題ないと考えている。

(一方storageモジュールの方はTerraform Testに関係あるので、フォルダ名を推奨されているtestsにすべき)

このフォルダではstorage_test.goというstorageモジュールをテストするGoのコードを置いている。

コードはterraform-execのExampleを参考にしたもので、以下のような実装となる。

terraform/tests/storage_test.go
package tests

import (
  "context"
  "encoding/json"
  "testing"
  "time"

  "github.com/hashicorp/go-version"
  "github.com/hashicorp/hc-install/product"
  "github.com/hashicorp/hc-install/releases"
  "github.com/hashicorp/terraform-exec/tfexec"
  "github.com/stretchr/testify/assert"
)

func TestStorageModule(t *testing.T) {
  // go test実行時にこのテストが並列実行の対象になる
  t.Parallel()

  // 以降の各処理のタイムアウト時間はそれぞれ1分
  ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
  defer cancel()

  // https://github.com/hashicorp/hc-install/blob/main/README.md#sources
  // を見ると多分Terraformダウンロードして、検査したのちTerraformをインストールしてるっぽい
  installer := &releases.ExactVersion{
    Product: product.Terraform,
    Version: version.Must(version.NewVersion("1.7.1")),
  }
  execPath, err := installer.Install(ctx)
  if err != nil {
    t.Fatalf("error installing Terraform: %s", err)
  }

  // terraform/modules/storageディレクトリ配下でterraformコマンドを実行するようになる
  workingDir := "../modules/storage"
  tf, err := tfexec.NewTerraform(workingDir, execPath)
  if err != nil {
    t.Fatalf("error running NewTerraform: %s", err)
  }

  // terraform init
  if err := tf.Init(ctx, tfexec.Upgrade(true)); err != nil {
    t.Fatalf("error running Init: %s", err)
  }

  // 正常、異常問わず関数終了後にterraform destroy
  defer tf.Destroy(ctx, tfexec.Var("env=test"))

  // terraform apply
  if err := tf.Apply(ctx, tfexec.Var("env=test")); err != nil {
    t.Fatalf("error running Apply: %s", err)
  }

  // outputの出力結果が意図する値かテスト
  outputs, err := tf.Output(ctx)
  if err != nil {
    t.Fatalf("error retrieving Output: %s", err)
  }
  assert.Equal(t, "ASIA", output(t, outputs, "example_bucket_location"))
  assert.Equal(t, "MULTI_REGIONAL", output(t, outputs, "example_bucket_storage_class"))
}

func output(t *testing.T, outputs map[string]tfexec.OutputMeta, name string) string {
  // OutputのValueはraw.JsonMessage型なのでUnmarshalする必要がある
  var value string
  if err := json.Unmarshal(outputs[name].Value, &value); err != nil {
    t.Fatalf("error unmarshaling json: %s", err)
  }
  return value
}

コードを作成したら、依存モジュールをダウンロードするために以下のコマンドを発行する。

$ go mod tidy

ちなみにコードのimport情報を元にGoモジュールをダウンロードするのは、ほとんどのプログラム言語の手順と逆なので、結構違和感を感じる。

(Goほとんど書いたことないので慣れれば便利に感じるのかも)

一応、go get github.com/ ~ コマンドでGoモジュールを先にダウンロードしてからコード書くことも出来る。

テストコードはGoの標準ライブラリに組み込まれているtestパッケージを使用して実装されていて、go testコマンドを実行することでテストを実行できる。

# "./..."はサブディレクトリ内の*_test.goも実行対象という意味
# -vオプションはt.Log()の実行結果を常時標準出力するという意味
# -count=1オプションは同じテストが実行されても、キャッシュ結果を返さずに再度実行させるという意味
$ go test ./... -v -timeout 30m -count=1
=== RUN   TestStorageModule
=== PAUSE TestStorageModule
=== CONT  TestStorageModule
--- PASS: TestStorageModule (20.36s)
PASS
ok  	tests	20.815s

テストではstorageモジュールのフォルダ配下でterraform init -> terraform applyコマンドを実行したのち、terraform outputで取得した値からGCSバケットが意図するロケーション、ストレージクラスで作成されたことを確認している。

そして最後にテストが完了したらterraform destroyを実行してテスト環境をクリアな状態に戻している。

terraformのバイナリのインストールやterraform initによるモジュールのインスールが行われるため、実行オーバーヘッドが大きい(20秒前後かかる)のが若干難である。

ちなみにTerratestではテスト実行中の進行状況が非常に分かりやすいログが標準出力に出力されていたのだが、terraform-execでは特に何もしないとログが一切表示されない。

(ドキュメントが存在しないので、機能が本当に無いのかはコードを読むしかない)

おわり

terraform-execを使ってTerraformのテストコードを書く検証は以上となる。

おそらくすべてのterraformコマンドを実行できることから、現時点ではTerratestの代替足りうると考えている。

しかし

  • terraform-execを使うためにはドキュメントが無いのでコード読むしかない
  • そのためにGoをまず理解しておく必要ある
  • さらにTerraformのテストを書くために、アプリのテストコードの実装に慣れる必要がある
  • にもかかわらず、作業者はおそらくインフラチーム

という点が確実にデファクト化の妨げになる気はしている。

追記

一応、Terraform Testを使用したテストコード実装のまとめも置いておく。

https://zenn.dev/erueru_tech/articles/f6e3985e0c7c45

Discussion