TrademarkTrademark
Features
Documentation

Terraform v1.0 Features I Love

The new features Terraform released since the release of v1 which are extremely exciting.
Brendan ThompsonJuly 8, 2022
Terraform v1.0 Features I Love
Key takeaways
  • The post highlights three Terraform features introduced since v1.0: precondition and postcondition blocks, nullable input variables, and the optional object attribute.
  • precondition and postcondition blocks live in a resource's lifecycle block and let you check properties before and after a resource is created to enforce guardrails.
  • The nullable property lets an input variable default to null, so callers are not forced to pass a value and the code can handle the null case.
  • The optional attribute, arriving in v1.3, lets you mark individual object properties as optional and set per-property default values.

A handful of features have landed in Terraform since the release of v1, and a few of them have changed how I write my code. The ones I want to cover here are:

  • precondition & postcondition
  • nullable
  • optional

This post walks through each one with examples you can drop into your own Terraform.

Note that the optional feature is not currently released, it will come out with v1.3

Pre & Post Conditions

Pre/Post Conditions are part of a resource's (or data source's) lifecycle block. They let you control the resource's lifecycle conditions more deeply, and they are evaluated as early as possible. An output can also make use of the precondition.

Why would you use these conditions?

precondition / postcondition's are extremely useful as they allow us to check properties or other resources before and after our resources are created. For instance, we might create a Kubernetes cluster and want to ensure that there are no public endpoints created, this would be done with a postcondition. Another example would be checking that a particular tag is available in your tags variable, this would be achieved using the precondition.

I see these conditions as the most useful inside of a module, especially when the interface is a little more relaxed. This means we can allow our engineers to be creative with their implementations but still enforce guardrails.

Below is an example of the format that is required for these conditions wherever they are used:

postcondition {
  condition     = self.encrypted
  error_message = "Err: the pre/post condition failed."
}

Similar to Input Variable Validation the precondition / postcondition have two fields:

  • condition - some sort of check against self, or external resources/variables that returns true or false
  • error_message - an error message to return to the engineer, this must be in sentence format

Here's a simple code example of this. Please note that there will be references to resources and variables that I won't show in the example but I will put the full script at the end of this post.

...
 
resource "azurerm_virtual_network" "this" {
  name                = format("vn-%s", local.suffix)
  location            = var.region
  resource_group_name = azurerm_resource_group.this.name
 
  address_space = var.network.address_space
  dns_servers   = var.network.dns_servers != null ? var.network.dns_servers : []
 
  tags = local.tags
 
  lifecycle {
    precondition {
      condition     = azurerm_resource_group.this.location == var.region
      error_message = "Err: resource group in incorrect region."
    }
 
    precondition {
      condition     = contains(keys(local.tags), "Environment")
      error_message = "Err: no environment tag present."
    }
  }
}
 
...

In the above example, the first precondition we have is ensuring that the location property of our resource group matches var.region, this is useful in validating that resources are created in the correct place. The second is validating that local.tags has a key named Environment.

Now here's a postcondition:

...
 
resource "azurerm_subnet" "this" {
  for_each = {
    for v in var.network.subnets :
    v.name => v
  }
 
  name                 = format("sn-%s-%s", local.suffix, each.value.name)
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
 
  address_prefixes = each.value.address_space
 
  lifecycle {
    postcondition {
      condition     = length(self.delegation) == 0
      error_message = "Err: subnet delegation in on the subnet."
    }
  }
}
 
...

Our example above shows a subnet that we are trying to create, once that resource is created we want to ensure that there are no delegations. We are doing that check via a postcondition. The self object is a special object that refers to the resource that has been created, similar to an each within a for_each loop. Using postconditions allows us to validate the state of a created resource immediately after its creation.

These examples are a little contrived, but hopefully they show why precondition / postcondition's are worth reaching for.

Nullable Input Variables

Plenty of times I've defined an input variable that I don't want to pass in on every call. My usual fix was to give it a default with some "sensible" value. That works, but it isn't always what you want. Sometimes you want a variable that you only pass in when it's really needed. The nullable property covers that case: the default can be null, and you check for it and handle it in the code.

Here's an example.

...
 
variable "tags" {
  type = map(string)
  default = null
 
  nullable = true
}
 
locals {
  tags = merge(var.tags, { Environment = var.environment })
}
 
resource "azurerm_resource_group" "this" {
  name     = format("rg-%s", local.suffix)
  location = var.region
 
  tags = local.tags
}
 
...

In the above example, we have a tags input variable, which we might not always want to provide a real value for. Traditionally we would set the default to be {} given that this variable is of type map(string), and even now I would still do this! However, for the sake of this example, we can set the default to be null as we have the property nullable defined as true. By doing this we are not forced into passing a value for this input variable into our code and can validate at the point of use if it is null or not. In some cases, it is easier to do a null check over checking the contents.

This is handy when you have a module that lots of engineers consume and you want to add a new feature without breaking the existing callers. nullable makes that much easier.

Optional Input Variable Attributes

The final, and in my opinion the most exciting feature is optional!! This is coming out shortly with the v1.3 release of Terraform.

If, like me, you tend to reach for complex input variables built with object({ ... }), you've hit the same annoyance I have: all properties of that object were required. That usually left a fair few null / {} / [] / "" scattered through my code, which is pretty unpleasant to read. With optional you can mark a property on an object as optional, so you don't have to pass in every property when you don't need to. The optional attribute also lets you set a default value for a given property, much like the default you'd set on an entire input variable.

Here's an example.

...
 
variable "network" {
  type = object({
    address_space           = list(string)
    dns_servers             = optional(list(string))
    flow_timeout_in_minutes = optional(number, 15)
    subnets = optional(list(
      object({
        name          = string
        address_space = list(string)
      })
    ))
  })
 
  default = {
    address_space = ["10.0.0.0/23"]
    subnets = [
      {
        name          = "0"
        address_space = ["10.0.0.0/24"]
      }
    ]
  }
}
 
...

As you can see on the example above, we are wrapping the type of our dns_servers and flow_timeout_in_minutes properties in the optional() attribute, this is letting Terraform know that this property does not need to be set when we pass in our object. Keep in mind, if we do not pass that object in that the property will be set to null if no default value is set on the optional attribute so any code that consumes this will need to do a null check.

By requiring our code to do null checks it is possible that we might introduce more complexity, as such I would advise caution and proper thought before using this feature.

Final Thoughts

That's a look at my favourite new features from v1.0 to v1.3 Terraform: precondition, postcondition, nullable input variables, and optional. I've been reaching for all of them in real code, and they've made my modules cleaner and easier to live with.

This article was originally published on Brendan Thompson's blog. You can follow Brendan @BrendanLiamT on Twitter.

About the author
Brendan Thompsonsolutions engineer at Scalr
Brendan Thompson is a solutions engineer at Scalr, specializing in Terraform and cloud infrastructure.