🕌

Terraformで多次元mapをシュッと走査する方法

2024/01/18に公開

はじめに

terraformでmoduleを実装していると多次元のmapを走査してリソースを作りたいケースが出てくるかと思います。例えばGoogle CloudでIAMを管理するコードは以下のように実装できると楽です。

module "iam" {
  source = "./modules/iam"
  member_roles = {
    "group:hogehoge@example.com" = [
      "roles/cloudsql.client",
      "roles/secretmanager.secretAccessor"
    ]
    "user:sho@example.com" = [
      "roles/cloudsql.client",
      "roles/secretmanager.secretAccessor"
    ]
  }
  project = "test-project"
}

member_rolesでキーにメンバーを値にロールの一覧を渡しています。これをmodule側で走査して必要な数のIAMを設定します(この場合だと4つですね)。

一般的なプログラミング言語であればfor文をネストすれば簡単に実装できますが、terraformのループの回し方は独特なので意外と難しいです。特に多次元mapや配列は実装の仕方によっては更新時に不要な再作成が走ってしまうので注意しながら実装する必要があります。

微妙(というか駄目)な例

ひと目思い浮かぶのは一次元配列に変換してからfor文で回す実装かと思います。

locals {
  member_roles = flatten([
    for member, roles in var.member_roles
    : [
      for role in roles
      : {
        member = member
        role   = role
      }
    ]
  ])
}

resource "google_project_iam_member" "member_roles" {
  for_each = { for i, members in local.member_roles : i => members }
  project  = var.project
  role     = each.value.role
  member   = each.value.member
}

flatten関数を使うことでvar.member_rolesを要素が4個の配列に変換できます。この時local.member_rolesは次のような値になっています。

member_roles = [
  {
    member = "group:hogehoge@example.com"
    role   = "roles/cloudsql.client"
  },
  {
    member = "group:hogehoge@example.com"
    role   = "roles/secretmanager.secretAccessor"
  },
  {
    member = "user:sho@example.com"
    role   = "roles/cloudsql.client"
  },
  {
    member = "user:sho@example.com"
    role   = "roles/secretmanager.secretAccessor"
  },
]

このコードをterraform planすると期待通り4つのgoogle_project_iam_member.member_rolesを作ろうとしてくれます。

Terraform will perform the following actions:

  # module.iam.google_project_iam_member.member_roles["0"] will be created
  + resource "google_project_iam_member" "member_roles" {
      + etag    = (known after apply)
      + id      = (known after apply)
      + member  = "group:hogehoge@example.com"
      + project = "test-project"
      + role    = "roles/cloudsql.client"
    }

  # module.iam.google_project_iam_member.member_roles["1"] will be created
  + resource "google_project_iam_member" "member_roles" {
      + etag    = (known after apply)
      + id      = (known after apply)
      + member  = "group:hogehoge@example.com"
      + project = "test-project"
      + role    = "roles/secretmanager.secretAccessor"
    }

  # module.iam.google_project_iam_member.member_roles["2"] will be created
  + resource "google_project_iam_member" "member_roles" {
      + etag    = (known after apply)
      + id      = (known after apply)
      + member  = "user:sho@example.com"
      + project = "test-project"
      + role    = "roles/cloudsql.client"
    }

  # module.iam.google_project_iam_member.member_roles["3"] will be created
  + resource "google_project_iam_member" "member_roles" {
      + etag    = (known after apply)
      + id      = (known after apply)
      + member  = "user:sho@example.com"
      + project = "test-project"
      + role    = "roles/secretmanager.secretAccessor"
    }

Plan: 4 to add, 0 to change, 0 to destroy.

ここまでは良いのですが、問題は更新する場合です。

var.member_rolesに新しくメンバーを追加したり、削除したりしようとするとplanで意図しない再作成が含まれてしまいます。これはflatten関数で生成する配列が順序制御されていないためです。

よって、この実装は再作成が伴っても構わないリソースでしか使うことができません。

Goodな例

次のようにfor文を回す配列と設定値に使う配列を分けることで再作成が伴わないよりスマートな実装になります。

locals {
  keys = distinct(flatten([
    for member, roles in var.member_roles
    : [
      for role in roles
      : "${member}--${role}"
    ]
  ]))
  bindings_by_member = distinct(flatten([
    for member, roles in var.member_roles
    : [
      for role in roles
      : {
        member = member
        role   = role
      }
    ]
  ]))
  bindings = zipmap(
    local.keys,
    local.bindings_by_member,
  )
}

resource "google_project_iam_member" "member_roles" {
  for_each = toset(local.keys)
  project  = var.project
  role     = local.bindings[each.key].role
  member   = local.bindings[each.key].member
}

keysはmemberとroleを「--」で文字列結合した値の配列で、bindings_by_memberはmemberとroleのmapからなる配列です。distinct関数は配列から重複を削除する関数です。

bindingsは2つの配列からmapを生成する配列です。zipmap関数はあまり馴染みのない関数かもしれませんが公式の例がとてもわかりやすいので以下をご覧ください。

https://developer.hashicorp.com/terraform/language/functions/zipmap

冒頭に記載した例ですとそれぞれの変数は以下のようになります。

keys = [
  "group:hogehoge@example.com--roles/cloudsql.client",
  "group:hogehoge@example.com--roles/secretmanager.secretAccessor",
  "user:sho@example.com--roles/cloudsql.client",
  "user:sho@example.com--roles/secretmanager.secretAccessor",
]
bindings_by_member = [
  {
    member = "group:hogehoge@example.com"
    role   = "roles/cloudsql.client"
  },
  {
    member = "group:hogehoge@example.com"
    role   = "roles/secretmanager.secretAccessor"
  },
  {
    member = "user:sho@example.com"
    role   = "roles/cloudsql.client"
  },
  {
    member = "user:sho@example.com"
    role   = "roles/secretmanager.secretAccessor"
  },
]
bindings = {
  "group:hogehoge@example.com--roles/cloudsql.client" = {
    member = "group:hogehoge@example.com"
    role   = "roles/cloudsql.client"
  }
  "group:hogehoge@example.com--roles/secretmanager.secretAccessor" = {
    member = "group:hogehoge@example.com"
    role   = "roles/secretmanager.secretAccessor"
  }
  "user:sho@example.com--roles/cloudsql.client" = {
    member = "user:sho@example.com"
    role   = "roles/cloudsql.client"
  }
  "user:sho@example.com--roles/secretmanager.secretAccessor" = {
    member = "user:sho@example.com"
    role   = "roles/secretmanager.secretAccessor"
  }
}

見て頂くと分かるようにkeysの各値でbindingsの中身を取り出せるようになっているのでこの2つの変数を使ってgoogle_project_iam_member.member_rolesを生成します。

以上、Terraformで多次元mapをシュッと実装する方法でした。

なお、今回話した内容の元ネタはこちらになります。

https://github.com/terraform-google-modules/terraform-google-iam/blob/v7.7.1/modules/helper/main.tf

terraform-google-modulesには優れた実装が数多くあるのでGoogle Cloudを利用されていない方でも読まれると参考になる箇所が多々あるかと思います。

Discussion