Terraform coding guidelines
Terraform code writing practices
General
-
Terraform modules should introduce ease of use not the other way around. Rule of thumb is do not write a module for one resource, it is probably easier to implement without a module. Exception is for a large resource to be able to deploy it with minimal inputs, when modules uses sane defaults for everything else.
-
Do not shorten on change names of variables in the resource, it makes for much easier use of the module comparing it to the official documentation of the resource.
-
Make modules easy to use for other engineers if I can write less lines when using your module than using a resource block I’m going to use the module.
File structure
-
Module should consist of main.tf, variables.tf outputs.tf versions.tf
-
main.tf - Should host all resource definitions, internal processing and local values if needed
-
variables.tf - Should contain all variable definitions, no processing or local values
-
outputs.tf - Should contain all output definitions and nothing else. Output value accepts some processing before return a value, that is permitted.
-
versions.tf - Should only contain
terraform{}
block with appropriate values.
-
Naming
-
Always follow the naming convention set by your organization, it will help you in many cases when used consistently
-
Use of prefix variable is encouraged to describe the first part of a resource name, it usually is location-client name-tier/environment that are mostly consistent throughout the resources
-
When authoring a module use prefix variable to pass fully made prefix rather than trying to assemble it inside the module, for example use this:
variable "prefix" {
type = string
default = "prefix"
description = "(Required) Specifies the Prefix to prepend on all resources. Changing this forces a new resource to be created."
}
- To maintain consistency the naming of a resource should be done through
format()
function which is build-in Terraform feature. This allows us to have a complex naming resolution within the module while keeping consistent through out the modules.
resource "azurerm_app_service" "prod" {
name = format("%s-app", var.prefix)
....
}
Versioning
-
Terraform has a versioning block in it self to prevent outdated dependencies, this should be kept in separate
versions.tf
file within the project directory -
We should lock down lowest possible version of a dependency for example
for_each
block has been introduced in Terraform version0.12.6
so if your module usesfor_each
this should be noted. -
Restrictions should apply to major providers of Terraform meaning that you may leave out the restriction block for build in providers like
random
ortls
but should apply toAzureRM
,AWS
,OCI
etc. -
Terraform and Provider versions are usually locked down in the root module, so child modules may just have
">=x.x.x"
notation meaning no less than provided version.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 2.8"
}
}
required_version = ">= 0.12.26"
}
NOTE: The above example is valid from Terraform 0.12.26
Reference [https://www.terraform.io/docs/configuration/provider-requirements.html#requiring-providers]
Variable and Output definitions
Both variables and outputs should be defined in separate files from the main code, variables.tf
and outputs.tf
respectively.
Variables
Should have name
, type
, default
, description
values.
variable "prefix" {
type = string
default = "prefix"
description = "(Required) Specifies the Prefix to prepend on all resources. Changing this forces a new resource to be created."
}
-
Name
would usually match the name described in Terraform resource, for example if a resourceazurerm_resource_group
requires a value forlocation
our module should ask the namelocation
exactly. Type
similar to Go types but has some uniqueness for example:string
number
bool
map(string)
list(string)
list(number)
object({key = value})
-
Default
should be a sane value, for any optional features we would use false for naming like prefix default value would be “prefix” to clearly see if this has not been provided, for any features and options that should be omitted by default usenull
Description
Use common information that you would want to see here, for example load_balancer_type can be ether"Basic"
or"Standard"
but it is a string value thus a free text field, in the description you can mentions that these are the only possible values.
NOTE: prefix variable block above is considered correct.
Outputs
Similarly to variables it must have a name, value and description. From modules you should pass the full object as output rather than just one value. So the below is considered correct:
output "resource_group" {
description = "Outputs the full resource group object from this module"
value = azurerm_resource_group.rg
}
Name
should match the resource you are outputting minus the provider
Value
should be the full object of what you are outputting (exceptions apply)
Description
mostly required to provide accurate documentation by automation so should be useful common information. Favored by Terraform module repository
Formatting
For linting use command terraform fmt
Some IDEs provide formatting for HCL out of the box but tends to break a lot of stuff so terraform fmt
is still the preferred way.
Commenting
For single line commenting we use ‘#’ sign. It is the default commenting style for terraform and should be used in most cases. Comment style used should usually be on top of the line rather than an inline comment.
Reference: https://www.terraform.io/docs/configuration/syntax.html#comments
Features
Module should be able to access most or all the features exposed by the resource it provisions, although all not mandatory features should be disabled by default. Sane defaults should be used.
When introducing a new feature, please keep in mind that it may break other workflows so do not make required variables needlessly.
Testing
As any code we write, Terraform code can be tested, there is still a heated argument within IaC community on how should the infrastructure code be tested and verified. We use Terratest for some sanity tests and to make sure code actually runs.
For now complex tests are not required although we need to make sure the module runs with the basic configuration so the test can look like this:
package tests
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestTerraformBuild(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "./fixture_simple",
Vars: map[string]interface{}{},
}
terraform.InitAndApply(t, terraformOptions)
defer terraform.Destroy(t, terraformOptions)
}
Documentation
While I do not prophesize any specific template please create a README.md
file and describe what the module does as well some example usage.
You can also use terraform-docs to generate basic information of your module.
Advanced
Get comfy in your chair maybe get your favorite beverage or something stronger before going further
Advanced module development requires making some key decisions when thinking of scaling the deployment.
First topic is to use for_each rather than count because of how count works it will not be aware in some situations that we need to get rid of or modify something in the middle of machine array.
Let’s image a possible implementation
locals {
vm_count = 2
disk_count = 5
}
resource "provider_vm" "vm" {
count = local.vm_count
os_disk = true
}
resource "provider_additional_data_disk" "disk" {
count = vm_count * disk_count
attach_disk_to = provider_vm.vm[count.index % disk_count]
}
This is how Terraform will make the assignments from out imagined code:
- machine[0] will get data_disk[0]
- machine[1] will get data_disk[1]
- machine[0] will get data_disk[2]
- machine[1] will get data_disk[3] and so on….
This implies that if we add additional Machine into the mix like vm_count = 3
everything in the workflow will change
The assignments will screw up as an extra Machine is inserted in the queue.
- machine[0] will get data_disk[0]
- machine[1] will get data_disk[1]
- machine[2] will get data_disk[2]
- machine[0] will get data_disk[3]
- machine[1] will get data_disk[4]
- machine[2] will get data_disk[5] and so on..
To circumvent this we need to use for_each where applicable this will use names for iterating over resources. To make this happen we need some internal processing to generate a set of Machines and Disks.
For this we can use setproduct()
another Terraform built in feature which will generate correct lists for us.
To make this a bit more visual
setproduct(["machine01", "machine02", "machine03"], ["disk01", "disk02"])
will generate:
[
[
"machine01",
"disk01",
],
[
"machine01",
"disk02",
],
[
"machine02",
"disk01",
],
[
"machine02",
"disk02",
],
[
"machine03",
"disk01",
],
[
"machine03",
"disk02",
],
]
Which will always make sense and will not drift like count would.
Note: this example is incomplete, if you wish to find the values needed for each there is a good example in Terraform documentation: https://www.terraform.io/docs/configuration/functions/setproduct.html#finding-combinations-for-for_each