CLOVER🍀

That was when it all began.

Terraformのコレクションには、単一のデータ型のみが格納可能だという話

これは、なにをしたくて書いたもの?

Terraformのコレクションが、単一のデータ型のみが格納可能だということを、ちゃんと把握できていなかったのでメモとして。

Type Constraints - Configuration Language - Terraform by HashiCorp

平たく言うと、anyを勘違いしていました、と。

Terraformのコレクションとは?

そもそも、ここから。

Collection Types

コレクションには、list、map、setの3種類があります。

今回のポイントは、ここです。

All elements of a collection must always be of the same type.

コレクションの要素は、すべて同じデータ型である必要があります。ハッキリ書いてありますね。

mapに関しては、キーがstringであるコレクションですね。

map(...): a collection of values where each is identified by a string label.

Collection Types / map

any?

ここでanyというキーワードをちょっと見てみたいと思います。map(any)とかで見かけるやつです。

Dynamic Types: The "any" Constraint

これ、ワイルドカード的なやつではありません。

The keyword any is a special construct that serves as a placeholder for a type yet to be decided. any is not itself a type: when interpreting a value against a type constraint containing any, Terraform will attempt to find a single actual type that could replace the any keyword to produce a valid result.

ポイントは、ここ。

Terraform will attempt to find a single actual type that could replace the any keyword to produce a valid result.

anyは、単一の型に収束しようとします。

つまり、map(any)といったanyが指定されたコレクションは、格納される値によってmap(string)などに決まるということですね。

では、複数のデータ型を入れた場合はどうなるんでしょう?たとえば、こんな感じで。

    map_param = {
      string_param = "Hello World!!"
      number_param = 8
      bool_param   = true
    }

この場合、型変換が行われます。

All of the elements of a collection must have the same type, so conversion to list(any) requires that all of the given elements must be convertible to a common type. This implies some other behaviors that result from the conversion rules described in earlier sections.

複数のデータ型が入った場合は、stringに変換しようとします。

コレクションの中に、他のデータ型とコレクションを混ぜた場合、stringに変換できなくなるためエラーになります。

If the given value were instead ["a", [], "b"] then the value cannot conform to the type constraint: there is no single type that both a string and an empty tuple can convert to. Terraform would reject this value, complaining that all elements must have the same type.

この変換ルール自体は、こちらに記載があります。

Conversion of Primitive Types

numberおよびboolがstringに変換される、ということですね。

なので、stringに変換できないコレクションを、コレクションの要素として混合させるとエラーになります。

これをちゃんと認識してなくて、だいぶハマったことがあります…。

value is not compatible with the variable's type constraint: all map elements must have the same type · Issue #21384 · hashicorp/terraform · GitHub

for_each

ちなみに、for_eachにもコレクションを指定することができますが、その値のデータ型はstringである必要があります。

for_each: Multiple Resource Instances Defined By a Map, or Set of Strings

ここも、たまに忘れてハマります…。

複雑な構成のコレクションをfor_eachに渡そうとして

The given "for_each" argument value is unsuitable: "for_each" supports maps
and sets of strings, but you have provided a xxxxx...

みたいなエラーを言われることになります。

https://github.com/hashicorp/terraform/blob/v0.13.4/terraform/eval_for_each.go#L69-L76

go-cty/unknown.go at v1.5.1 · zclconf/go-cty · GitHub

今回確認したかった内容はだいたいこんな感じなのですが、ちょっと動作も確認してみましょう。

環境

今回の環境は、こちら。

$ terraform version
Terraform v0.13.4
+ provider registry.terraform.io/hashicorp/null v3.0.0

試してみる

各種のデータ型を引数に取るモジュールを用意してみます。
modules/simple_args/variables.tf

variable "string_param" {
  type    = string
}

variable "number_param" {
  type    = number
}

variable "bool_param" {
  type    = bool
}

variable "list_param" {
  type    = list(string)
}

variable "map_param" {
  type    = map(string)
}

variable "object_param" {
  type = object({
    param = string
  })
}

結果は、local-execでechoするだけにしておきましょう。
modules/simple_args/main.tf

locals {
  params = <<PARAMS
receive args:
  string_param = ${var.string_param}
  number_param = ${var.number_param}
  bool_param = ${var.bool_param}
  list_param = ${join(",", var.list_param)}
  map_param = { a = ${var.map_param.a}, b = ${var.map_param.b} }
  object_param = { param = ${var.object_param.param} }
PARAMS
}

resource "null_resource" "this" {
  triggers = {
    params = local.params
  }

  provisioner "local-exec" {
    command = "echo '${local.params}'"
  }
}

このモジュールを使って、NGなパターンを見ていきます。

これを呼び出すルートモジュールを作成します。
main.tf

terraform {
  required_version = "0.13.4"

  required_providers {
    null = {
      version = "3.0.0"
      source  = "hashicorp/null"
    }
  }
}

## 後で

この「後で」の部分の呼び出し方法を、いろいろ変えていきたいと思います。

コレクションを使わない場合

ルートモジュールに、こんな感じで追記。

terraform {
  ## 省略
}

module "simple_args" {
  source = "./modules/simple_args"

  string_param = "Hello World!!"
  number_param = 8
  bool_param   = true
  list_param   = [1, 2, 3]
  map_param    = { a = 1, b = 2 }
  object_param = { param = "value123" }
}

単純な呼び出しですね。実行してみます。

$ terraform init
$ terraform apply -auto-approve
module.object_args.module.simple_args.null_resource.this: Refreshing state... [id=6976449612170353829]
module.object_args.module.simple_args.null_resource.this: Destroying... [id=6976449612170353829]
module.object_args.module.simple_args.null_resource.this: Destruction complete after 0s
module.simple_args.null_resource.this: Creating...
module.simple_args.null_resource.this: Provisioning with 'local-exec'...
module.simple_args.null_resource.this (local-exec): Executing: ["/bin/sh" "-c" "echo 'receive args:\n  string_param = Hello World!!\n  number_param = 8\n  bool_param = true\n  list_param = 1,2,3\n  map_param = { a = 1, b = 2 }\n  object_param = { param = value123 }\n'"]
module.simple_args.null_resource.this (local-exec): receive args:
module.simple_args.null_resource.this (local-exec):   string_param = Hello World!!
module.simple_args.null_resource.this (local-exec):   number_param = 8
module.simple_args.null_resource.this (local-exec):   bool_param = true
module.simple_args.null_resource.this (local-exec):   list_param = 1,2,3
module.simple_args.null_resource.this (local-exec):   map_param = { a = 1, b = 2 }
module.simple_args.null_resource.this (local-exec):   object_param = { param = value123 }

module.simple_args.null_resource.this: Creation complete after 0s [id=3000518257607236606]

まあ、これは動きますね、と。

mapで渡そうとしてみる

では、呼び出し先のモジュールとの間に、モジュールをもうひとつ置いてみます。

こんな感じで、引数にmap(any)を取るモジュールを用意。
modules/map_args/variables.tf

variable "container" {
  type = map(any)
}

先ほど作ったモジュールに対して、mapを介して引数を渡します。
modules/map_args/main.tf

module "simple_args" {
  source = "../simple_args"

  string_param = var.container.string_param
  number_param = var.container.number_param
  bool_param   = var.container.bool_param
  list_param   = var.container.list_param
  map_param    = var.container.map_param
  object_param = var.container.object_param
}

ルートモジュール側は、このモジュールを呼び出します。引数には、mapを渡します。

module "map_args" {
  source = "./modules/map_args"

  container = {
    string_param = "Hello World!!"
    number_param = 8
    bool_param   = true
    list_param = [ 1, 2, 3 ]
    map_param = { a = 1, b = 2 }
    object_param = { param = "value123" }
  }
}

initして

$ terraform init

実行しようとするものの、うまくいきません。

$ terraform apply -auto-approve

Error: Invalid value for module argument

  on main.tf line 30, in module "map_args":
  30:   container = {
  31:     string_param = "Hello World!!"
  32:     number_param = 8
  33:     bool_param   = true
  34:     list_param = [ 1, 2, 3 ]
  35:     map_param = { a = 1, b = 2 }
  36:     object_param = { param = "value123" }
  37:   }

The given value is not suitable for child module variable "container" defined
at modules/map_args/variables.tf:1,1-21: all map elements must have the same
type.

いろんなデータ型を混合してあり、かつコレクションやobjectまでいるのでstringに変換できなくなっているからですね。

all map elements must have the same
type.

つまり、こういったmapの使い方はできません、と。map(any)ではなく、最初からmap(string)とかの具体的な型指定に
した方が、混乱しなさそう…。

うまくいかないことはわかりましたが、もう少し小細工してみましょう。

引数を、任意項目にしてみます。
modules/simple_args/variables.tf

variable "string_param" {
  type    = string
  default = null
}

variable "number_param" {
  type    = number
  default = null
}

variable "bool_param" {
  type    = bool
  default = null
}

variable "list_param" {
  type    = list(string)
  default = null
}

variable "map_param" {
  type    = map(string)
  default = null
}

variable "object_param" {
  type = object({
    param = string
  })
  default = null
}

mapを使って呼び出すモジュールを、lookupを使って指定されていない項目にはデフォルト値を与えてみます。
modules/map_args/main.tf

module "simple_args" {
  source = "../simple_args"

  string_param = lookup(var.container, "string_param", "default")
  number_param = lookup(var.container, "number_param", 10)
  bool_param   = lookup(var.container, "bool_param", true)
  list_param   = lookup(var.container, "list_param", [0])
  map_param    = lookup(var.container, "map_param", { a = 99, b = 99 })
  object_param = lookup(var.container, "object_param", { param = "v1" })
}

stringの項目だけmapで指定してみます。

module "map_args" {
  source = "./modules/map_args"

  container = {
    string_param = "Hello World!!"
  }
}

実行してみます。

$ terraform apply -auto-approve
module.simple_args.null_resource.this: Refreshing state... [id=3000518257607236606]

Error: Invalid function argument

  on modules/map_args/main.tf line 25, in module "simple_args":
  25:   list_param   = lookup(var.container, "list_param", [0])

Invalid value for "default" parameter: the default value must have the same
type as the map elements.


Error: Invalid function argument

  on modules/map_args/main.tf line 26, in module "simple_args":
  26:   map_param    = lookup(var.container, "map_param", { a = 99, b = 99 })

Invalid value for "default" parameter: the default value must have the same
type as the map elements.


Error: Invalid function argument

  on modules/map_args/main.tf line 27, in module "simple_args":
  27:   object_param = lookup(var.container, "object_param", { param = "v1" })

Invalid value for "default" parameter: the default value must have the same
type as the map elements.

今度は、lookupの部分で怒られます。

まあ、コレクションやobjectに関しては、データ型が合わないのでどうやっても通りませんね、と。

objectを使う

では、こういう場合はどうするのか?というと、Terraformとしてはobjectを使うのが正解なんでしょう。

Structural Types

https://github.com/hashicorp/terraform/issues/21384#issuecomment-495265031

objectを引数に取るモジュールを作ってみます。
modules/object_args/variables.tf

variable "container" {
  type = object({
    string_param = string
    number_param = number
    bool_param   = bool
    list_param   = list(string)
    map_param    = map(any)
    object_param = object({ param = string })
  })
}

objectの中身は、最初のモジュールにそのまま渡すだけです。 modules/object_args/main.tf

module "simple_args" {
  source = "../simple_args"

  string_param = var.container.string_param
  number_param = var.container.number_param
  bool_param   = var.container.bool_param
  list_param   = var.container.list_param
  map_param    = var.container.map_param
  object_param = var.container.object_param
}

ルートモジュールで、これを呼び出します。

module "object_args" {
  source = "./modules/object_args"

    container = {
      string_param = "Hello World!!"
      number_param = 8
      bool_param   = true
      list_param = [ 1, 2, 3 ]
      map_param = { a = 1, b = 2 }
      object_param = { param = "value123" }
    }
}
$ terraform init
$ terraform apply -auto-approve
module.simple_args.null_resource.this: Refreshing state... [id=3000518257607236606]
module.simple_args.null_resource.this: Destroying... [id=3000518257607236606]
module.object_args.module.simple_args.null_resource.this: Creating...
module.simple_args.null_resource.this: Destruction complete after 0s
module.object_args.module.simple_args.null_resource.this: Provisioning with 'local-exec'...
module.object_args.module.simple_args.null_resource.this (local-exec): Executing: ["/bin/sh" "-c" "echo 'receive args:\n  string_param = Hello World!!\n  number_param = 8\n  bool_param = true\n  list_param = 1,2,3\n  map_param = { a = 1, b = 2 }\n  object_param = { param = value123 }\n'"]
module.object_args.module.simple_args.null_resource.this (local-exec): receive args:
module.object_args.module.simple_args.null_resource.this (local-exec):   string_param = Hello World!!
module.object_args.module.simple_args.null_resource.this (local-exec):   number_param = 8
module.object_args.module.simple_args.null_resource.this (local-exec):   bool_param = true
module.object_args.module.simple_args.null_resource.this (local-exec):   list_param = 1,2,3
module.object_args.module.simple_args.null_resource.this (local-exec):   map_param = { a = 1, b = 2 }
module.object_args.module.simple_args.null_resource.this (local-exec):   object_param = { param = value123 }

module.object_args.module.simple_args.null_resource.this: Creation complete after 0s [id=7084308946517713323]

objectであれば、複数のデータ型が入っていても、問題なく扱えます。mapと違って属性名も決まっているので補完も
効きやすいです。

では、mapよりも便利なのかというと、objectの場合は全属性が必須となります。

Values that match the object type must contain all of the specified keys, and the value for each key must match its specified type.

オプション的な引数の場合、この制約は割と面倒になったりします…。

なので、使いどころは制約、特性を理解しつつ考えていきましょう、と。