Struggling with For - trying to create subnet/route table association

Looking for someone to assist in helping me understand the use of For in Terraform. I don’t come from a programming background and so far the only thing I’m really struggling with in Terraform is managing the inputs/outputs of various resources. I remember enough of my programming classwork to know what all the basic data types are, string, boolean, etc. Where I start to get confused is when we begin to combine those into various constructs like maps, objects, maps of lists, maps of objects, etc. Most of the Terraform documentation seems to be written at a level that would be easy to grasp if you came from another language or programming background but… that isn’t me.

Here’s the documentation for For and creating an object…

[for k, v in var.map : length(k) + length(v)]

For a map or object type, like above, the k symbol refers to the key or attribute name of the current element.

Ok, what is v? Assuming it means value, what if I just want to return k,v or k and not apply length to them? What if I want multiple attributes? Is For even what I’m looking for here as it appears to be applying grouping/filtering/conditional logic to results vs just returning data? I think I know how to do what I’m trying to do using outputs, but we can’t pass those to a child module from a parent module…

Use Case: I’ve got a project where I have created a map of objects using tfvars, passed that down to a networking module, and use it to create 8 subnets. All that works great.

Networking Module

variables.tf

variable "subnets" {
  type = map(object({
    name                = string
    address_prefix      = list(string)
    service_delegations = bool
    virtual_network_name = string
    resource_group_name = string
  }))
}

main.tf

resource "azurerm_subnet" "subnets" {
  for_each             = var.subnets
  name                 = each.key
  address_prefixes     = each.value.address_prefix
  virtual_network_name = each.value.virtual_network_name
  resource_group_name  = each.value.resource_group_name
}

I then pass that same variable to a child module of the networking module to create a set of route tables, one for each subnet. This also works great (although it took awhile for me to get the nested for_each syntax right). Note that I used the subnet names and a prefix to create the route table names…

Firewall Child Module
main.tf

resource "azurerm_route_table" "default_routes" {
  for_each            = var.subnets
  name                = "rt-${each.value.name}"
  location            = var.resourcegroup_location
  resource_group_name = each.value.resource_group_name

  dynamic "route" {
    for_each =       each.value.address_prefix
  
    content {
      name = each.value.name
      address_prefix = route.value
      next_hop_type = "VnetLocal"
    }
  }
  
  route {
    name                   = "Default"
    address_prefix         = "0.0.0.0/0"
    next_hop_type          = "VirtualAppliance"
    next_hop_in_ip_address = var.lb_internal_ipaddress
  }
}

So now I need to do my associations, I need the subnet ID and the route table ID. Oops, the subnets object map obviously doesn’t have that as Azure creates it. Where I’ve needed the subnet ID before in the project I’ve used an output but that won’t work here, as the module is a child module of the module creating the subnets. So I need to construct an object (I assume, because creating a map of strings doesn’t work because a string is a scary primitive and can’t be used as an attribute reference). I need the subnet ID and the corresponding subnet name. Then I need to match that subnet ID to the route table ID using rt-subnet name. Having some difficulty with that syntax as well, I can’t seem to use a string to preface a lookup value i.e. azurerm_route_table.default_routes["rt-"each.value.name].id

Association code

resource "azurerm_subnet_route_table_association" "subnet_associations" {
  for_each = var.subnet_associations
  subnet_id = each.value.id
  route_table_id = azurerm_route_table.default_routes[each.value.name].id
}

Child module call

module "firewall" {
  source = "./modules/Fortigate/Active-Active-ELB-ILB"
  subnets = var.subnets
  subnet_associations = {for subnet in azurerm_subnet.subnets: subnet.name => subnet.id}

Any assistance is greatly appreciated.

In Terraform, “for” is used for constructing complex data structures such as lists and maps by iterating over an existing collection. The syntax of the “for” expression is:

for variable in list or map : expression

Here, the <variable> represents the current item in the iteration, and <list or map> is the collection to iterate over. The <expression> is evaluated for each item in the collection, and the result of each evaluation is returned in the resulting list or map.

In your example, the expression:

[for k, v in var.map : length(k) + length(v)]

iterates over the map var.map, and for each key-value pair, returns the sum of the length of the key and the length of the value.

Now, to answer your questions:

  • What is v?
    Assuming it means value, you are correct. “v” refers to the value of the current key-value pair.
  • What if I just want to return k,v or k and not apply length to them? What if I want multiple attributes?
    You can use the expression to return any combination of attributes of the key-value pair. For example, if you want to return both the key and the value, you can use the following expression:

[for k, v in var.map : { key = k, value = v }]

This will return a list of objects, where each object has a “key” attribute and a “value” attribute.

  • Is For even what I’m looking for here as it appears to be applying grouping/filtering/conditional logic to results vs just returning data?
    Yes, “for” is used for constructing complex data structures and can be used for filtering or transforming data. However, it can also be used to just return data without applying any transformations.

I hope this helps you understand how to use “for” in Terraform. Let me know if you have any other questions or need further clarification.

Even for people with a strong programming background, it is still confusing that Terraform has two list-like data types, and two map-like data types:

List-like:

  • A tuple is an ordered sequence of values
  • A list is an ordered sequence of values that are all of the same type

Map-like:

  • An object is a mapping of string keys to values of various types, and sometimes the specific valid keys and valid value types might also be defined
  • A map is a mapping of string keys to values that are all of the same type

Technically no - this is creating a tuple.

It may help to pause and realise there are effectively 4 different kinds of for expression:

  • list-like to list-like
  • list-like to map-like
  • map-like to list-like
  • map-like to map-like

The input depends on what type of thing appears after in, and determines whether you need one variable name or two after the for. (One variable name for list-like input, two variable names for map-like input.)

The output depends on the outer punctuation - [ ] for list-like output, { } for map-like output. When producing map-like output, the result expression, after the colon, needs to contain a =>, when producing list-like output it must not.

Yes, value. Write whatever you want after the colon.

Maybe? You haven’t really said what you want at this point.

Right, parent-to-child would be input variables instead of output variables, which flow child-to-parent.

It’s a little weird to have name as an attribute of an object in a map, since that seems redundant with the map’s key. And indeed…

… you’re using the map key here, and not using each.value.name.

Though here you are using each.value.name. Best to pick one and stick with it.

Um… what?

Well, yes, this syntax is wrong - you already used the correct syntax when defining the name earlier…

… except you are currently only adding the rt- prefix to the name attribute of the azurerm_route_table resources, you’re not adding the rt- prefix to the keys within the for_each, so actually you want to leave the prefix out when referencing them.


You chose to define your subnet_associations in this way:

meaning you are now referencing the contents of this variable incorrectly in

It should be:

resource "azurerm_subnet_route_table_association" "subnet_associations" {
  for_each = var.subnet_associations
  subnet_id = each.value
  route_table_id = azurerm_route_table.default_routes[each.key].id
}

so that it matches the shape of the data you created with your for expression.

For the primitive I was referencing this:

│ Error: Unsupported attribute
│
│   on modules\networking\modules\Fortigate\Active-Active-ELB-ILB\00-main.tf line 76, in resource "azurerm_subnet_route_table_association" "subnet_associations":
│   76:   subnet_id = each.value.id
│     ├────────────────
│     │ each.value is "/subscriptions/blahblah/resourceGroups/rg-network-eastus/providers/Microsoft.Network/virtualNetworks/vnet-prod-eastus/subnets/snet-web-prod-eastus"
│
│ Can't access attributes on a primitive-typed value (string).

but I can’t remember which iteration of my for statement I used there, I’ve had a few.

Thanks for the detailed responses, I think that makes things a little clearer. So I was specifically trying to input… well, I would have said a list but I guess technically a map of subnet ID’s and their names into the child module since I used the subnet name for the creation of the route table resource. If you would, see if I have this correctly.

First I create the for expression for the input:

module "firewall" {
  source = "./modules/Fortigate/Active-Active-ELB-ILB"
  subnets = var.subnets
  subnet_associations = {for name,id in azurerm_subnet.subnets: name => id}
  resourcegroup_name = azurerm_resource_group.rg-network.name
  resourcegroup_location = azurerm_resource_group.rg-network.location
  }

Then I create the variable for the input in variables.tf (child module).

 variable "subnet_associations" {
  type = map(object({
    id = string
    name = string
  }))
 }

Then the route_table_association code in main.tf (child module)

resource "azurerm_subnet_route_table_association" "subnet_associations" {
  for_each = var.subnet_associations
  subnet_id = each.value.id
  route_table_id = azurerm_route_table.default_routes[each.key].id
}

This runs. If I understand you correctly, each.key here is actually pulling the key from azurerm_route_table.default_routes and not subnet_associations. i.e. if I created a route_table with the name rt-subnetA, one of my keys will be rt-subnetA, the key value isn’t coming from the for_each source of var.subnet_associations, thus I don’t need the “rt-”${}. Which… makes sense, I also had some confusion around this when I created the subnets as you noticed. The part I’m still trying to wrap my head around is how that is matching with the subnet_id. In azurerm_route_table I’m creating one route table per subnet and then I want to associate each subnet with the route table created for it. I’m not even using the name portion of my variable subnet_associations here correct?

As written, I would expect it to spit out something many-many.

subnetA_id     rt-subnetA
subnetA_id     rt-subnetB
subnetB_id     rt-subnetA
subnetB_id     rt-subnetB

Whereas what I’m really looking for

subnetA_id     rt-subnetA
subnetB_id     rt-subnetB

A terraform plan only shows 16 creates (I have 8 subnets so 8 route tables and 8 associations) which is the end goal… I’m just not sure what exactly it’s doing and can’t tell because the route table ID won’t be known until after apply.

As to the subnet code and key/name issue. Since those subnets have already been created via apply, the non-destructive path would just be to remove the name attribute from tfvars since I’m using the key (they’re identical anyway) and to use each.key in the route table creation correct?

So azurerm_route_table would look like:

resource "azurerm_route_table" "default_routes" {
  for_each            = var.subnets
  name                = "rt-${each.key}"
  location            = var.resourcegroup_location
  resource_group_name = each.value.resource_group_name

and in tfvars in root one of my subnets would look like:

subnets = {
  snet-edge-out-eastus = {
    address_prefix      = ["192.168.0.0/28"]
    service_delegations = false
    virtual_network_name = "vnet-edge-eastus"
    resource_group_name = "rg-network-eastus"
  }

instead of

subnets = {
  snet-edge-out-eastus = {
    name                = "snet-edge-out-eastus"
    address_prefix      = ["192.168.0.0/28"]
    service_delegations = false
    virtual_network_name = "vnet-edge-eastus"
    resource_group_name = "rg-network-eastus"
  }

Ok, I think I get it now. For some reason I keep confusing the name attribute as the key identifier of these resources created by for_each loops. In the case of subnets that didn’t matter since the two values were identical, but in the route_table case if I look at my plan I can see it creating the 8 route tables and all are identified using the subnet name, not the name attribute in the resource block.

 module.networking["networking"].module.firewall.azurerm_route_table.default_routes["snet-app-prod-eastus"] will be created

Thus each.key works fine.