Terraform - applying complex default values

Working with terraform often provides you with the challenge to support default values in your code.

Isn't it easy to use defaults on the TF vars? So why am I writing a blog post about it?

I'm having some development background in object-oriented languages, so i tend to use object types in my TF vars, like this:

variable "vrrp_interfaces" {
  type = map(object({
    enabled         = optional(bool)
    comment         = optional(string)
    interface       = string
    vrid            = number
    priority        = optional(number)
    group_authority = optional(string)
    interval_ms     = optional(number)
    mtu             = optional(number)
    on_backup       = optional(string)
    on_fail         = optional(string)
    on_master       = optional(string)
    preempt_enable  = optional(bool)
    v3_protocol     = optional(string)
    version         = optional(number)
    connection_tracking = optional(object({
      enabled        = bool,
      remote_address = string
    }))
    addresses = optional(map(object({
      type         = string
      address      = string
      static_route = optional(any)
    })))
  }))
  default     = {}
  nullable    = false
  description = "A map of VRRP interfaces"

  validation {
    condition     = alltrue(flatten([for _, v in coalesce(var.vrrp_interfaces, {}) : [for _, a in v.addresses : contains(["local", "failover"], a.type)] if v.addresses != null]))
    error_message = "values for addresses must have a type of 'local' or 'failover'"
  }
}

example var

This means, someone can provide quite some properties to each defined instance (in this case VRRP instances). Using an object as variable provides me quite some compact form to feed values into the instances (and quite some other benefits), but makes it harder to support defaults.

So, how do I do defaults quick and easy?

Merge defaults

My solution here is to do an object merge from a default value (that can have same properties like my actual config object) and merge these defaults with the instances.

The defaults look like this:

variable "vrrp_interfaces_defaults" {
  type = object({
    priority        = optional(number)
    comment         = optional(string)
    enabled         = optional(bool)
    group_authority = optional(string)
    interval_ms     = optional(number)
    mtu             = optional(number)
    on_backup       = optional(string)
    on_fail         = optional(string)
    on_master       = optional(string)
    preempt_enable  = optional(bool)
    v3_protocol     = optional(string)
    version         = optional(number)
  })
  description = "Default values for VRRP interfaces"
  nullable    = false
  default = {
    group_authority = "none"
    preempt_enable  = true
  }
}

default values

There are two ways to merge.

Native merge

resource "routeros_interface_vrrp" "vrrp_interfaces" {
  for_each                 = { for k, v in coalesce(var.vrrp_interfaces, {}) : k => merge(var.vrrp_interfaces_defaults, { for mk, mv in v : mk => mv if mv != null }) }
  name                     = each.key
  disabled                 = lookup(each.value, "enabled", true) == false
  comment                  = coalesce(lookup(each.value, "comment", null), format("vrrp_interface_%s", each.key))
  ...
  v3_protocol              = each.value.v3_protocol
  version                  = each.value.version
}

resource definition

What's happening here:

  • We're iterating over the values
  • On each value, we're merge the default value
  • On each value, we're taking the instance value and keep only fields that have a non-null value set (to make sure defaults will "survive" the merge)

The benefit is easy - on each iteration, each.value uses just the value of each iteration and you don't need to fiddle around with lookup or try between the value and the defaults.

Pushing further - recursive merge a.k.a. deepmerge

If you have been looking very careful in my example, you could wonder if it's possible to set connection_tracking.remote_address using the defaults and enabled per instance. Using the native merge it's not possible because having a connection_tracking object in any instance, it would override the default.

The solution is to use a terraform module - in our case: registry.terraform.io/isometry/deepmerge

Just add this module and slightly adjust the code:

resource "routeros_interface_vrrp" "vrrp_interfaces" {
  for_each                 = { for k, v in coalesce(var.vrrp_interfaces, {}) : k => provider::deepmerge::mergo(var.vrrp_interfaces_defaults, { for mk, mv in v : mk => mv if mv != null }) }
  name                     = each.key
  disabled                 = lookup(each.value, "enabled", true) == false
  comment                  = coalesce(lookup(each.value, "comment", null), format("vrrp_interface_%s", each.key))
  ...
  v3_protocol              = each.value.v3_protocol
  version                  = each.value.version
}

using deepmerge

Summary

We're using this approach on several modules and i just can say: it's quite awesome. Using objects for the variables not only provides the ability to merge stuff, it's already in place in the code by just adding the fields to the vars.