Loop with conditional using object known only after apply - oh my!

Hello friends, I am trying to consolidate some variables to make a module easier to use, and found myself in a pickle.

I have something like:

variable "stateful_config" {
  type = object({
    per_instance_config = map(object({
      #name is the key
      stateful_disks = map(object({
        #device_name is the key
        mode        = string  
      }))
    }))
    mig_config = object({
      stateful_disks = map(object({
        #device_name is the key
        mode = string 
      }))
    })
  default = null
}

So an example would be:

{
    per_instance_config = {
           instance_1 = {
                   stateful_disks = {
                          persistent-disk-1 = {
                                 mode = 'READ_ONLY'
                          }
                   }   
           }
    }
     mig_config = {
             stateful_disks = {
                      persistent-disk-1 = {
                             mode = 'READ_ONLY'
                      }
               }   
       }
}

This is because GCP provider allows you to configure stateful managed instance groups and define disk parameters for the entire group and/or per specific instance of the group.

Here is the rundown:

  • User can define stateful_config with 0-n per_instance_configs,
  • Additionally, they can define 0-1 mig_config,
  • Finally, both per_instance_configs and mig_config allow 0-n stateful_disks

Considering this, if I use a conditional for_each I get the expected error that this is an object so will only be known after apply:

...
resource "google_compute_instance_group_manager" "default" {
...
  dynamic "stateful_disk" {
    for_each = var.stateful_config && var.stateful_config.mig_config != null ? {} : var.stateful_config.mig_config.stateful_disks
    iterator = config
    content {
      device_name = config.key
      delete_rule = config.value.delete_rule
    }
  }
...
}
...
# and google_compute_per_instance_config is another can of worms, we can focus just on the above.

Error:

E           │ var.stateful_config is a object, known only after apply
E
E       Unsuitable value for left operand: bool required.
E
E       Error: Invalid operand

Thoughts or suggestions?

Hi @stenio123!

I’m a bit confused by the rather unusual formatting/ordering of that error message and the parts of it that are missing, but I think what it’s trying to tell you is that var.stateful_config isn’t a valid operand for the && operator, because that operator works only with boolean values but var.stateful_config has an object type.

If you were intending to make the for_each value be an empty map when var.stateful_config is null then I’d suggest the following expression instead:

  for_each = try(var.stateful_config.mig_config.stateful_disks, tomap({}))

Here I used the try function to concisely handle an invalid attribute error at either the .mig_config step or at the .stateful_disks step, in either case using the second argument tomap({}) instead. The tomap call here isn’t strictly necessary because Terraform will figure out what you meant by context anyway, but I find it helpful to be explicit so that a future reader doesn’t have to have all of Terraform’s automatic type conversion rules memorized in order to guess what return types this expression could have.

Thank you @apparentlymart ! The error message comes from pytest, but other than the format should just be the pass through error.

I believe your suggestion will help me clear the first hurdle. Now the second challenge is how to manage the 0-n google_compute_per_instance_config resource blocks. Since they are high level constructs I cant use simple dynamic blocks with for_each.

What I need is:

  • Create 0-n resource blocks of google_compute_per_instance_config
  • Configure these resources with base parameters in the object (like name of the instance)
  • Configure these resources with 0-n dynamic blocks (the stateful disks)

Here is what I attempted:

I have updated the nested variable per_instance_config from map(object({… to list(object({… and used the following:

resource "google_compute_per_instance_config" "default" {
  count = try(length(var.stateful_config.per_instance_config), tolist({}))
  minimal_action                   = try(var.stateful_config.per_instance_config[*].update_config.minimal_action)
  most_disruptive_allowed_action   = try(var.stateful_config.per_instance_config[*].update_config.most_disruptive_allowed_action)
...
}

However this gives me error

 155:   count = try(length(var.stateful_config.per_instance_config), tolist({}))
E           ├────────────────
E           │ var.stateful_config is null
E
E       Call to function "try" failed: no expression succeeded:
E       - Attempt to get attribute from null value (at
../../../../modules/compute-mig/main.tf:155,41-61)
E         This value is null, so it does not have any attributes.
E       - Invalid function argument (at
E       ../../../../modules/compute-mig/main.tf:155,71-72)
E         Invalid value for "v" parameter: cannot convert object to list of any single type.
E
E       At least one expression must produce a successful result.

And either way if I were to remove all the extra "try"s when trying to retrieve attributes I would get error

E        161:   name                             = var.stateful_config.per_instance_config[*].name
E           ├────────────────
E           │ var.stateful_config.per_instance_config is a list of object, known only after apply
E
E       Inappropriate value for attribute "name": string required.
E
E       Error: Incorrect attribute value type

Any thoughts on this one? Thank you again for your help in these complex configs!

Focusing for the moment of the first thing you tried:

  count = try(length(var.stateful_config.per_instance_config), tolist({}))

The second expression here failed because it’s not possible to convert {} (the value of the empty object type) to a list. That’s what the second item in the error message was reporting. tomap({}) would work in isolation, but I think it’s still not quite right because count is expecting a number value, not a map value.

I think this might be what you need:

  count = try(length(var.stateful_config.per_instance_config), 0)

This will produce zero if any part of the first expression fails. However, this approach is a bit risky because it puts more into the try than just the attribute lookup, and so this would potentially mask an error if e.g. var.stateful_config.per_instance_config were something that isn’t valid to take length of, so here’s another variant that minimizes the expression inside the try by moving the length call outside of it:

  count = length(try(var.stateful_config.per_instance_config, tomap({})))

This is equivalent to the previous example as long as var.stateful_config.per_instance_config has the expected type, but has the small advantage of being able to explicitly report a type error if that were incorrectly set to a non-collection value

However, it is more verbose and harder to read, so I might still be inclined to take the first example with the outermost try to weigh readability over the error checking reliability, particularly because var.stateful_config's type constrant already guarantees that var.stateful_config.per_instance_config must be a map, and so should catch that possible error earlier on anyway.


I used count above to focus on what was causing the error in your first example, but I also want to point out that this seems like a situation where it might be better to use for_each instead of count, because that will tell Terraform to track the google_compute_per_instance_config.default instances by the keys in your source map, rather than by arbitrary indexes that may change as you add or remove items in the map later:

  for_each = try(var.stateful_config.per_instance_config, tomap({}))

Using your example value from an earlier comment, Terraform would track an instance of this resource with the address google_compute_per_instance_config.default["instance_1"], where "instance_1" matches the key you chose in per_instance_config, and therefore if you add new entries to that map later Terraform will understand that you want to add new instances of the resource with those keys, leaving the existing one undisturbed.

for_each declares one instance for each element of the given map, so this will still declare the same number of instances, but will track them by the map element keys instead of by arbitrary integers.

1 Like

This is brilliant!

Thanks a ton for this. The only other challenge I had was how to reference each instance of the created resource (similar to the splat operator or index of count), but checking the docs I found out just need to use each.parameter:

resource "google_compute_per_instance_config" "default" {
  for_each = try(var.stateful_config.per_instance_config, tomap({}))
  zone = var.location
google_compute_region_instance_group_manager.default : google_compute_instance_group_manager.default
  instance_group_manager           = local.instance_group_manager[0].id
  name                             = each.key
  project                          = var.project_id
  minimal_action                   = each.value.update_config.minimal_action
  most_disruptive_allowed_action   = each.value.update_config.most_disruptive_allowed_action
  remove_instance_state_on_destroy = each.value.update_config.remove_instance_state_on_destroy
  preserved_state {

    metadata = each.value.metadata

    dynamic "disk" {
      for_each = try(each.value.stateful_disks, tomap({}))
      iterator = config
      content {
        device_name = config.key
        source      = config.value.source
        mode        = config.value.mode
        delete_rule = config.value.delete_rule
      }
    }
  }
}

Thank you again for the comprehensive and eloquent explanation, great to see the Hashicorp Tao applied in real life :smiley:

Cheers!