Please note that this post, first published over a year ago, may now be out of date.
You might have missed the latest release of HashiCorp’s Terraform 1.3 in the midst of autumn conference season and being released just ahead of HashiConf Global. But the latest release also didn’t come with a big flash as it’s introducing more stable improvements. Nevertheless, I think there are two features that got me excited about and got my attention. Let’s see what those are and why you should care!
Code maintenance is hard
So if you consolidate your reusable blocks of Terraform code in modules (which you should be doing, really), up until now you would face two major constraints. The first one is related to Terraform type constraints:when using object type to handle complex data structures, you wouldn’t be able to define optional attributes. The second constraint is related to the moved
block introduced in Terraform 1.1 to assist with code refactoring. It’s a very useful feature, but it’s limited to only changes within the same module package. So you could move resources into child modules that reside in the same directory structure as the root module, but you couldn’t move resources to a module in an external location. So how would this manifest in the Terraform code?
What does this mean for my code?
Let’s break those two features down, look at a few code examples, dive into how they would affect the existing codebase and perhaps introduce some improvements or simplifications.
Optional attributes
When defining Terraform variables you can use the type
argument to define type constraints. In simple use cases this can be string
, number
or bool
. But when you want to use more complex data structures where you may need to group multiple values into a single variable you could use the object
type, which is a collection of named attributes, each having their own type. Up until now you would either need to define all argument types and specify them or you could fall back to using the special type any
which is a placeholder and would allow any type to be passed down. In this case Terraform would try to make an assumption and guess what that is.
But if you want to use this variable, for example passing it down to your (community) module, you’d need to provide all values, otherwise you’d end up with an error message similar to this, notifying you about a failed object check:
Error: Missing required argument
on main.tf line 461, in module "object_storage":
461: module "object_storage" {
The argument "account_email" is required, but no definition was found.
So this meant if you wanted to support optional arguments you’d need to include a lot of horrible conditionals or ternary operators in order to allow this behaviour.
Based on this the community proposed a feature that would allow marking an object’s named attributes as optional. This feature was added to Terraform 0.14 under experimental support and eventually became one of the most upvoted community GitHub issues!
Following that massive traction and community support Terraform 1.3 graduated the optional object type attribute feature from experimental status and added the ability to set a default value. The benefit of this feature is to provide module authors a way to extend their modules to handle more complex use cases using the object type. This also avoids forcing module consumers to provide values for attributes that are not relevant to their work.
When declaring an input variable whose type constraint includes an object type, you can now declare individual attributes as optional, and specify a default value to use if the caller doesn’t set it. For example:
variable "volumes" {
type = list(object({
name = string # name will be null if not specified
enabled = optional(bool, true) # default to true
vendor = optional(string)
params = optional(object({
type = optional(string, "ssd")
capacity = optional(number, 100)
iops = optional(number)
}), {})
}))
}
Assigning [{ name = "foo" }]
to this variable will result in the value [{ name = "foo", enabled = true, vendor = null, params = {} }]
.
If you assign [{name = "foo"}, { params = { type = "ssd" }}]
then that becomes a two-item list: [{ name = "foo", enabled = true, vendor = null, params = {} }, { name = null, enabled = true, vendor = null, params = { type = "ssd" } }]
. Some other code within the module could then generate a name
automatically for the second item in the list, giving you automatic naming and the change to set an explicit override.
Move to external resources
The moved block was introduced in version 1.1 to provide a programmatic method for refactoring resources within a config file. This update removed the dependency on using terraform state mv
, which was manual, error-prone, and would often lose the context of refactor operations to the command line history. Leveraging a moved
block within the source code causes Terraform to instead treat the existing object at the old address as if it now belongs at the new address. There was one limitation with the initial release of the moved block: they only supported refactoring moves if they were between modules within the same local path.
This release removes that limitation by adding the ability to refactor resources into third-party and separately sourced blocks. This includes modules sourced from the Terraform Registry, a private registry hosted within Terraform Cloud, or any of the options available through the source argument.
Here is an example of using the moved
block to refactor a resource into a module sourced from Terraform Cloud’s private registry:
moved {
from = aws_instance.app
to = module.web-server.aws_instance.app
}
module "web-server" {
source = "app.terraform.io/scalefactory/web-server"
version = "1.0.3" # when moving, it's important to pin the module version
prefix = var.prefix
region = var.region
key_name = var.key_name
instance_type = var.instance_type
}
After the move is complete, you can remove the moved
block. If you want to, you can also widen the pin for the module version; for this example, you could switch to "~> 1.0"
.
Simpler is usually better – KISS rule
This is great for community modules, where module APIs are contracts, and also for internal consumed modules, where inputs define important interfaces between teams and organisation units. Keeping your code simple doesn’t only make it easier to read, but also contributes to shorter on-boarding times, lowers maintenance burden to your platform teams and lowers your total cost of ownership (TCO) of your solution. If you work in a SaaS space, this could have an important impact on your funding burndown or tenant pricing!
Next steps
If you are interested in the other changes that came with the 1.3 release (including security hardening, backend deprecation, new functions startswith
/ endswith
, output changes and others), have a look at the changelog, Terraform 1.3 upgrade guide and optional object type attributes documentation. Also if you are using a SaaS platform to run your Terraform, such as HashiCorp’s Terraform Cloud, you might want to consider a major version bump to require Terraform 1.3, ahead of mentioned features, as well as Terraform Cloud support for Health Checks (Drift detection and Continuous validation).
Whether you’re writing Terraform code for the wider community, for your colleagues, or just for your own team, Terraform 1.3 offers a good way to make API contracts that you can stick to.
Keeping on top of all the latest features can feel like an impossible task. Is practical infrastructure-modernisation an area you are interested in hearing more about? Book a free chat with us to discuss this further.
This blog is written exclusively by The Scale Factory team. We do not accept external contributions.