Configurable default values on terraform objects

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.