Terraform HCL の構文ガイド｜型・演算子・関数・制御式

プログラミング言語の入門によくある流れで Terraform の構文をまとめてみたらなんか面白そうと思ったので、まとめてみました。

# 実行環境の準備

この記事で記載しているサンプルコードは全て `locals` ブロックにて記載しており、 `terraform console` にて式の結果などを確認できます。
また、ステートファイルは生成されないので、 `terraform init` は不要です。

Terraform のバージョンは 1.0 以上であれば、問題ありません。

```bash:Bashでの確認方法
mkdir tf-demo && cd tf-demo

cat << EOF > main.tf
locals {
  message = "Hello, World!"
}
EOF

terraform console

> message
#=> "Hello, World!"
```

# local ブロックの基本

データ型や関数など、プログラミング言語としての機能を解説するのがこの記事の本題ですが、
サンプルコードは `locals` ブロックの中で書いているので、先に `locals` ブロックについての構文ルールを説明しておきます。

```hcl:main.tf
locals {
  # ローカル値を宣言
  service_name = "forum"

  # 再代入はできない。
  # service_name = "forum"
  # The argument "service_name" was already set at main.tf:3,3-15.
  # Each argument may be set only once.

  # 宣言済みのローカル値を参照（locals.<name> ではなく、 local.<name> になる点に注意）
  declared_service_name = local.service_name
}

locals {
  # locals ブロックはスコープを形成しない。
  # そのため、違う locals ブロックで宣言した値も参照できる。
  another_locals_value = local.service_name
}
```

```other.tf
locals {
  # ファイル単位においてもスコープを形成しない。
  # そのため、違うファイルで宣言した値も参照できる。
  other_file_value = local.service_name
}
```

Terraform で宣言した `local` ブロック内の値は同一のディレクトリ（Terraform の世界では Module）単位でのみスコープを持ちます。
言い換えると、同じ名前の値は同一のディレクトリで一つだけ定義できるため、ファイルが違っても名前が被らないようにする必要があります。
ただ、この記事で書いているサンプルコードはすべて名前が被らないようにしているので、その辺は気にしなくても大丈夫です。

# データ型

Terraform では様々な型の値を定義できます。
locals ブロックでは、宣言時にてリテラル、もしくは、式の結果を元に型推論されるため、
明示的に型を宣言することはありません。というよりも、そういった構文はありません。

ただ、variable ブロックでは型を明示的に宣言して、外部から取得する値の検証ができます。（この記事では割愛）

以下、データ型の一覧です。

| 型 | リテラル |
|----|----|
| string | `"Hello, world!"` |
| number | `42`, `3.14` |
| bool | `true` or `false` |
| tuple | `["test", 42, true, null]` |
| object | {"hoge": 123} |
| list | 関数で変換: `tolist([23, 42])` |
| map | 関数で変換: `tomap({"hoge": 123, "piyo": 456})` |
| set | 関数で変換: `toset(["A", "B", "C"])` |
| null | null |

string, number, bool はプリミティブ型、 tuple, list, object, map, set を複合型（Complex type）と呼びます。複合型はさらにコレクション型（tuple, list）と構造型（object, map）に分けられます。

null は厳密にはデータ型ではなく、特殊な値として扱われています。
Terraform ではリソースの Argument に null を指定するとデフォルト値が適用されます。

```hcl
locals {
  # string（ダブルクォート/シングルクォート可）
  message = "Hello, World!"

  # 埋め込み文字列
  embedded_message = "They said ${local.message}" # "They said Hello, World!"

  # ヒアドキュメント（インデント保持）
  heardoc_indent = <<EOT
  これは複数行のテキストです。
    インデントも保持されます。
EOT

  #  これは複数行のテキストです。
  #    インデントも保持されます。
  #

  # ヒアドキュメント（インデント削除）
  heardoc_no_indent = <<-EOT
  これは複数行のテキストです。
  先頭の空白は削除されます。
  EOT

  #これは複数行のテキストです。
  #先頭の空白は削除されます。
  #

  # %{for} と %{if} の組み合わせ
  # %{for} と %{endfor} ブロックの最後に ~ を入れることで、無駄な改行が出力されない。
  # ただ、なぜか <<-EOT でインデントなしができない。
  template_with_numbers = <<EOT
%{for num in range(1, 3)~}
現在の数値は ${num} です。
%{if num % 2 == 0~}
偶数です。
%{else~}
奇数です。
%{endif~}
%{endfor~}
EOT

  #現在の数値は 1 です。
  #奇数です。
  #現在の数値は 2 です。
  #偶数です。
  #

  # number
  age   = 30
  price = 19.99

  # bool
  has_permission = true
  is_admin       = false

  # tuple
  numbers_tuple = [1, 2, 3]
  # データ型が異なっていても OK。末尾のカンマがあっても OK。
  various_type_tuple = ["一", 2, true, ]

  # list（すべて同じデータ型を含む）
  numbers_list = tolist([1, 2, 3])
  names        = tolist(["Alice", "Bob", "Charlie"])

  # set（重複なし、順序なし）
  numbers_set = toset([23, 23, 42])

  # object
  metadata_obj = {
    environment  = "production"
    region       = "us-west-2"
    use_multi_az = true
  }

  # map（すべて value に同じデータ型を含む）
  metadata_map = tomap({
    environment = "production"
    region      = "us-west-2"
  })

  # null
  use_default = null
}
```

# 演算子

Terraform でサポートしている演算子はこちらです。
演算子の優先度順に応じて区切って書いています。

[https://developer.hashicorp.com/terraform/language/expressions/operators](https://developer.hashicorp.com/terraform/language/expressions/operators)

```hcl
locals {
  # 単項演算子（!, -）
  ope_true_to_false = !true # false
  ope_plus_to_minus = -10   # -10

  # 算術演算子（*/%）
  ope_product   = 4 * 3  # 12
  ope_division  = 20 / 5 # 4
  ope_remainder = 12 % 5 # 2

  # 算術演算子（+-）
  ope_sum        = 1 + 2  # 3
  ope_difference = 10 - 5 # 5

  # 比較演算子（>, >=, <, <=）
  ope_is_valid = 3 > 2 # true

  # 等価演算子（==, !=）
  ope_is_staging = "staging" == "staging" # true

  # 論理演算子（AND）
  # 先行評価はされません。右辺の true も評価されます。
  ope_logical_and = false && true # false

  # 論理演算子（OR）
  # 先行評価はされません。右辺の false も評価されます。
  ope_logical_or = true || false # true
}
```

演算子の優先順位を上書きしたい場合は、`()` で上書きされます。

# 関数

Terraform には多くの組み込み関数があります。

詳しくはこちらをご参照ください。
https://developer.hashicorp.com/terraform/language/functions

また、関数を定義する構文はサポートされていません。
故に、Terraform HCL は関数型言語っぽいですが、そうではなく、宣言型言語と呼ばれています。

```hcl
locals {
  # Number 関数
  func_ceil_value  = ceil(3.1)      # 4
  func_floor_value = floor(3.9)     # 3
  func_max_value   = max(1, 5, 3)   # 5
  func_min_value   = min(1, 5, 3)   # 1
  func_parsed      = tonumber("42") # 42

  # String 関数
  func_upper_text = upper("hello")             # "HELLO"
  func_lower_text = lower("WORLD")             # "world"
  func_title_text = title("hello world")       # "Hello World"
  func_trimmed    = trimspace("  hello  ")     # "hello"
  func_replaced   = replace("hello", "l", "L") # "heLLo"

  # Collection 関数
  func_distinct = distinct(["a", "b", "a", "c", "d", "b"])
  # [
  #   "a",
  #   "b",
  #   "c",
  #   "d",
  # ]
  func_length = length(["a", "b"])
  # 2
  func_range = range(1, 3, 0.5)
  # [
  #   1,
  #   1.5,
  #   2,
  #   2.5,
  # ]

  # Encoding 関数
  func_csv_data  = <<-CSV
    local_id,instance_type,ami
    foo1,t2.micro,ami-54d2a63b
    foo2,t2.micro,ami-54d2a63b
  CSV
  func_instances = csvdecode(local.func_csv_data)
  # tolist([
  #   {
  #     "ami" = "ami-54d2a63b"
  #     "instance_type" = "t2.micro"
  #     "local_id" = "foo1"
  #   },
  #   {
  #     "ami" = "ami-54d2a63b"
  #     "instance_type" = "t2.micro"
  #     "local_id" = "foo2"
  #   },
  # ])

  # FileSystem 関数
  func_file = file("${path.module}/other.tf")
  # locals {
  #   # ファイル単位においてもスコープを形成しない。
  #   # そのため、違うファイルで宣言した値も参照できる。
  #   other_file_value = local.service_name
  # }

  # Date and Time 関数
  current_datetime = timestamp() # "2024-12-01T12:59:44Z"

  # Hash and Crypto 関数
  func_sha256 = sha256("hello world")
  # b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9

  # IP Network 関数
  func_cidrnetmask = cidrnetmask("172.16.0.0/12")
  # 255.240.0.0

  # Type Conversion 関数
  # エラーが発生した場合、 "not matched" を返す
  func_try_no_error = try(regex("^[a-z_]+$", "all_small_case"), "not matched") # "all_small_case"
  func_try_error    = try(regex("^[a-z_]+$", "ALL_LARGE_CASE"), "not matched") # "not matched"
  # エラーが発生しなかった場合は true, エラー発生時は false を返す
  func_can_true  = can(regex("^[a-z_]+$", "all_small_case")) # true
  func_can_false = can(regex("^[a-z_]+$", "ALL_LARGE_CASE")) # false
}
```

また、tuple や list では、関数の引数として渡す際に、`...` を後ろにつけると各値が引数に展開されます。

```tf
locals {
  # min(55, 2453, 2) を実行した結果と同じ
  func_expanding_min = min([55, 2453, 2]...) # 2
}
```

# 分岐処理

分岐をする場合は、 条件式（Conditional Expressions）を使います。
条件式は他のプログラミング言語でいう、３項演算子の記述と同じです。

ドキュメントでは、演算子としてではなく、条件式として紹介されていたので、この記事でもあえて分けました。

```tf
locals {
  if_is_production = true
  if_environment   = "dev"

  # 単純な if 式
  instance_type = local.if_is_production ? "t3.medium" : "t3.micro" # "t3.medium"

  # 複数の条件を組み合わせた if 式
  instance_count = local.if_environment == "prod" ? 3 : (local.if_environment == "staging" ? 2 : 1) # 1
}
```

# ループ処理

ループ処理を行うには、 for 式を使います。
for 式はある一つの複合型の値を受け取り、別の複合型の値に変換します。
使用感としては、 Python のリスト内包表記に近いです。

```hcl
locals {
  # 入力データ
  servers = {
    app1 = { name = "server-1", role = "web", env = "prod" }
    app2 = { name = "server-2", role = "api", env = "prod" }
    app3 = { name = "server-3", role = "db", env = "staging" }
  }

  # map 操作（object から特定の key の値のみで tuple を生成）
  server_names = [
    for key, server in local.servers :
    upper(server.name)
  ]
  # [
  #   "SERVER-1",
  #   "SERVER-2",
  #   "SERVER-3",
  # ]

  # map 操作（object から特定 key, value の組み合わせの object を生成）
  server_names_map = {
    for key, server in local.servers :
    key => upper(server.name)
  }
  # {
  #   "app1" = "SERVER-1"
  #   "app2" = "SERVER-2"
  #   "app3" = "SERVER-3"
  # }

  # filter 操作
  prod_servers = {
    for key, server in local.servers :
    key => server
    if server.env == "staging"
  }
  # {
  #   "app3" = {
  #     "env" = "staging"
  #     "name" = "server-3"
  #     "role" = "db"
  #   }
  # }

  # リストに対する map 操作
  ports = [80, 443, 8080, 8443]
  port_mapping = {
    for port in local.ports :
    port => "port-${port}"
  }
  # {
  #   "443" = "port-443"
  #   "80" = "port-80"
  #   "8080" = "port-8080"
  #   "8443" = "port-8443"
  # }

  # インデックス使用の tuple 操作
  indexed_servers = [
    for idx, port in local.ports :
    "${idx}: port-${port}"
  ]
  # [
  #   "0: port-80",
  #   "1: port-443",
  #   "2: port-8080",
  #   "3: port-8443",
  # ]

  # グループ化
  servers_by_env = {
    for server in values(local.servers) :
    server.env => server...
  }
  # {
  #   "prod" = [
  #     {
  #       "env" = "prod"
  #       "name" = "server-1"
  #       "role" = "web"
  #     },
  #     {
  #       "env" = "prod"
  #       "name" = "server-2"
  #       "role" = "api"
  #     },
  #   ]
  #   "staging" = [
  #     {
  #       "env" = "staging"
  #       "name" = "server-3"
  #       "role" = "db"
  #     },
  #   ]
  # }
}
```

# スプラット式

スプラット式（`[*]`）は、複合型の要素に対して一括でアクセスする機能を提供します。
特にコレクション型の特定の属性へアクセスする際に便利です。

```hcl
locals {
  users = [
    {
      name = "Sato"
      age  = 25
    },
    {
      name = "Suzuki"
      age  = 30
    }
  ]

  # for式を使用して全ユーザーの名前を取得
  all_names_for = [for user in local.users : user.name] # ["Sato", "Suzuki"]

  # 全ユーザーの名前を取得
  all_names_splat = local.users[*].name # ["Sato", "Suzuki"]
}
```

# 演習問題（FizzBuzz）

これまで紹介した構文を使って FizzBuzz の実装例を書きました。

```hcl
locals {
  # 1から100までの数列を作成
  numbers = range(1, 101)

  # FizzBuzzのロジックを適用
  fizzbuzz_list = [
    for n in local.numbers :
    n % 15 == 0 ? "FizzBuzz" :
    n % 3 == 0 ? "Fizz" :
    n % 5 == 0 ? "Buzz" :
    tostring(n)
  ]

  # 改行して表示
  fizzbuzz = join("\n", local.fizzbuzz_list)
}
```

ここまでくると、Terraform も意図せずチューリング完全な言語になってしまったのかと思ってしまいますが、そこまで計算能力が完備されている言語ではないです。

というのも、無限ループや再帰的な参照ができないので、そこまで自由度が高くはなってないんですよね。
ただ、これはインフラストラクチャ管理の言語としては設計的に優れていると思ってます。

データ記述言語（json, yaml など）では冗長的な書き方になって苦しい場面をいい感じで便利にかける構文をサポートしてくれていますし、また、汎用言語（Ruby, Groovy など）を設定記述用に用いていると、どうしてもどの言語機能がほぼフルで使えてしまうので、自由度が高くなりすぎてしまう問題が起きてしまいます。

Terraform はまさにこの中間の機能を提供してくれていて、その中庸さがここまでの普及に貢献していると個人的に思っています。

# おわりに

公式ドキュメントの一部のサンプルコードに古い記法が残っていたので、プルリク送ってみて無事マージしていただきました。

https://github.com/hashicorp/terraform/pull/36065

Terraform の人、レスポンス早かったです。
ボランティア駆動の OSS でなければ、１営業日以内でのレスポンスが普通だったりするのかな。

Thanks anyway.