Terraform version 1.5の新機能達を使ってみた

2023.07.31

2023/06/12にTerraform のversion 1.5がGAになりました。(2023/07/31時点でv1.5.4まで出ています。)
だいぶ遅いですが、新機能達を使ってみたのでレポートします。

checkブロック

一言で言えば汎用的なvalidation機能です。

以下がcheckブロックの例です。

check "health_check" {
  data "http" "example" {
    url = "https://${aws_lb.example.dns_name}"
  }
 
  assert {
    condition     = data.http.example.status_code == 200
    error_message = "${data.http.example.url} returned an unhealthy status code"
  }
}
  • checkブロックは1個以上のassertブロックと、0-1個のdata sourceブロックから構成されます。assertブロックを複数個かけるということは複数個のテストができるということです。
    data sourceについては後述します。
  • assertブロックはconditionとerror_message attributesで構成され、conditionの結果がfalseの時にerror_message値が出力されます。
  • conditionとerror_messageでは同module内の全要素(resource,data source,variable,local variable,output)を参照することができます。
    • これまでもvalidation系の機能はありましたが、variableに対してのものだったり、data source、resourceに対してのものだったりと、特定の要素単体に対してのものでした。対してcheck ブロックは全要素を参照できます。そういう意味で「汎用的な」validation機能だと述べました。
  • テストはplan完了後、apply完了後に実行されます。plan完了後にcheck blockでエラーが出たとしても、applyは可能です。またapply完了後にエラーが出たとしても、applyを取り消すことはできません。
    つまり、この機能はエラーの出るapplyを抑止する、中断することはできません。
  • checkブロックの中のdata sourceブロックはscoped data sourceブロックと呼ばれ、checkブロック内でしか使用することができません。が、checkブロック内のassertブロックで参照できます。そしてdataを参照しに行くのはcheckブロックの処理が実施されるタイミングつまりplan後とapply後です。

使い道としては主に以下いずれかかなと思います。

  • 前述の例のようにapply後のe2eテストのようなもの。前述のcheckブロックの例は、同Terraformモジュール内でプロビジョニングしたALBのDNSにhttpsアクセスして、そのレスポンスステータスコードが200かどうかチェックするものです。こんな感じでTerraformでプロビジョニングしたリソース群が要件を満たしているか確認することができます。
  • ユーザーに対しての警告。サービスクオータが上限に達しそうだよ、とかリソースが期限切れになっているよ(もしくはなりそうだよ)みたいな、プロビジョニングした(する)リソースについてユーザーが知っておいたほうがいい情報を提供する事ができます。
    前述の通りこのcheckブロックでエラーになったとしてもapplyを抑止することはできませんので、そういった目的では使うべきではありません。apply抑止を目的にする場合は、1.2でGAになったprecondition / postcondition、これらはエラー時に後段の処理を止めることが出来ますので、こちらで頑張るべきでしょう。

importブロック

既存リソースをTerraformに取り込む新しい方法です。

これまで上記を実現しようとした際には、terraform importコマンドを使う必要がありました。ですがこのコマンドで既存リソースをTerraformに取り込むことにはいくつか問題点があります。

  • 1回のterraform importコマンドで1リソースしかインポートできない。たくさん対象リソースがある場合は大変です。
  • terraform importは対象リソースをstateに取り込むだけです。リソースの状態に即したTerraformのresourceコードを書いてくれるわけではありません。また、プレビューはなく即時対象リソースをstateに取り込みます。そのため、コードが出来上がるまでの間に他のチームメンバーがterraform applyしてしまうとインポートしたリソースが意図しない削除や変更を受けてしまう危険性があります。
  • また、上記「リソースの状態に即したTerraformのresourceコード」を書くのは時間がかかります。resourceのattribute値を修正してplan→修正してplan…をdiffが検出されなくなるまでやることになるでしょう。

新機能importブロックはこのあたりの問題を解決してくれます。以下がimportブロックの例です。

s3.tf

import {
  id = "terraform1-5importtest"
  to = aws_s3_bucket.test
}

idterraform importコマンドで指定するリソースの固有値ですね。そのidを持つ既存リソースをtoのアドレスにインポートする、というコードです。この例だとterraform1-5importtestという名前のS3バケットをaws_s3_bucket.testにインポートする、というコードになります。

これをterraform planすると…

% terraform plan                                            

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Import block target does not exist
│ 
│   on s3.tf line 1:
│    1: import {
│ 
│ The target for the given import block does not exist. If you wish to automatically generate config for
│ this resource, use the -generate-config-out option within terraform plan. Otherwise, make sure the
│ target resource exists within your configuration. For example:
│ 
│   terraform plan -generate-config-out=generated.tf
╵

エラーになりました。targetに指定したアドレスのコード(今回の例だとaws_s3_bucket.test)は予め用意しておく必要があるようですね。そしてエラーメッセージに書かれているようにそのリソースのコードを自動生成してくれるオプションがあります!

% terraform plan -generate-config-out=generated_resources.tf
aws_s3_bucket.test: Preparing import... [id=terraform1-5importtest]
aws_s3_bucket.test: Refreshing state... [id=terraform1-5importtest]

Terraform will perform the following actions:

  # aws_s3_bucket.test will be imported
  # (config will be generated)
    resource "aws_s3_bucket" "test" {
        arn                         = "arn:aws:s3:::terraform1-5importtest"
        bucket                      = "terraform1-5importtest"
        bucket_domain_name          = "terraform1-5importtest.s3.amazonaws.com"
        bucket_regional_domain_name = "terraform1-5importtest.s3.ap-northeast-1.amazonaws.com"
        hosted_zone_id              = "Z2M4EHUR26P7ZW"
        id                          = "terraform1-5importtest"
        object_lock_enabled         = false
        region                      = "ap-northeast-1"
        request_payer               = "BucketOwner"
        tags                        = {}
        tags_all                    = {}

        grant {
            id          = "3a13d3890609a4deee6c8971f4cbfc5fe859d0d9e414f3bd88bc5889d2a97be8"
            permissions = [
                "FULL_CONTROL",
            ]
            type        = "CanonicalUser"
        }

        server_side_encryption_configuration {
            rule {
                bucket_key_enabled = true

                apply_server_side_encryption_by_default {
                    sse_algorithm = "AES256"
                }
            }
        }

        versioning {
            enabled    = false
            mfa_delete = false
        }
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
╷
│ Warning: Config generation is experimental
│ 
│ Generating configuration during import is currently experimental, and the generated configuration
│ format may change in future versions.
╵

─────────────────────────────────────────────────────────────────────────────────────────────────────────

Terraform has generated configuration and written it to generated_resources.tf. Please review the
configuration and edit it as necessary before adding it to version control.

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly
these actions if you run "terraform apply" now.

はい。generate-config-outオプションを指定すると、import ブロックの内容に沿ってインポート対象のresourceのコードを自動生成してくれます!

自動生成されたgenerated_resources.tf

# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "terraform1-5importtest"
resource "aws_s3_bucket" "test" {
  bucket              = "terraform1-5importtest"
  bucket_prefix       = null
  force_destroy       = null
  object_lock_enabled = false
  tags                = {}
  tags_all            = {}
}

このままapplyしてもOKです。私はいらないattributeを削除して、ファイルも移動させてからapplyしたいと思います。

s3.tf

  import {
    id = "terraform1-5importtest"
    to = aws_s3_bucket.test
  }
+ 
+ resource "aws_s3_bucket" "test" {
+   bucket = "terraform1-5importtest"
+ }
# generated_resources.tfは削除しました
% terraform apply                      
(略)
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_s3_bucket.test: Importing... [id=terraform1-5importtest]
aws_s3_bucket.test: Import complete [id=terraform1-5importtest]

Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

これでインポート完了です!便利。
インポート後はimportブロックを削除しても構いません。残したままでも良いです。

importブロックにより、最初に挙げたterraform importでのリソースインポートの問題を解決することができます。

1回のterraform importコマンドで1リソースしかインポートできない。たくさん対象リソースがある場合は大変。

→ importブロックは複数書けるので、一度のapplyでまとめてインポート可能です。

terraform importは対象リソースをstateに取り込むだけです。リソースの状態に即したTerraformのresourceコードを書いてくれるわけではありません。また、プレビューはなく即時対象リソースをstateに取り込みます。そのため、コードが出来上がるまでの間に他のチームメンバーがterraform applyしてしまうとインポートしたリソースが意図しない削除や変更を受けてしまう危険性があります。

terraform planでプレビューができます。またインポート先のリソースのコードが無いとインポートできません。そしてstateに取り込まれるのはapply時です。以上のことから挙げたような意図しない変更や削除が発生してしまうリスクを大幅に減らせるでしょう。

また、上記「リソースの状態に即したTerraformのresourceコード」を書くのは時間がかかります。resourceのattribute値を修正してplan→修正してplan…をdiffが検出されなくなるまでやることになるでしょう。

terraform planの新オプションgenerate-config-outがコードを自動生成してくれます。もちろん汎用的なコードにするなどのためにいくつかの手直しが必要になることも多いと思いますが、ひとまず土台を作ってくれるので大幅に時間短縮できるでしょう。

細かな仕様

変数は使えない

importブロックのidにvariableやlocal variableは使えません。エラーになります。

╷
│ Error: Variables not allowed
│ 
│   on s3.tf line 2, in import:
│    2:   id = var.bucket_name
│ 
│ Variables may not be used here.
╵
╷
│ Error: Unsuitable value type
│ 
│   on s3.tf line 2, in import:
│    2:   id = var.bucket_name
│ 
│ Unsuitable value: value must be known

for_each, countも使えない

variableやlocal variableが使えない時点で予想できますが、for_eachやcountも使えません。

for_each
import {
  for_each = toset(["terraform1-5importtest", "terraform1-5importtest2"])

  id       = each.value
  to       = aws_s3_bucket.test[each.value]
}
% terraform apply
╷
│ Error: Unsupported argument
│ 
│   on s3.tf line 2, in import:
│    2:   for_each = toset(["terraform1-5importtest", "terraform1-5importtest2"])
│ 
│ An argument named "for_each" is not expected here.
╵
╷
│ Error: Variables not allowed
│ 
│   on s3.tf line 4, in import:
│    3:   id       = each.value
│ 
│ Variables may not be used here.
╵
╷
│ Error: Unsuitable value type
│ 
│   on s3.tf line 4, in import:
│    3:   id       = each.value
│ 
│ Unsuitable value: value must be known
╵
╷
│ Error: Invalid expression
│ 
│   on s3.tf line 5, in import:
│    4:   to       = aws_s3_bucket.test[each.value]
│ 
│ A single static variable reference is required: only attribute access and indexing with constant keys.
│ No calculations, function calls, template expressions, etc are allowed here.
count
import {
  count = 2

  id = element(["terraform1-5importtest", "terraform1-5importtest2"], count.index)
  to = aws_s3_bucket.test[count.index]
}
% terraform apply
╷
│ Error: Unsupported argument
│ 
│   on s3.tf line 2, in import:
│    7:   count = 2
│ 
│ An argument named "count" is not expected here.
╵
╷
│ Error: Unsuitable value type
│ 
│   on s3.tf line 4, in import:
│    9:   id = element(["terraform1-5importtest", "terraform1-5importtest2"], count.index)
│ 
│ Unsuitable value: value must be known
╵
╷
│ Error: Function calls not allowed
│ 
│   on s3.tf line 4, in import:
│    9:   id = element(["terraform1-5importtest", "terraform1-5importtest2"], count.index)
│ 
│ Functions may not be called here.
╵
╷
│ Error: Invalid expression
│ 
│   on s3.tf line 5, in import:
│   10:   to = aws_s3_bucket.test[count.index]
│ 
│ A single static variable reference is required: only attribute access and indexing with constant keys.
│ No calculations, function calls, template expressions, etc are allowed here.
╵

providerを指定できる

providerattiributeでaliasを指定できます。

provider "aws" {
  alias = "europe"
  region = "eu-west-1"
}

import {
  provider = aws.europe
  to = aws_instance.example["foo"]
  id = "i-abcd1234"
}

sub module内にインポートしたい時は

これまでと同様root module内でimportブロックを書いて、toのアドレスをsub module内にすれば良いです。

import {
  id = "terraform1-5importtest"
  to = module.hogehoge.aws_s3_bucket.test
}

module "hogehoge" {
  source = "./modules/hogehoge"
}

./modules/hogehoge/s3.tf

resource "aws_s3_bucket" "test" {
  bucket = "terraform1-5importtest"
}

逆にsub module内にimport blockを書くことは出来ません。

./modules/hogehoge/s3.tf

import {
  id = "terraform1-5importtest"
  to = aws_s3_bucket.test
}

resource "aws_s3_bucket" "test" {
  bucket = "terraform1-5importtest"
}
% terraform apply                                      
╷
│ Error: Invalid import configuration
│ 
│   on modules/hogehoge/s3.tf line 1:
│    1: import {
│ 
│ An import block was detected in "module.hogehoge". Import blocks are only allowed in the root module.

plantimestamp関数

こちらについては以下エントリにまとめています。

strcontains関数

第一引数の文字列の中に第二引数の文字列が含まれているかチェックし、含まれているならtrue、含まれていないならfalseを返す関数です。

これまでも既存の関数の組み合わせで同様のことはできましたが、strcontainsを使うことでよりシンプルに書けるようになります。正規表現は使えないので、正規表現が必要な場合は従来どおりのやや複雑な記法が必要です。

これまで

% terraform console
> length(regexall("abcd", "abcdefg")) > 0
true
> length(regexall("aaaa", "abcdefg")) > 0
false

> can(regex("abcd", "abcdefg"))
true
> can(regex("aaaa", "abcdefg"))
false

> replace("abcdefg", "abcd", "") != "abcdefg"
true
> replace("abcdefg", "aaaa", "") != "abcdefg"
false

これから

% terraform console
> strcontains("abcdefg","abcd")
true
> strcontains("abcdefg","aaaa")
false

補足: 前方一致、後方一致の場合

startswith関数endswith関数があるのでこちらを使いましょう。

参考情報