Array with a map in resource

Hi group,

I’m trying to create a security group rule from an array with maps which is like this:

  albc_alb_sg__rules = [for sg_id in var.albc_alb_sg_ids :
    {
      ingress_vpn_80 = {
        description              = "Custom HTTP users to EKS Internal ALB"
        protocol                 = "tcp"
        from_port                = 80
        to_port                  = 80
        type                     = "ingress"
        source_security_group_id = sg_id
      }
      ingress_vpn_443 = {
        description              = "Custom HTTPS users to EKS Internal ALB"
        protocol                 = "tcp"
        from_port                = 443
        to_port                  = 443
        type                     = "ingress"
        source_security_group_id = sg_id
      }
    }
  ]

The resource where i need to apply is this:

resource "aws_security_group_rule" "internal" {
  for_each = {for e in local.albc_alb_sg__rules : e => { for k, v in e : k => v }}

  # Required
  security_group_id = aws_security_group.internal.id
  protocol          = each.value.protocol
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  type              = each.value.type

  # Optional
  description      = try(each.value.description, null)
  cidr_blocks      = try(each.value.cidr_blocks, null)
  ipv6_cidr_blocks = try(each.value.ipv6_cidr_blocks, null)
  prefix_list_ids  = try(each.value.prefix_list_ids, [])
  self             = try(each.value.self, null)

  source_security_group_id = try(each.value.source_security_group_id, null)
}

I got this error when i try to make the plan:

The key expression produced an invalid result: string required.

So the thing here, it is i would like to use for_each instead of count but i can’t get the way, whan am i doing wrong, please?

Cheers.

Hi @fran.rodriguez,

I think what this error message is telling you is that the e in your for expression isn’t a string, and so it isn’t a valid key. You are using e as a key by including it just before the => symbol in the for expression.

Looking at your definition of albc_alb_sg__rules it seems to be generating a list of maps of objects rather than just a single map, and so e in your for expression is each whole map, rather than each element of the maps inside.

In order for this to work, your local.albc_alb_sg__rules value will need to be a map of objects rather than a list of maps of objects. One part of getting to that could be to use merge to ask Terraform to merge all of these maps together into a single map, like this:

locals {
  flat_rules = merge(local.albc_alb_sg__rules...)
}

The extra ... symbol in this call tells Terraform to take each element of the list as a separate argument to the function, rather than passing the entire list as one argument.

However, the problem here is that all of your elements of local.albc_alb_sg__rules have the same two keys "ingress_vpn_80" and "ingress_vpn_443", so they will all overwrite each other so that only the last element of var.albc_alb_sg_ids will survive into the result.

To fix that will require changing some details that aren’t visible in the snippets you’ve shared, so I need to make some guesses about what else you have. I’m guessing that you’ve declared variable "albc_alb_sg_ids" as being either type = list(string) or type = set(string), and that sg_id will be the remote-server-generated security group IDs, like sg-abcd1234. If so, those IDs generated by the server are not suitable for use as instance tracking keys in Terraform because their values will not be decided until the apply step, and so Terraform will not be able to generate a durable address for each of the instances during planning.

To deal with that, the typical answer is to use a map instead, where the map keys are static strings chosen in your configuration and only the map values are dynamic strings chosen by the remote system during the apply step:

variable "albc_alb_sg_ids" {
  type = map(string)
}

When you call this module you’d then specify a particular tracking key for each of the security groups you are passing. The module block calling this module would contain something like this:

module "example" {
  # ...

  albc_alb_sg_ids = {
    example_a = aws_security_group.example_a.id
    example_b = aws_security_group.example_b.id
  }
}

I would suggest understanding the above as saying “a security group which we will call "example_a", whose remote ID is aws_security_group.example_a.id”. This then specifies both a local key that Terraform can use to track each security group and the ID that the remote system uses to track the object.

With the variable redefined in that way, you can then propagate the keys from that input variable into the keys of your derived map, and then finally into your instance keys for for_each:

locals {
  albc_alb_sg__rules = merge([for key, sg_id in var.albc_alb_sg_ids :
    {
      "${key}:ingress_vpn_80" = {
        description              = "Custom HTTP users to EKS Internal ALB"
        protocol                 = "tcp"
        from_port                = 80
        to_port                  = 80
        type                     = "ingress"
        source_security_group_id = sg_id
      }
      "${key}:ingress_vpn_443" = {
        description              = "Custom HTTPS users to EKS Internal ALB"
        protocol                 = "tcp"
        from_port                = 443
        to_port                  = 443
        type                     = "ingress"
        source_security_group_id = sg_id
      }
    }
  ]...)
}

I changed only two things here relative to your original example:

  • The for expression is wrapped in a merge call like I showed above, so that all of the mappings generated by the for expression will be combined together into a single map.
  • I changed the keys expressions for the two generated map elements to include ${key}: interpolation, which means that with the example module input I showed above this would generate keys like "example_a:ingress_vpn_80" and "example_b:ingress_vpn_80", which ensures that the keys will not conflict when merge combines the maps.

This result now matches the requirements for for_each: it’s a mapping with one element per instance you want to declare, and each element has a key that Terraform can know during the plan step because it was specified in the configuration rather than decided by the remote system.

So finally we can just plug that expression directly into for_each:

resource "aws_security_group_rule" "internal" {
  for_each = local.albc_alb_sg__rules

  # (everything else unchanged)
}

This should declare the following resource instance addresses, if the module were given the example input I showed above:

  • aws_security_group_rule.internal["example_a:ingress_vpn_80"]
  • aws_security_group_rule.internal["example_a:ingress_vpn_443"]
  • aws_security_group_rule.internal["example_b:ingress_vpn_80"]
  • aws_security_group_rule.internal["example_b:ingress_vpn_443"]

These unique instance keys will then allow Terraform to track the instance between the plan and apply step, and then on future runs allow Terraform to understand the difference between updating an existing definition in-place vs. adding or removing security groups entirely.

Thank you so much @apparentlymart for the quickly answer and perfect explanation.

You’re totally right, the main issue for me, was that i didn’t realize about the merge, i thought i could handle in the resource but i guess not… :). About the suggestion of keys map, i’ve realized but i was just trying to work the first thing with the list of maps of objects, i know the keys in a map must be unique.

Thank you so much crack!!
Cheers.