Creating a Conditional Object

I have a module defined for an EKS cluster using the AWS EKS module, and I am trying to conditionally create a gpu-specific managed node group depending on the cluster.

I have no idea if this is even feasible, I know conditionals within Terraform are classically limiting and frustrating. Here is what I have so far (invalid), but it gets the point across for what I’m trying to do which is conditionally create the gpu managed node group.

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "19.0.4"
  ...

  eks_managed_node_groups = {
    // always launch the generic group
    generic = {
      name                = "${var.cluster_name}-generic"
      iam_role_name       = "${var.cluster_name}-generic"
      security_group_name = "${var.cluster_name}-generic"
    }
    gpu = var.enable_gpu ? {
      ami_type       = "AL2_x86_64"
      capacity_type  = "ON_DEMAND"
      instance_types = ["p2.xlarge"] 

      name                = "${var.cluster_name}-gpu"
      iam_role_name       = "${var.cluster_name}-gpu"
      security_group_name = "${var.cluster_name}-gpu"

      desired_size = 1 
      max_size     = 3 
      min_size     =  0 

      update_config = {
        max_unavailable_percentage = 50
      }

      create_schedule = true
      schedules = {
        schedule-scale-down = {
          recurrence       = "0 1 * * 1-5"
          min_size         = -1
          max_size         = -1
          desired_capacity = 0
        },
        schedule-scale-up = {
          recurrence       = "0 13 * * 1-5"
          min_size         = -1
          max_size         = -1
          desired_capacity = 1
        }
      }
    } : {}

Is this even possible? Other ideas I’ve had are to conditionally merge the generic and gpu node groups in locals like so

locals {
  default_managed_node_group = {
    generic = {
      name                = "${var.cluster_name}-generic"
      iam_role_name       = "${var.cluster_name}-generic"
      security_group_name = "${var.cluster_name}-generic"
    }
  }

  gpu_managed_node_group = {
    gpu = {
      ami_type       = "AL2_x86_64"
      capacity_type  = "ON_DEMAND"
      instance_types = ["p2.xlarge"] 

      name                = "${var.cluster_name}-gpu"
      iam_role_name       = "${var.cluster_name}-gpu"
      security_group_name = "${var.cluster_name}-gpu"

      desired_size = 1 
      max_size     = 3 
      min_size     =  0 

      update_config = {
        max_unavailable_percentage = 50
      }

      create_schedule = true
      schedules = {
        schedule-scale-down = {
          recurrence       = "0 1 * * 1-5"
          min_size         = -1
          max_size         = -1
          desired_capacity = 0
        },
        schedule-scale-up = {
          recurrence       = "0 13 * * 1-5"
          min_size         = -1
          max_size         = -1
          desired_capacity = 1
        }
      }
    }
  }

  # Conditional fails when var.enable_gpu = false
  eks_managed_node_groups = var.enable_gpu ? merge(local.default_managed_node_group, local.gpu_managed_node_group) : local.default_managed_node_group
  
}

which fails with The true and false result expressions must have consistent types. The 'true' value includes object attribute "gpu", which is absent in the 'false' value. when var.enable_gpu is false.

I feel like this should be a relatively common issue, but I can’t find a single instance of someone else trying to do this.

Your question intrigued me, because I’m a bit surprised that I’ve never bumped into this particular issue before.

I can understand why Terraform is complaining here… and also see how it’s a really irritating and pointless thing for it to be complaining about.

I found that Terraform is happy to accept it if you rewrite your merge invocation like so:

locals {
  eks_managed_node_groups = merge(
    local.default_managed_node_group,
    var.enable_gpu ? local.gpu_managed_node_group : {},
  )
}

I’m … actually not entirely sure why the Terraform type system is happy with this when it’s not happy with the other variant. It seems to work, though!

1 Like

I have a feeling that this latter form is appearing to work because Terraform is making an incorrect deduction about what type local.gpu_managed_node_group is intended to be, though I’m not sure cause I’ve not looked closely at what’s going on here.

I think the robust solution here would be to give Terraform more information about what types you are intending these different values to have, so it’s not forced to guess… in the original question it’s guessing incorrectly and therefore returning an error, while in this other variant it seems to be guessing incorrectly in a way that succeeds but might well be doing something unexpected, such as converting all of the numbers to strings so that is can be automatically converted to a map.

From what you’ve shared I understand both gpu_managed_node_group and default_managed_node_group as being maps of a particular object type whose attributes correspond with the arguments of a resource type in AWS, in which case I’d write them out like this so Terraform has more information with which to understand that intent:

locals {
  default_managed_node_groups = tomap({
    generic = {
      ami_type            = null
      capacity_type       = null
      instance_types      = null
      name                = "${var.cluster_name}-generic"
      iam_role_name       = "${var.cluster_name}-generic"
      security_group_name = "${var.cluster_name}-generic"
      desired_size        = null
      max_size            = null
      min_size            = null
      update_config       = null

      # This attribute being null represents that
      # schedules should not be declared.
      schedules           = null
    }
  })
  gpu_managed_node_groups = tomap({
    gpu = {
      ami_type       = "AL2_x86_64"
      capacity_type  = "ON_DEMAND"
      instance_types = toset(["p2.xlarge"])

      name                = "${var.cluster_name}-gpu"
      iam_role_name       = "${var.cluster_name}-gpu"
      security_group_name = "${var.cluster_name}-gpu"

      desired_size = 1 
      max_size     = 3 
      min_size     = 0 

      update_config = {
        max_unavailable_percentage = 50
      }

      # This argument being non-null represents
      # that schedules should be declared.
      schedules = tomap({
        schedule-scale-down = {
          recurrence       = "0 1 * * 1-5"
          min_size         = -1
          max_size         = -1
          desired_capacity = 0
        },
        schedule-scale-up = {
          recurrence       = "0 13 * * 1-5"
          min_size         = -1
          max_size         = -1
          desired_capacity = 1
        }
      })
    }
  })

  eks_managed_node_groups = (
    var.enable_gpu ?
    merge(local.default_managed_node_groups, local.gpu_managed_node_groups) :
    local.default_managed_node_groups
  )
}

With this structure both of the “arms” of the conditional expression are of the same type (map(object({ ... }))) and so Terraform should be able to resolve this expression into a new value of that same type.

The attributes set to null in the first object should be automatically inferred as having the same types as the non-null attributes of the same name in the other object, so the result type will be:

map(object({
  ami_type            = string
  capacity_type       = string
  instance_types      = set(string)
  name                = string
  iam_role_name       = string
  security_group_name = string
  desired_size        = number
  max_size            = number
  min_size            = number
  update_config = object({
    max_unavailable_percentage = number
  })
  schedules = map(object({
    recurrence       = string
    min_size         = number
    max_size         = number
    desired_capacity = number
  }))
}))

If Terraform does not succeed in deducing this type, then my next step would be to add type conversions to some or all of the null values in the first object, like tomap(null) or tonumber(null); each new type conversion you add removes ambiguity and therefore allows Terraform to more correctly understand what type of value you are intending to construct, without so much guesswork.

Hi @apparentlymart ,

Your suggestion is an interesting intellectual exercise in examining the internals of the Terraform type system… but are you really suggesting users write out a great deal of cumbersome something = null boilerplate in real production configs?

It’s not going to make sense to anyone who hasn’t made an in-depth effort to understand the Terraform type system, so isn’t great for Terraform’s reputation as a user-friendly tool.

Playing around with terraform console, I do see the unwanted conversion you described, in a way which would be a problem, if this configuration applied the ?: operator directly to a group definition object value - e.g.:

> (true ? {ami_type = "foobar", desired_size = 42} : {})
tomap({
  "ami_type" = "foobar"
  "desired_size" = "42"
})

(Notice that desired_size has become a string.)

But it turns out that since this configuration is always working with { name = value } structures, it becomes immune to the issue, as only the top-level type is being coerced to map - compare this example, where the previous value is wrapped in { some_name = ... }:

> (true ? {some_name = {ami_type = "foobar", desired_size = 42}} : {})
tomap({
  "some_name" = {
    "ami_type" = "foobar"
    "desired_size" = 42
  }
})

So I think the construction I suggested ends up being safe (not that I knew about this pitfall at the time I originally made it!).

Ah this is really insightful, I never would have thought to rewrite the statement in this way. I appreciate it!

I am suggesting that when you get type errors in Terraform it typically means that Terraform has reached the wrong conclusion about what you were implying and that you should fix that by implying less. The way I typically imply less is to write out exactly the type I intend to write, and I consider the result to be more readable because it’s now explicit about exactly what type is being written, but I understand that others prefer brevity at the expense of possible ambiguity.

With that said, I would indeed prefer there to be a syntax for doing this via explicit type constraint. Terraform is often held back by its Terraform v0.11-and-earlier legacy where the type system was essentially non-existent, and this is one such case. Hopefully one day Terraform can do better here by making it possible to write out exactly the type you intended as a type constraint, rather than always relying on type inference and then goading Terraform into inferring the correct type via individual type conversion.

I think you’re right though that what you wrote out does allow Terraform to infer your intent correctly. Specifically, in your most recent example Terraform seems to have understood correctly that you intended to return map(object({ami_type = string, desired_size = number})), but the overall merge expression will return different types depending on whether var.enable_gpu is set, which I would consider to be undesirable because it may mask errors elsewhere in the module.

1 Like