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"
}
}
} |
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
}
} |
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"
}
} |
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
} |
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]
} |
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
} |
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.