How to Use a Non-unique Key in Terraform Resource Loop to Create Unique Resources

Viewed 879

I'd like to create every type of DNS record for a domain using a generic module, and so be able to call it with something like:

module "example_com_dns" {
  source = "[PATH_TO_MODULES]/modules/dns"

  domain  = "example.com"

  a_records = {
    "@"    = [SOME IP]
    "www"  = [SOME IP]
    "home" = [SOME IP]
  }

  txt_records = {
    "@"                    = "txt-foobar1"
    "@"                    = "txt-foobar2"
    "mail._domainkey.self" = "foobar"
  }

  mx_entries = {
     "10" = "mail.someprovider.com"
     "20" = "mail2.someprovider.com"
  }

  cname_records {
    "cname-foo" = "cname-bar
  }
}

I have something that works fine for A, CNAME , and MX records, but TXT has an edge case which I need to work around. My module has resource blocks for each type of record, which run through loops. I'll just paste the TXT one, but they're all the same:

resource "digitalocean_record" "this_txt_record" {
  for_each = var.txt_records

  domain = var.domain
  type   = "TXT"
  name   = each.key
  value  = each.value
}

This all works fine, except for the fact that since there are 2 records with "@" for their key, it results in only the last one being created (in my example above, this being "txt-foobar2"):


...

  # module.example_com.digitalocean_record.this_txt_record["@"] will be created
  + resource "digitalocean_record" "this_txt_record" {
      + domain = "example.com"
      + fqdn   = (known after apply)
      + id     = (known after apply)
      + name   = "@"
      + ttl    = (known after apply)
      + type   = "TXT"
      + value  = "txt-foobar2"
    }

I'd like for it to create both "txt-foobar1" and "txt-foobar2", even given non-unique keys in the map.

Perhaps this is the wrong way and I just need to figure out a clever loop for for parsing this structure instead?:

  txt_records = [
    { "@" = "foo" },
    { "@" = "bar"},
    { "mail._domainkey.self" = "foobar"}
  ]

If so, I'm currently failing there too :)

2 Answers

Resources cannot be created by for_each'ing a list since there must be a unique key that will become part of the terraform resource name. List indexes cannot be a reliable key since your TF plan will be all messed up if you reorder items in the list.

Maps on the other hand do have unique keys by definition.

You can generate a map from a list though! I found this little trick here. Note you additionally need to manually compute the unique map key (${txt_record[0]}=${txt_record[1]} in the example below).

Your resources with updates in place:

module "example_com_dns" {
  ...

  txt_records = [
    ["@",                     "txt-foobar1"],
    ["@",                     "txt-foobar2"],
    ["mail._domainkey.self",  "foobar"],
  ]
}
resource "digitalocean_record" "this_txt_record" {
  for_each = {for txt_record in var.txt_records: "${txt_record[0]}=${txt_record[1]}" => txt_record}

  domain = var.domain
  type   = "TXT"
  name   = each.value[0]
  value  = each.value[1]
}

or slightly more verbose if you prefer:

module "example_com_dns" {
  ...

  txt_records = [
    {name: "@", value: "txt-foobar1"},
    {name: "@", value: "txt-foobar2"},
    {name: "mail._domainkey.self", value: "foobar"},
  ]
}
resource "digitalocean_record" "this_txt_record" {
  for_each = {for txt_record in var.txt_records: "${txt_record.name}=${txt_record.value}" => txt_record}

  domain = var.domain
  type   = "TXT"
  name   = each.value.name
  value  = each.value.value
}

Alternative way to already given one is to use the following:

 variable "txt_records" {
  default = {
       "@" = ["foo", "bar"],
       "mail._domainkey.self" = ["foobar"]
       }
 }

Then you can flatten the txt_records using:

locals {

  txt_records_flat = merge([
      for key, values in var.txt_records:
        {for value in values: 
           "${key}-${value}" => {"record_name" = key, "record_value" = value}
        }
  ]...)
}

which results in local.txt_records_flat of:

{
  "@-bar" = {
    "record_name" = "@"
    "record_value" = "bar"
  }
  "@-foo" = {
    "record_name" = "@"
    "record_value" = "foo"
  }
  "mail._domainkey.self-foobar" = {
    "record_name" = "mail._domainkey.self"
    "record_value" = "foobar"
  }
}

Then you use it:

resource "digitalocean_record" "this_txt_record" {
  for_each = local.txt_records_flat

  domain = var.domain
  type   = "TXT"
  name   = each.value.record_name
  value  = each.value.record_value
}
Related