You've successfully subscribed to Nuvotex Blog
Great! Next, complete checkout for full access to Nuvotex Blog
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info is updated.
Billing info update failed.

Configurable default values on terraform objects

Creating complex configurations requires many dimensions on the input - this post shows how to take care of default values in such a case to offer users of your code fine grained control without enforcing too much input if not required.

Daniel Nachtrub
Daniel Nachtrub

Depending on your personal favor you might be using terraform in a sense where you pass all values/variables in a simple way into the code - just by issueing a variable per value.

This works fine for smaller modules or simple configurations. On more complex environments you might end up having the demand for more more sophisticated values that might be passed as an object.

Let's say or example, you want to pass a list of firewall rules - each rule has some mandatory values, like action, priority or maybe the protocol (assuming that most rules might be tcp).

The pattern shown in this post allows the user of your code to override your defaults.

Configurable defaults

Let's take the example with the firewall - you can write a terraform module and enable your users to supply custom default values. This can look like this:

variable "filter_rules_ipv4_defaults" {
  type = object({
    enabled                      = optional(bool)
    comment                      = optional(string)
    action                       = optional(string)
    chain                        = optional(string)
    protocol                     = optional(string)
    port                         = optional(string)
    interfaces                   = optional(list(string))
    interfaces_negate            = bool
    source_addresses             = optional(list(string))
    source_addresses_negate      = bool
    destination_addresses        = optional(list(string))
    destination_addresses_negate = bool
    priority                     = number
    auto_rule = object({
      create_related_deny_rule                  = bool
      create_deny_rule_without_interface_filter = bool
    })
  })
  default = {
    priority                     = 100
    interfaces_negate            = false
    source_addresses_negate      = false
    destination_addresses_negate = false
    auto_rule = {
      create_related_deny_rule                  = false
      create_deny_rule_without_interface_filter = false
    }
  }
  nullable    = false
  description = "Default values for filter rules."
}

variable "filter_rules_ipv4" {
  type = map(object({
    enabled                      = optional(bool)
    comment                      = optional(string)
    action                       = string
    chain                        = string
    protocol                     = string
    port                         = optional(number)
    interfaces                   = optional(list(string))
    interfaces_negate            = optional(bool)
    source_addresses             = optional(list(string))
    source_addresses_negate      = optional(bool)
    destination_addresses        = optional(list(string))
    destination_addresses_negate = optional(bool)
    priority                     = optional(number)
    auto_rule = optional(object({
      create_related_deny_rule                  = optional(bool)
      create_deny_rule_without_interface_filter = optional(bool)
    }))
  }))
  description = "A map of firewall rules"
  default     = {}
  nullable    = false

  validation {
    condition     = alltrue([for rule in var.filter_rules_ipv4 : contains(["input", "forward", "output"], rule.chain)])
    error_message = "Invalid chain value. Must be either 'input', 'forward' or 'output'."
  }

  validation {
    condition     = alltrue([for rule in var.filter_rules_ipv4 : contains(["accept", "drop", "reject"], rule.action)])
    error_message = "Invalid protocol value. Must be either 'accept', 'drop' or 'reject'."
  }

  validation {
    condition     = alltrue([for rule in var.filter_rules_ipv4 : rule.port == null || try(rule.port >= 0 && rule.port <= 65535, false)])
    error_message = "Invalid port value. Must be between 0 and 65535."
  }
}

example code for customizable defaults

This will provide two variables:

  • One that holds the firewall rules as a map
  • One that holds the default values as a single object

Now, to merge the values, the rules will be iterated and for every iteration there will be a merge - either using terraform's native merge or for example the deepmerge module (registry.terraform.io/isometry/deepmerge)

The merge then looks like this:

user_rules_input_ipv4 = {
    for k, v in var.filter_rules_ipv4 : k => provider::deepmerge::mergo(var.filter_rules_ipv4_defaults, { for mk, mv in v : mk => mv if mv != null })
}

merging values

This pattern is quite powerful, especially if a user should be able to customize the defaults on invocation to not be forced to specifiy all values over and over again in the map/list of inputs. My next post will cover another approach.

TerraformIaCCloud

Daniel Nachtrub

Kind of likes computers. Linux foundation certified: LFCS / CKA / CKAD / CKS. Microsoft certified: Cybersecurity Architect Expert & Azure Solutions Architect Expert.