Combine object literals with a set of objects of a specific type

I have a Terraform module that creates a Kubernetes NetworkPolicy from sets of predefined policies that are contained in the module. These are defined as object literals that mirror the Kubernetes API spec for a network policy egress rule. For example, one of the object literals looks like this:

locals {
  available_egress_policies = {
    "https" : {
      ports = [
        {
          port     = 443,
          protocol = "TCP",
        },
      ],
      to = [],
    },
  }
}

This is a policy that allows the caller to egress on HTTPS to the internet at large.

The resource consuming it looks like this:

resource "kubernetes_network_policy" "policy" {
  spec {
    dynamic "egress" {
      for_each = local.selected_egress_policies

      content {
        dynamic "ports" {
          for_each = egress.value.ports

          content {
            port     = ports.value.port
            protocol = ports.value.protocol
          }
        }

        # etc... big mess of dynamic blocks for all possible options

The user specifies which policy stanzas they want in the arguments to the module by name:

module "networkpolicy" {
  egress_policies = [ "https" ]
}

I want to extend this module to allow the user to pass in custom policy blocks, so that they can use more policy than just the predefined stanzas in the module. I added a var that looks like this:

variable "custom_policies" {
  default     = []
  type = set(object({
    ports = optional(list(object({
      port     = optional(any) # Can be either a port number or a named port.
      protocol = optional(string)
    })))
    to = optional(list(object({
      ip_block = optional(object({
        cidr = string
      }))
      namespace_selector = optional(object({
        match_labels = map(string)
      }))
    })))
  }))
}

The problem is that when I try to combine the policies passed in the var and policies selected from the locals so that I can for_each over all of them, I get errors from Terraform that their types aren’t compatible.

This:

locals {
  selected_egress_policies = toset([for p in var.egress_policies : local.available_egress_policies[p]])
  all_egress_policies = setunion(
    local.selected_egress_policies,
    var.custom_policies,
  )
}

Yields this error:

β•·
β”‚ Error: Error in function call
β”‚ 
β”‚   on ../networkpolicy/vars.tf line 65, in locals:
β”‚   65:   all_egress_policies = setunion(
β”‚   66:     local.selected_egress_policies,
β”‚   67:     var.custom_policies,
β”‚   68:   )
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     β”‚ local.selected_egress_policies is set of object with 1 element
β”‚     β”‚ var.custom_policies is set of object with 1 element
β”‚ 
β”‚ Call to function "setunion" failed: given sets must all have compatible
β”‚ element types.
β•΅

And this:

locals {
  selected_egress_policies = setunion(
    [for p in var.egress_policies : local.available_egress_policies[p]],
    var.custom_policies,
  )
}

Yields this error:

β•·                                                                                                                                                                                                                                                                                                                    
β”‚ Error: Error in function call                                              
β”‚                                                                            
β”‚   on ../networkpolicy/vars.tf line 64, in locals:
β”‚   64:   selected_egress_policies = setunion(
β”‚   65:     toset([for p in var.egress_policies : local.available_egress_policies[p]]),
β”‚   66:     var.custom_policies,                                             
β”‚   67:   )                                                                  
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€                                                      
β”‚     β”‚ local.available_egress_policies is object with 3 attributes
β”‚     β”‚ var.custom_policies is set of object with 1 element
β”‚     β”‚ var.egress_policies is set of string with 1 element
β”‚                                                                            
β”‚ Call to function "setunion" failed: given sets must all have compatible
β”‚ element types.                                                             
β•΅   

Is there a way to mush these two together into one set? I would rather not have to copy-paste the entire dynamic "egress" block to for_each over them separately.

Have you tried using lists with concat and toset only in the for_each statement?

Unfortunately that doesn’t seem to work, either:

  selected_egress_policies = toset(concat(
    [for p in var.egress_policies : local.available_egress_policies[p]],
    var.custom_policies,
  ))
β•·                                                                            
β”‚ Error: Invalid function argument                                           
β”‚                                                                            
β”‚   on ../component_networkpolicy/vars.tf line 71, in locals:
β”‚   69:   selected_egress_policies = toset(concat(                                                                                                        
β”‚   70:     [for p in var.egress_policies : local.available_egress_policies[p]],
β”‚   71:     var.custom_policies,                                             
β”‚   72:   ))                                                                 
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€                                                      
β”‚     β”‚ var.custom_policies is set of object with 1 element
β”‚                                                                            
β”‚ Invalid value for "seqs" parameter: all arguments must be lists or tuples;
β”‚ got set of object.                                                         
β•΅ 
  selected_egress_policies = toset(concat(
    [for p in var.egress_policies : local.available_egress_policies[p]],
    tolist(var.custom_policies),
  ))
β•·
β”‚ Error: Invalid function argument
β”‚ 
β”‚   on ../component_networkpolicy/vars.tf line 69, in locals:
β”‚   69:   selected_egress_policies = toset(concat(
β”‚   70:     [for p in var.egress_policies : local.available_egress_policies[p]],
β”‚   71:     tolist(var.custom_policies),
β”‚   72:   ))
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     β”‚ local.available_egress_policies is object with 3 attributes
β”‚     β”‚ var.custom_policies is set of object with 1 element
β”‚     β”‚ var.egress_policies is set of string with 1 element
β”‚ 
β”‚ Invalid value for "v" parameter: cannot convert tuple to set of any single
β”‚ type.
β•΅

I also tried doing a for expression and set conversion over the var.custom_policies, hoping that that might convert it to a set(any) (assuming such a thing exists), but got the same errors.

It would help me a lot if I could β€œcast” my literals to or assert them as a set of this type, but that doesn’t seem possible.

I suppose a workaround could be to define the local literals instead as the default value of a variable of the same type, but that’s not very clean. Just put in the description β€œthis is an internal variable, don’t overwrite or you will break it.”

…except that that disallows using other var values in the predefined policy stanzas, of which there are some. The user can pass in CIDR ranges to allow, for example.

I think an easier solution would be to change the custom_policies type constraint to list(...) instead of set(...)
Get rid of all toset() inside locals { ... }
like so

  selected_egress_policies = concat(
    [for p in var.egress_policies : local.available_egress_policies[p]],
    var.custom_policies,
  )

and finally here add the toset()

resource "kubernetes_network_policy" "policy" {
  spec {
    dynamic "egress" {
      for_each = toset(local.selected_egress_policies)

Getting desperate now:

  selected_egress_policies = toset(concat(
    [for p in var.egress_policies : local.available_egress_policies[p]],
    [for p in var.custom_policies : jsondecode(jsonencode(p))],
  ))
β•·
β”‚ Error: Invalid function argument
β”‚ 
β”‚   on ../component_networkpolicy/vars.tf line 69, in locals:
β”‚   69:   selected_egress_policies = toset(concat(
β”‚   70:     [for p in var.egress_policies : local.available_egress_policies[p]],
β”‚   71:     [for p in var.custom_policies : jsondecode(jsonencode(p))],
β”‚   72:   ))
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     β”‚ local.available_egress_policies is object with 3 attributes
β”‚     β”‚ var.custom_policies is set of object with 1 element
β”‚     β”‚ var.egress_policies is set of string with 1 element
β”‚ 
β”‚ Invalid value for "v" parameter: cannot convert tuple to set of any single
β”‚ type.
β•΅

That led me to:

  selected_egress_policies = setunion(
    [for p in var.egress_policies : jsonencode(local.available_egress_policies[p])],
    [for p in var.custom_policies : jsonencode(p)],
  )

Which works. And then:

resource "kubernetes_network_policy" "policy" {
    dynamic "egress" {
      for_each = local.selected_egress_policies

      content {
        dynamic "ports" {
          for_each = jsondecode(egress.value).ports

          content {
            port     = ports.value.port
            protocol = ports.value.protocol
          }
        }

        dynamic "to" {
          for_each = jsondecode(egress.value).to

          content {
            # XXX: This is a single attribute in the Kubernetes API spec, but
            # the Terraform resource defines it as a block.
            dynamic "ip_block" {
              for_each = to.value.ip_block[*]

              content {
                cidr = ip_block.value.cidr
              }
            }

            # XXX: This is a single attribute in the Kubernetes API spec, but
            # the Terraform resource defines it as a block.
            dynamic "namespace_selector" {
              for_each = to.value.namespace_selector[*]

              content {
                match_labels = namespace_selector.value.match_labels
              }
            }
          }
        }
      }
    }
  }
}

But now splat syntax doesn’t work to fetch optional attributes:

β•·
β”‚ Error: Unsupported attribute
β”‚ 
β”‚   on ../component_networkpolicy/policy.tf line 47, in resource "kubernetes_network_policy" "policy":
β”‚   47:               for_each = to.value.namespace_selector[*]
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚     β”‚ to.value is object with 1 attribute "ip_block"
β”‚ 
β”‚ This object does not have an attribute named "namespace_selector".
β•΅

But this tool has never yet defeated me:

  for_each = try([to.value.namespace_selector], [])

I think this might now be working.

But I would love to know if there’s a better way to do it.

Unfortunately I don’t think making it a list would fix it, because it still has to become a set for use in the dynamic block, and these two types refuse to coexist in the same set, even though they’re compatible.

Last (?) fix:

β•·                 
β”‚ Error: Attempt to get attribute from null value 
β”‚                               
β”‚   on ../component_networkpolicy/policy.tf line 40, in resource "kubernetes_network_policy" "policy":
β”‚   40:                 cidr = ip_block.value.cidr
β”‚     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€                  
β”‚     β”‚ ip_block.value is null
β”‚                                             
β”‚ This value is null, so it does not have any attributes.
β•΅ 

for_each = try([to.value.ip_block], []) β†’ for_each = try(to.value.ip_block[*], [])

Yeah, the documentation isn’t very clear about what makes two sets containing complex types compatible with each other. Would need to take a peek at the source code…

That’s why I normally resort to list and count when that happens.

I’ve definitely encountered a few use-cases for count instead of for_each in dynamic blocks, but unfortunately it only supports the latter.

I suppose I could use for_each = toset(range(length(combined_list))) and combined_list[dynamic.value]. That might even be better.

Okay, what?

Using a list as the for_each argument seems to work:

  selected_egress_policies = concat(
    [for p in var.egress_policies : local.available_egress_policies[p]],
    tolist(var.custom_egress_policies),
  )
resource "kubernetes_network_policy" "policy" {
  spec {
    dynamic "egress" {
      for_each = local.selected_egress_policies

      # etc...

I’m not sure why I didn’t try this permutation. I’m accustomed to Terraform complaining when I try to use a non-set/map in for_each.

The splat syntax is still broken and needs to be wrapped with try, but aside from that, this is for sure the best solution yet.

1 Like

That type restriction applies to for_each used in resource or data block context, whereas for_each in dynamic block context is more permissive (as a consequence of not needing to generate string identifiers for each instance of the iteration.

The splat syntax is not the issue. The actual issue is that the conversions are removing the optional type information somehow, so the attribute access that occurs before the splat is raising an error.

There are no type conversions anymore in my final solution. I think the issue is that the object literals in my local don’t have null attributes for the optional fields in the variable type: they’re being typed dynamically as objects that don’t have that field at all. So the splat syntax, which converts a non-null attribute to a single-element tuple and a null attribute to an empty tuple, can’t work because there is no attribute, null or otherwise.

I agree that the splat itself isn’t the problem. It’s behaving as expected.

Perhaps that’s the reason setunion() was failing in the first place.

I have since come to my senses and abandoned the approach of passing custom policies into this module (in line with the best practices outlined in the documentation), but there’s one more thing I figured out here that could be helpful to anyone else doing something similar in the future:

A great way around this was to to define empty defaults for the object and its fields, and merge into those defaults in each dynamic block so that the resulting value had all of the fields.

locals {
  default_egress = {
    ports = [],
    to    = [],
  }
  default_egress_ports = {
    port     = null,
    protocol = null,
  }
  default_egress_to = {
    ip_block           = null,
    namespace_selector = null,
    pod_selector       = null,
  }
}
resource "kubernetes_network_policy" "policy" {
    dynamic "egress" {
      for_each = [
        for egress in local.selected_egress_policies :
        merge(local.default_egress, egress)
      ]

      content {
        dynamic "ports" {
          for_each = [
            for port in egress.value.ports :
            merge(local.default_egress_ports, port)
          ]

          content {
            port     = ports.value.port
            protocol = ports.value.protocol
          }
        }

        dynamic "to" {
          for_each = [
            for to in egress.value.to :
            merge(local.default_egress_to, to)
          ]

          content {
            # XXX: This is a single attribute in the Kubernetes API spec, but
            # the Terraform resource defines it as a block.
            dynamic "ip_block" {
              for_each = to.value.ip_block[*]

              content {
                cidr = ip_block.value.cidr
              }
            }

      # etc.

This forces the resulting objects to have all of the fields. It allows completely eliminating the trys and some of the splats, and makes the splats that are here behave as expected.

1 Like