Pattern to handle optional dynamic blocks

What’s a good way to handle optional dynamic blocks, depending on existence of map keys?

Example: Producing aws_route53_record resources, where they can have either a “records” list, or an “alias” block, but not both. I’m using Terraform 0.12.6.

I’m supplying the entire Route53 zone YAML as a variable, through yamldecode():

zone_id: Z987654321
records:
- name: route53test-plain.example.com
  type: A
  ttl: 60
  records:
  - 127.0.0.1
  - 127.0.0.2

- name: route53test-alias.example.com
  type: A
  alias:
    name: "foo.bar.baz"
    zone_id: "Z12345678"
    evaluate_target_health: false

Terraform config:

#variable "zone_data" {
#  type = object({
#    zone_id = string
#    records = list(object({
#      name    = string
#      type    = string
#      ttl     = any          # optional
#      records = list(string) # optional
#      alias   = object({     # optional
#        name                   = string
#        zone_id                = string
#        evaluate_target_health = bool
#      })
#    }))
#  })
#}
variable "zone_data" {}

resource "aws_route53_record" "record" {
  for_each = { for v in var.zone_data.records : "${v.name}-${v.type}" => v }
  zone_id  = var.zone_data.zone_id
  name     = each.value.name
  type     = each.value.type
  ttl      = lookup(each.value, "ttl", null)     # ttl is optional
  records  = lookup(each.value, "records", null) # records is optional

  dynamic "alias" {
    for_each = lookup(each.value, "alias", {})
    content {
      name                   = each.value.name
      zone_id                = each.value.zone_id
      evaluate_target_health = each.value.evaluate_target_health
    }
  }
}

What’s a good way to handle the optional “alias” (and “ttl”, “records”) block here?

I’m currently getting errors like these:

  42:       zone_id                = each.value.zone_id
    |----------------
    | each.value is object with 4 attributes

This object does not have an attribute named "zone_id".

Is lookup() the right thing to use when keys may be missing?

Hi @sveniu!

The for_each argument inside a dynamic block is expecting a collection to use as the basis for repetition. In your case it looks like each.value.alias is a single object rather than a list, and so what you need for for_each is either a single-item list when the object is set or a zero-length list when it is null.

Fortunately, that particular situation is a special power of the [*] splat operator when used on something that isn’t a list:

  dynamic "alias" {
    for_each = each.value.alias[*]
    content {
      name                   = each.value.name
      zone_id                = each.value.zone_id
      evaluate_target_health = each.value.evaluate_target_health
    }
  }

each.value.alias[*] will return [each.value.alias] if each.value.alias is non-null, or [] if it is null.

The other tricky part in this case is that we need to ensure that the records you are iterating over always have the same object type, which means that either records or alias will need to be present and null rather than absent. We can do that normalization with a for expression to transform the result of loading the YAML:

locals {
  zone_data_raw = yamldecode(file("${path.module}/zone_data.yaml"))
  zone_data = {
    zone_id = zone_data_raw.zone_id
    records = [for r in zone_data_raw.records : {
      name    = r.name
      type    = r.type
      ttl     = lookup(r, "ttl", null)
      alias   = lookup(r, "alias", null)
      records = lookup(r, "records", null)
    }]
  }
}

The local.zone_data value should conform to the type constraint you originally wrote for variable "zone_data", and sets alias up in a way that the above technique of using [*] will work.

When working with data loaded from external files (in JSON or YAML format) it’s often a good idea to do something like the above just to ensure that the data in the file conforms to the expected shape and normalize where necessary. This helps ensure that any errors dealing with the file can get reported close to where the file is loaded, rather than downstream when the value is used, and that can be helpful for future maintainers that might not get the YAML structure right on the first try.

4 Likes

Thanks for all your contributions @apparentlymart
[*] is exactly what I needed, I am doing

# terraform.tfvars
dns_record_set = {
  pauls1 = {
    alias = null
    record_type = "A"
    records = {
      record_set = [
        "1.1.1.1"
      ]
      ttl = 300
    }
  },
  paulscname = {
    alias = null
    record_type = "CNAME"
    records = {
      record_set = [
        "test.0001.use1.cache.amazonaws.com"
      ]
      ttl = 300
    }
  },
  paulsalias = {
    alias = {
      evaluate_target_health = true
      target = "test.us-east-1.elb.amazonaws.com."
      target_zone = "Z1234K"
    }
    record_type = "A"
    records = null
  }
#main.tf
resource "aws_route53_record" "dns" {
  for_each = var.dns_record_set

  dynamic "alias" {
    for_each = each.value.alias[*]

    content {
      name                   = each.value.alias.target
      zone_id                = each.value.alias.target_zone
      evaluate_target_health = each.value.alias.evaluate_target_health
    }
  }

  name    = "${each.key}.${var.domain}"
  records = each.value.records == null ? null : each.value.records.record_set
  ttl     = each.value.records == null ? null : each.value.records.ttl
  type    = each.value.record_type
  zone_id = var.hosted_zone_id
}
#variables.tf
variable "dns_record_set" {
  description = "dns records"
  type = map(object({
    alias = object({
      evaluate_target_health = bool
      target = string
      target_zone = string
    })
    record_type = string
    records = object({
      record_set = set(string)
      ttl = number
    })
  }))
}
1 Like

@paulSambolin
How did you declare and specify the variable
name = "${each.key}.${var.domain}"
in your code ?

Is there a way to use that to optionally load a block in a counted resource? e.g. I have
two google hosts modules. One for if the host gets a public ip, one for if it doesn’t.

The only difference is

Gets external ip:

network_interface {
 stuff-here
access_config {
}
}

Doesn’t get external ip:

network_interface {
 stuff-here
}

I’d love to be able to have a variable I can set and then do something like

dynamic "access_config" {
  for_each = magic_variable
 content {
 }
}

And if the value evaluates as true, or whatever condition I need, it’ll insert a “access_config {}” block, and if it’s the other way, it will just leave it out.

Ah, I figured it out for my case… Bad combination of lists and objects was causing it to silently fail each time because I wasn’t checking what I thought I was checking.

For anyone else trying this…

variable "access_config" {
  description = "Access configurations, i.e. IPs via which the VM instance can be accessed via the Internet."
  type        = list(object({
    nat_ip       = string
    network_tier = string
  }))
  default     = []

In the calling module:

locals {
  access_config {
     nat_ip = ""  # (in my case, I want it auto-assigned)
     network_tier = "PREMIUM"
}

//later
module "whatever" {

#yadda
access_config = [local.access_config]
}

And in the module itself embed the access_config where it belongs in the network block and:

network_interface {
    network            = var.network
    subnetwork         = var.subnetwork
    subnetwork_project = var.subnetwork_project
    dynamic "access_config" {
      for_each = var.access_config
      content {
        nat_ip       = access_config.value.nat_ip
        network_tier = access_config.value.network_tier
      }
    }
  }