Today I needed a double for_each in my Terraform configuration; the ability to for_each over one thing, and at the same time for_each over another thing.
Here’s the context:
I want to produce two Azure Private DNS Zones, with records inside each of them, but conditionally. Think of it as ‘zones’ – zone A and zone B will be unique in their identifiers, but have commonalities in the IP addresses used.
I want do to this conditionally (a zone may not always exist) but also without repeating myself in code.
Lets start with a variable Map of my zones:
variable "zoneversions" { default = { "zonea" = { "zonename" = "a", "first3octets" = "10.9.3" }, "zoneb" = { "zonename" = "b", "first3octets" = "10.9.4" } } } |
Here I’m creating an object that will work with for_each syntax. You’ll note I’m including additional attributes that are unique to each zone – this will come in handy later.
This variable allows me to create my Azure DNS private zones like this:
resource "azurerm_private_dns_zone" "zones-privatedns" { for_each = var.zoneversions name = "${each.value.zonename}.domain.com" resource_group_name = azurerm_resource_group.srv-rg.name } } |
This is using the “each.value” syntax, referencing the attributes of each zone. This terraform will produce the Private DNS zones described in the image above.
Now I want to populate each zone with records.
First, I’m going to use a local variable (could be a regular variable too) that will create a map of keys (common parts of server names) and values (last octet of the ip addresses):
locals { ipaddresses = { web = ".3" rdp = ".4" dc = ".10" db = ".11" } } |
For each zone that I have (a or b), I want to create a DNS record for each key in this map (hence the double for_each). Terraform won’t let you combine a for_each and count, and it doesn’t natively support 2 for_each expressions.
After a lot of trial and error (using terraform console to test) I came up with the code below. This article with a post by ‘apparentlysmart’ was a big help in the final task and helped me understand the structure of what I was trying to build.
I need 2 new local variables. The first will produce a flattened list of the combinations I’m looking for. And then since for_each only interacts with maps, I need a second local to convert it into that object type.
zonedips-list = flatten([ # Produce a list of maps, containing a name and IP address for each zone we specify in our variable for zones in var.zoneversions: [ for servername,ips in local.ipaddresses: { zonename = "${zones.zonename}" name = "${zones.zonename}${servername}" ipaddress = "${zones.first3octets}${ips}" } ] ]) zonedips-map = { # Take the list, and turn it into a map, so we can use it in a for_each for obj in local.zonedips-list : "${obj.name}" => obj # this means set the key of our new map to be $obj.name (hfx23-ti-web1) and => means keep the attributes of the object the same as the original } |
Then I can use that second local when defining a single “azurerm_private_dns_a_record” resource:
resource "azurerm_private_dns_a_record" "vm-privaterecords" { for_each = local.zonedips-map name = each.value.name zone_name = azurerm_private_dns_zone.zones-privatedns[each.value.zonename].name resource_group_name = azurerm_resource_group.srv-rg.name ttl = 300 records = [each.value.ipaddress] } |
This is where the magic happens. Because my map “zonedips-map” has attributes for each object, I can reference them with the ‘each.value’ syntax. So the name field of my DNS record will be equivalent to “${zones.zonename}${servername}”, or “aweb/bweb” as the for_each iterates. To place these in the correct zone, I’m using index selection on the resource, within the “zone_name” attribute – this says refer to the private_dns_zone with the terraform identifier “zones-privatedns” but an index (since there are multiple) that matches my version name.
This is where terraform console comes in real handy; I can produce a simple terraform config (without an AzureRM provider) that contains these items, with either outputs, or a placeholder resource (like a file).
For example, take the terraform configuration below, do a “terraform init” on it, and then “terraform console” command.
terraform { backend "local" { } } locals { zonedips-list = flatten([ for zones in var.zoneversions: [ for servername,ips in local.ipaddresses: { zonename = "${zones.zonename}" name = "${zones.zonename}${servername}" ipaddress = "${zones.first3octets}${ips}" } ] ]) zonedips-map = { for obj in local.zonedips-list : "${obj.name}" => obj } ipaddresses = { web = ".3" rdp = ".4" dc = ".10" db = ".11" } } variable "zoneversions" { default = { "zonea" = { "zonename" = "a", "first3octets" = "10.9.3" }, "zoneb" = { "zonename" = "b", "first3octets" = "10.9.4" } } } resource "local_file" "test" { for_each = local.zonedips-map filename = each.value.name content = each.value.ipaddress } |
You can then explore and display the contents of the variables or locals by calling them explicitly in the console:
So we can display the contents of our flattened list:
And then the produced map:
Finally, we can do a “terraform plan”, and look at the file resources that would be created (I shrunk this down to just 2 items for brevity):
You can see the key here in the ‘content’ and ‘filename’ attributes.
Awesome blog on nested maps.
Very well done. I was struggling to create a map given 2 lists. This is exactly what I needed.
I was struggling between using maps or locals thinking there’d no way to cleanly use them side by side.
Never thought to generate the maps with the locals. This is slick!
I was similarly bashing my head against this same sort of for-each in for-each wall with vnet and subnet deployment. This finally helped me understand what is going on here. Thank you!