Search code examples
amazon-web-servicesterraformterraform-provider-aws

Access Sub Keys In Nested Maps


Currently learning terraform and I am trying to create 2 VPCs (dev and stg) below. I would like to populate cidr_block and availability_zone in subnets.tf by accessing ap-southeast-1* and public_cidr_block in the map I created. What is the proper way to do this?

variables.tf

variable "vpcs" {
  type        = map(object({}))
  description = "VPCs"
}

vpc.tfvars

vpcs = {
  "dev" = {
     "ap-southeast-1a" = {
        "public_cidr_block" : "10.1.1.0/24",
        "app_cidr_block" : "10.1.2.0/24",
        "db_cidr_block" : "10.1.3.0/24"
      },
      "ap-southeast-1b" = {
        "public_cidr_block" : "10.1.4.0/24",
        "app_cidr_block" : "10.1.5.0/24",
        "db_cidr_block" : "10.1.6.0/24"
      }
    },
    "stg" = {
       "ap-southeast-1a" = {
         "public_cidr_block" : "10.1.1.0/24",
         "app_cidr_block" : "10.1.2.0/24",
         "db_cidr_block" : "10.1.3.0/24"
       },
       "ap-southeast-1b" = {
         "public_cidr_block" : "10.1.4.0/24",
         "app_cidr_block" : "10.1.5.0/24",
         "db_cidr_block" : "10.1.6.0/24"
       }
     }
   }

vpc.tf

resource "aws_vpc" "vpc" {
  for_each             = var.vpcs
  cidr_block           = var.vpc_cidr_block
  instance_tenancy     = var.instance_tenancy
  enable_dns_support   = var.enable_dns_support
  enable_dns_hostnames = var.enable_dns_hostnames
}

subnets.tf

resource "aws_subnet" "public_subnet" {
  for_each                = var.vpcs
  vpc_id                  = each.key
  cidr_block              = ???
  availability_zone       = ???
  map_public_ip_on_launch = var.map_public_ip_on_launch
}

Solution

  • Fro your question I'm understanding that you have a single VPC and that your var.vpcs map is, despite its name, intended to represent a set of three subnets for each availability zone in each of two environments.

    To start then, I'd redefine the input variables as follows, since your current definition doesn't work for the data you want to represent: you've declared a map of empty objects, which therefore provides nowhere to represent the availability zones and subnets.

    variable "vpc_subnets" {
      type = map(map(object({
        public_cidr_block = string
        app_cidr_block    = string
        db_cidr_block     = string
      })))
    }
    
    variable "vpc_cidr_blocks" {
      type = map(string)
    }
    

    A value for these input variables might then be defined like this:

    vpc_cidr_blocks = {
      "dev" = "10.1.0.0/16"
      "stg" = "10.1.0.0/16"
    }
    vpc_subnets = {
      "dev" = {
        "ap-southeast-1a" = {
          public_cidr_block = "10.1.1.0/24",
          app_cidr_block    = "10.1.2.0/24",
          db_cidr_block     = "10.1.3.0/24"
        }
        "ap-southeast-1b" = {
          public_cidr_block = "10.1.4.0/24",
          app_cidr_block    = "10.1.5.0/24",
          db_cidr_block     = "10.1.6.0/24"
        }
      }
      "stg" = {
        "ap-southeast-1a" = {
          public_cidr_block = "10.1.1.0/24",
          app_cidr_block    = "10.1.2.0/24",
          db_cidr_block     = "10.1.3.0/24"
        }
        "ap-southeast-1b" = {
          public_cidr_block = "10.1.4.0/24",
          app_cidr_block    = "10.1.5.0/24",
          db_cidr_block     = "10.1.6.0/24"
        }
      }
    }
    

    You can declare the VPCs in a similar way to how you already declared them:

    resource "aws_vpc" "vpc" {
      for_each = var.vpc_cidr_blocks
    
      cidr_block           = each.value
      instance_tenancy     = var.instance_tenancy
      enable_dns_support   = var.enable_dns_support
      enable_dns_hostnames = var.enable_dns_hostnames
    }
    

    However, the structure of var.vpc_subnets does not yet match the requirements of for_each, because it contains one element per VPC, rather than one element per subnet. Therefore you'll need to first transform that data structure into a single flat collection with one element per subnet. A common way to do that is using the flatten function, as described in Flattening nested structures for for_each.

    The following example adapts the example in the Terraform documentation for your slightly-different structure where the VPC cidr_blocks and subnet cidr_blocks are represented separately and where there is more than one subnet per availability zone:

    locals {
      vpc_subnets = flatten([
        for env, azs in var.vpc_subnets : [
          for az, subnets in azs : [
            for attr_name, cidr_block in subnets : {
              env         = env
              az          = az
              type        = trimsuffix(attr_name, "_cidr_block")
              cidr_block  = cidr_block
            }
          ]
        ]
      ])
    }
    

    With this definition, local.vpc_subnets is a list with one element per subnet, and with the environment, availability zone, and subnet name information encoded as part of the element value rather than as map keys.

    This list can therefore be transformed one more time to produce a map with one element per subnet, using the three discriminating attributes to form a compound key for each element, like this:

    resource "aws_subnet" "all" {
      for_each = {
        for subnet in local.vpc_subnets :
        "${subnet.env}:${subnet.az}:${subnet.type}" => subnet
      }
    
      vpc_id                  = aws_vpc.vpc[each.value.env].id
      cidr_block              = each.value.cidr_block
      availability_zone       = each.value.az
      map_public_ip_on_launch = var.map_public_ip_on_launch
    }
    

    Here I made the totally-arbitrary decision to join the discriminating keys together using colons :, which means that (given the example values I included above) this block declares the following resource instance addresses:

    • aws_subnet.all["dev:ap-southeast-1a:public"]
    • aws_subnet.all["dev:ap-southeast-1a:app"]
    • aws_subnet.all["dev:ap-southeast-1a:db"]
    • aws_subnet.all["dev:ap-southeast-1b:public"]
    • aws_subnet.all["dev:ap-southeast-1b:app"]
    • aws_subnet.all["dev:ap-southeast-1b:db"]
    • aws_subnet.all["stg:ap-southeast-1a:public"]
    • aws_subnet.all["stg:ap-southeast-1a:app"]
    • aws_subnet.all["stg:ap-southeast-1a:db"]
    • aws_subnet.all["stg:ap-southeast-1b:public"]
    • aws_subnet.all["stg:ap-southeast-1b:app"]
    • aws_subnet.all["stg:ap-southeast-1b:db"]

    Above I assumed that you'd prefer to declare all of the subnets using a single resource block, but it's also possible to shape this differently and use a separate resource block for each of the three subnet types. In that case you can use an additional step to split the flat list of all subnets into three separate lists that each contain only one subnet type.

    locals {
      vpc_subnets_by_type = {
        for subnet in local.vpc_subnets :
        subnet.type => subnet...
      }
    }
    

    This particular for expression is using the extra ... symbol, used for grouping results. This means that the result is a map of lists where the map keys are the three subnet types and each lists contains only the subnets of one type.

    You can then use this data structure to write out three resource blocks similar to the one above but where each one uses only the subnets of a particular type. For example:

    resource "aws_subnet" "public" {
      for_each = {
        for subnet in local.vpc_subnets_by_type["public"] :
        "${subnet.env}:${subnet.az}" => subnet
      }
    
      vpc_id                  = aws_vpc.vpc[each.value.env].id
      cidr_block              = each.value.cidr_block
      availability_zone       = each.value.az
      map_public_ip_on_launch = var.map_public_ip_on_launch
    }
    
    resource "aws_subnet" "app" {
      for_each = {
        for subnet in local.vpc_subnets_by_type["app"] :
        "${subnet.env}:${subnet.az}" => subnet
      }
    
      vpc_id                  = aws_vpc.vpc[each.value.env].id
      cidr_block              = each.value.cidr_block
      availability_zone       = each.value.az
      map_public_ip_on_launch = var.map_public_ip_on_launch
    }
    
    resource "aws_subnet" "db" {
      for_each = {
        for subnet in local.vpc_subnets_by_type["db"] :
        "${subnet.env}:${subnet.az}" => subnet
      }
    
      vpc_id                  = aws_vpc.vpc[each.value.env].id
      cidr_block              = each.value.cidr_block
      availability_zone       = each.value.az
      map_public_ip_on_launch = var.map_public_ip_on_launch
    }
    

    This variation therefore declares the following resource instances, using a separate resource for each subnet type:

    • aws_subnet.public["dev:ap-southeast-1a"]
    • aws_subnet.public["dev:ap-southeast-1b"]
    • aws_subnet.public["stg:ap-southeast-1a"]
    • aws_subnet.public["stg:ap-southeast-1b"]
    • aws_subnet.app["dev:ap-southeast-1a"]
    • aws_subnet.app["dev:ap-southeast-1b"]
    • aws_subnet.app["stg:ap-southeast-1a"]
    • aws_subnet.app["stg:ap-southeast-1b"]
    • aws_subnet.db["dev:ap-southeast-1a"]
    • aws_subnet.db["dev:ap-southeast-1b"]
    • aws_subnet.db["stg:ap-southeast-1a"]
    • aws_subnet.db["stg:ap-southeast-1b"]

    This variation would presumably be better if the configuration settings for each type of subnet need to be significantly different.