Nested Dynamic Blocks + Modules + Complex Variable Input

As far as I can tell this should work, though I’m getting an error I am unsure how to troubleshoot. I am working with the panos provider for palo alto configuration, though that should be irrelevant based on my understanding; so I’ve left that detail absent from below.

Any guidance would be greatly appreciated. :relaxed:

“Root” main.tf variable declaration in a local block:

nat_rules = [
    {
      name = "egress"
      type = "ipv4"
      original_packet = {
        source_zones          = ["Trust"]
        destination_zone      = "Untrust"
        destination_interface = "ethernet1/1"
        source_addresses      = ["any"]
        destination_addresses = ["any"]
        service               = "any"
      }
      translated_packet = {
        source = {
          dynamic_ip_and_port = {
            interface_address = {
              interface = "ethernet1/1"
            }
          }
        }
        destination = {
          static_translation = {
            address = "10.2.0.20"
            port    = 80
          }
        }
      }
    }
  ]

variable definition in module:

variable "nat_rules" {
  description = "List of NAT rules to create."
  type = list(object({
    name              = string
    type              = string
    original_packet   = object({
      source_zones = list(string)
      destination_zone = string
      destination_interface = string
      source_addresses = list(string)
      destination_addresses = list(string)
      service = string
    })
    translated_packet = object({
      source = object({
        dynamic_ip_and_port = object({
          interface_address = object({
            interface = string
          })
        })
      })
      destination = object({
        static_translation = object({
          address = string
          port    = number
        })
      })
    })
  }))
}

resource definition in module:

resource "panos_nat_rule_group" "default" {

  dynamic "rule" {
    for_each = var.nat_rules

    content {
      name              = rule.value.name
      type              = rule.value.type
      original_packet {
        source_zones          = rule.value.original_packet.source_zones
        destination_zone      = rule.value.original_packet.destination_zone
        destination_interface = rule.value.original_packet.destination_interface
        source_addresses      = rule.value.original_packet.source_addresses
        destination_addresses = rule.value.original_packet.destination_addresses
        service               = rule.value.original_packet.service
      }
      translated_packet {
        source {
          dynamic_ip_and_port {
            interface_address {
              interface = rule.value.translated_packet.source.dynamic_ip_and_port.interface_address.interface
            }
          }
        }
        destination {
          dynamic "static_translation" {
            for_each = rule.value.translated_packet.destination.static_translation

            content {
              address = static_translation.value.address
              port    = static_translation.value.port
            }
          }
        }
      }
    }
  }
}

Error returned post-refresh from a plan:

------------------------------------------------------------------------

Error: Unsupported attribute

  on ../modules/terraform-panos-cep/main.tf line 131, in resource "panos_nat_rule_group" "default":
 131:               address = static_translation.value.address
    |----------------
    | static_translation.value is "10.2.0.20"

This value does not have any attributes.


Error: Unsupported attribute

  on ../modules/terraform-panos-cep/main.tf line 131, in resource "panos_nat_rule_group" "default":
 131:               address = static_translation.value.address
    |----------------
    | static_translation.value is 80

This value does not have any attributes.


Error: Unsupported attribute

  on ../modules/terraform-panos-cep/main.tf line 132, in resource "panos_nat_rule_group" "default":
 132:               port    = static_translation.value.port
    |----------------
    | static_translation.value is "10.2.0.20"

This value does not have any attributes.


Error: Unsupported attribute

  on ../modules/terraform-panos-cep/main.tf line 132, in resource "panos_nat_rule_group" "default":
 132:               port    = static_translation.value.port
    |----------------
    | static_translation.value is 80

This value does not have any attributes.

Hi @scottzilla!

It looks like you’re iterating over your single static_translation object, causing Terraform to think you want a separate static_translation block for each attribute in that object.

Since static_translation seems to just be a single object, you can just write out its block statically:

  destination {
    static_translation {
      address = rule.value.translated_packet.destination.static_translation.address
      port    = rule.value.translated_packet.destination.static_translation.port
    }
  }

If you wanted to use a dynamic block just for its side-effect of shortening that long expression to be static_translation.value then I suppose you could instead set your for_each to be a single-element list:

        destination {
          dynamic "static_translation" {
            for_each = [rule.value.translated_packet.destination.static_translation]

            content {
              address = static_translation.value.address
              port    = static_translation.value.port
            }
          }
        }

…though I don’t think I’d do that, because to my eye it makes the intent harder to follow; when I see a dynamic block I expect that to mean that the number of blocks is decided dynamically, so it’s odd to see a dynamic block with a fixed number of items in its for_each expression.

Thanks @apparentlymart for the quick response!

What I’m really looking to take advantage of is being able to have the following be a valid output:

destination {}

For example, if I passed in destination = {} in the module argument.

Is there a better approach to achieve this?

I have more testing to do, but I believe I can achieve the behavior I’m looking for with the following (and no, it is not pretty…)—

resource "panos_nat_rule_group" "default" {

  dynamic "rule" {
    for_each = var.nat_rules

    content {
      name              = rule.value.name
      type              = rule.value.type
      original_packet {
        source_zones          = rule.value.original_packet.source_zones
        destination_zone      = rule.value.original_packet.destination_zone
        destination_interface = rule.value.original_packet.destination_interface
        source_addresses      = rule.value.original_packet.source_addresses
        destination_addresses = rule.value.original_packet.destination_addresses
        service               = rule.value.original_packet.service
      }
      translated_packet {
        source {
          dynamic_ip_and_port {
            interface_address {
              interface = rule.value.translated_packet.source.dynamic_ip_and_port.interface_address.interface
            }
          }
        }
        dynamic destination {
          for_each = [rule.value.translated_packet.destination]

          content {
            dynamic static_translation {
              for_each = !contains(keys(destination.value), "static_translation") ? [] : [destination.value.static_translation]

              content {
                address = static_translation.value.address
                port    = static_translation.value.port
              }
            }
          }
        }
      }
    }
  }
}

Well, shucks; I suppose this wasn’t unexpected, but passing in a list of rules as such (one with an empty translated_packet.destination object and the second populated):

  nat_rules = [
    {
      name = "egress"
      type = "ipv4"
      original_packet = {
        source_zones          = ["Trust"]
        destination_zone      = "Untrust"
        destination_interface = "ethernet1/1"
        source_addresses      = ["any"]
        destination_addresses = ["any"]
        service               = "any"
      }
      translated_packet = {
        source = {
          dynamic_ip_and_port = {
            interface_address = {
              interface = "ethernet1/1"
            }
          }
        }
        destination = {}
      }
    },
    {
      name = "ingress-specific"
      type = "ipv4"
      original_packet = {
        source_zones          = ["Untrust"]
        destination_zone      = "Trust"
        destination_interface = "ethernet1/1"
        source_addresses      = ["any"]
        destination_addresses = ["any"]
        service               = "ServiceName1"
      }
      translated_packet = {
        source = {
          dynamic_ip_and_port = {
            interface_address = {
              interface = "ethernet1/2"
            }
          }
        }
        destination = {
          static_translation = {
            address = "10.2.0.20"
            port    = 80
          }
        }
      }
    }
  ]

Leads to a panic:

panic: inconsistent list element types (cty.Object(map[string]cty.Type{"name":cty.String, "original_packet":cty.Object(map[string]cty.Type{"destination_addresses":cty.List(cty.String), "destination_interface":cty.String, "destination_zone":cty.String, "service":cty.String, "source_addresses":cty.List(cty.String), "source_zones":cty.List(cty.String)}), "translated_packet":cty.Object(map[string]cty.Type{"destination":cty.Map(cty.DynamicPseudoType), "source":cty.Map(cty.Object(map[string]cty.Type{"interface_address":cty.Object(map[string]cty.Type{"interface":cty.String})}))}), "type":cty.String}) then cty.Object(map[string]cty.Type{"name":cty.String, "original_packet":cty.Object(map[string]cty.Type{"destination_addresses":cty.List(cty.String), "destination_interface":cty.String, "destination_zone":cty.String, "service":cty.String, "source_addresses":cty.List(cty.String), "source_zones":cty.List(cty.String)}), "translated_packet":cty.Object(map[string]cty.Type{"destination":cty.Map(cty.Object(map[string]cty.Type{"address":cty.String, "port":cty.Number})), "source":cty.Map(cty.Object(map[string]cty.Type{"interface_address":cty.Object(map[string]cty.Type{"interface":cty.String})}))}), "type":cty.String}))

goroutine 405 [running]:
github.com/zclconf/go-cty/cty.ListVal(0xc000498800, 0x2, 0x2, 0xc0008a54a0, 0xc000cc5080, 0x1, 0x1)
	github.com/zclconf/go-cty@v1.2.1/cty/value_init.go:166 +0x436
github.com/zclconf/go-cty/cty/convert.conversionTupleToList.func2(0x2fd3580, 0xc000996ae0, 0x269fe20, 0xc000996b00, 0xc000cc5080, 0x1, 0x1, 0xc000080800, 0x10, 0x10, ...)
	github.com/zclconf/go-cty@v1.2.1/cty/convert/conversion_collection.go:267 +0x53f
github.com/zclconf/go-cty/cty/convert.getConversion.func1(0x2fd3580, 0xc000996ae0, 0x269fe20, 0xc000996b00, 0x0, 0x0, 0x0, 0xc00095d050, 0xc000cc5070, 0x2fd3480, ...)
	github.com/zclconf/go-cty@v1.2.1/cty/convert/conversion.go:46 +0x1ab
github.com/zclconf/go-cty/cty/convert.retConversion.func1(0x2fd3580, 0xc000996ae0, 0x269fe20, 0xc000996b00, 0xc000cc5070, 0x0, 0x0, 0x0, 0x0, 0x0)
	github.com/zclconf/go-cty@v1.2.1/cty/convert/conversion.go:179 +0x6b
github.com/zclconf/go-cty/cty/convert.Convert(0x2fd3580, 0xc000996ae0, 0x269fe20, 0xc000996b00, 0x2fd3480, 0xc000263080, 0x0, 0x2fd3580, 0xc000996ae0, 0x269fe20, ...)
	github.com/zclconf/go-cty@v1.2.1/cty/convert/public.go:51 +0x1aa
github.com/hashicorp/terraform/terraform.(*EvalModuleCallArgument).Eval(0xc000498740, 0x3005ca0, 0xc000b34340, 0x2, 0x2, 0x0, 0xc000bf4200)
	github.com/hashicorp/terraform@/terraform/eval_variable.go:77 +0x160
github.com/hashicorp/terraform/terraform.EvalRaw(0x2f8d700, 0xc000498740, 0x3005ca0, 0xc000b34340, 0x2d, 0x0, 0x0, 0x2d)
	github.com/hashicorp/terraform@/terraform/eval.go:57 +0x131
github.com/hashicorp/terraform/terraform.(*EvalOpFilter).Eval(0xc0008a5680, 0x3005ca0, 0xc000b34340, 0x2, 0x2, 0x1077628, 0xc0000c2000)
	github.com/hashicorp/terraform@/terraform/eval_filter_operation.go:37 +0x4c
github.com/hashicorp/terraform/terraform.EvalRaw(0x2f8d740, 0xc0008a5680, 0x3005ca0, 0xc000b34340, 0x0, 0xc000db1d08, 0xc0000c2000, 0x2d)
	github.com/hashicorp/terraform@/terraform/eval.go:57 +0x131
github.com/hashicorp/terraform/terraform.(*EvalSequence).Eval(0xc000996b20, 0x3005ca0, 0xc000b34340, 0x2, 0x2, 0x1b25855, 0x2f8dde0)
	github.com/hashicorp/terraform@/terraform/eval_sequence.go:20 +0xfd
github.com/hashicorp/terraform/terraform.EvalRaw(0x2f8d8a0, 0xc000996b20, 0x3005ca0, 0xc000b34340, 0x27622e0, 0x3e3d185, 0x26c6560, 0xc0001ca280)
	github.com/hashicorp/terraform@/terraform/eval.go:57 +0x131
github.com/hashicorp/terraform/terraform.Eval(0x2f8d8a0, 0xc000996b20, 0x3005ca0, 0xc000b34340, 0xc000996b20, 0x2f8d8a0, 0xc000996b20, 0xc000202ae0)
	github.com/hashicorp/terraform@/terraform/eval.go:35 +0x4d
github.com/hashicorp/terraform/terraform.(*Graph).walk.func1(0x29322c0, 0xc00049f9c0, 0x0, 0x0, 0x0)
	github.com/hashicorp/terraform@/terraform/graph.go:90 +0xf7e
github.com/hashicorp/terraform/dag.(*Walker).walkVertex(0xc000d5aa80, 0x29322c0, 0xc00049f9c0, 0xc0009dd7c0)
	github.com/hashicorp/terraform@/dag/walk.go:392 +0x377
created by github.com/hashicorp/terraform/dag.(*Walker).Update
	github.com/hashicorp/terraform@/dag/walk.go:314 +0xaa7

Specifically:

inconsistent list element types (

cty.Object(
  map[string]cty.Type{
    "name":cty.String, 
    "original_packet":cty.Object(
      map[string]cty.Type{
        "destination_addresses":cty.List(cty.String), 
        "destination_interface":cty.String, 
        "destination_zone":cty.String, 
        "service":cty.String, 
        "source_addresses":cty.List(cty.String), 
        "source_zones":cty.List(cty.String)}
      ), 
    "translated_packet":cty.Object(
      map[string]cty.Type{
        "destination":cty.Map(cty.DynamicPseudoType), 
        "source":cty.Map(
          cty.Object(
            map[string]cty.Type{
              "interface_address":cty.Object(
                map[string]cty.Type{
                  "interface":cty.String
                }
              )
            }
          )
        )
      }
    ), 
    "type":cty.String
  }
) 

then 

cty.Object(
  map[string]cty.Type{
    "name":cty.String, 
    "original_packet":cty.Object(
      map[string]cty.Type{
        "destination_addresses":cty.List(cty.String), 
        "destination_interface":cty.String, 
        "destination_zone":cty.String, 
        "service":cty.String, 
        "source_addresses":cty.List(cty.String), 
        "source_zones":cty.List(cty.String)
      }
    ), 
    "translated_packet":cty.Object(
      map[string]cty.Type{
        "destination":cty.Map(
          cty.Object(
            map[string]cty.Type{
              "address":cty.String, 
              "port":cty.Number
            }
          )
        ), 
        "source":cty.Map(
          cty.Object(
            map[string]cty.Type{
              "interface_address":cty.Object(
                map[string]cty.Type{
                  "interface":cty.String
                }
              )
            }
          )
        )
      }
    ), 
    "type":cty.String
  }
)

)

EDIT – I spoke too soon :laughing: The below does not actually work as expected.

I lose any sort of type checking, but was able to eliminate the panic by updating the variable definition in my variable…

FROM:

    translated_packet = object({
      source = object({
        dynamic_ip_and_port = object({
          interface_address = object({
            interface = string
          })
        })
      })
      destination = object({
        static_translation = object({
          address = string
          port    = number
        })
      })
    })

TO:

    translated_packet = object({
      source = object({
        dynamic_ip_and_port = object({
          interface_address = object({
            interface = string
          })
        })
      })
      destination = object({})
    })

As I noted in the edit in the previous post, the above does not at all do what is expected since {} means empty object, not to be confused with any.

For this to work, I had to remove any definition at all for nat_rules in my module:

variable "nat_rules" {
  description = "List of NAT rules to create."
  type = list(any)
}

Hi @scottzilla,

I’m sorry that I think I’ve lost where you are along the way here, but it sounds like you got something working.

In one of your earlier comments you were talking about the possibility of there being zero static_translation blocks depending on the value, which is indeed an example of a situation where you need a dynamic block: the decision about whether there are zero or one blocks will be made at expression evaluation time.

The most direct way to represent that would be to make static_translation instead be static_translations and declare it as being a list of objects rather than a single object. You could then leave the list empty in the case where you want zero blocks, although it would also create the possibility of there being more than one block, which might not be appropriate for this block type. (I’m not really familiar with this provider, so I’m not sure.)

If you want to explicitly represent “zero or one”, a reasonable way to do that could be to allow static_translation to be null. If you take that approach then you can rely on the “special power” of the splat operator [*] to concisely convert a single value that might be null into a list of zero or one elements:

        destination {
          dynamic "static_translation" {
            for_each = rule.value.translated_packet.destination.static_translation[*]

            content {
              address = static_translation.value.address
              port    = static_translation.value.port
            }
          }
        }

This is the same as your original example except that I added [*] to the end of the for_each expression. With that in place, if static_translation is given as a defined object then there will be one element in the for_each collection, while if static_translation = null there will be zero elemennts in the for_each collection, getting the result I think you wanted: effectively, an empty destination {} block.


Regarding that panic you saw: that seems familiar to me as something that got fixed during the 0.13 development window, so I think it’ll appear as a proper error message in 0.13.0 or later.

1 Like

Nice. This conditional below allows for destination = {}:

dynamic destination {
  for_each = [rule.value.translated_packet.destination]

  content {
    dynamic static_translation {
      for_each = contains(keys(destination.value), "static_translation") ? [destination.value.static_translation] : []

      content {
        address = static_translation.value.address
        port    = static_translation.value.port
      }
    }
  }
}

(for this resource, destination is required to be present, but its child properties are all optional)


Would your splat syntax work for the destination for_each if the rule contained destination = null?

If your intent for destination = null to mean no destination blocks at all then yes, you could use the splat operator at that level too:

  for_each = rule.value.translated_packet.destination[*]

The static_translation for_each expression could then be shortened to use the destination iterator variable:

  for_each = destination.value[*]
1 Like

and if I want to allow destination = {}, is the expression the only way to handle that?