Terraform - applying complex default values
This post explores handling default values in Terraform. It shows merging default values with instance-specific values, using both native merge and the deepmerge module for recursive merging of nested objects. This approach ensures compact, maintainable code and seamless use of 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.