Terraform Best Practices: Versioning

Versioning is an important topic when authoring any kind of code, and Terraform is no exception. We’ll discuss the best practices for versioning Terraform code. We’ll also discuss how to use versioning to manage the lifecycle of your infrastructure.

Problems

Versioning has presented challenges for many platforms and it includes aspects of managing dependencies and ensuring consistency and reliability for those that rely on your code and for you when consuming others’ code. Breaking changes can be introduced and allowing the default behavior of pulling the latest version of a dependency can lead to unexpected changes in behavior.

Semantic Versioning

Semantic versioning is a versioning scheme that is widely used in the software industry. It is a simple and easy to understand versioning scheme that allows for a clear understanding of the impact of a version change. The version is composed of three numbers separated by periods: MAJOR.MINOR.PATCH. The numbers are incremented as follows:

  • MAJOR version when you make breaking API changes,
  • MINOR version when you add functionality in a backwards-compatible manner, and
  • PATCH version when you make backwards-compatible bug fixes.

HashiCorp has adopted semantic versioning for Terraform modules and providers. This allows for clear communication of the impact of a version change. If code is written by someone else, you will want to know if adhere to semantic versioning so you can understand the impact of a version change.

Using Semantic Versioning is a contract with your users. It allows them to understand the impact of a version change and to make informed decisions about when to upgrade. It also allows you to communicate the impact of a change to your users. The outcome depends on adherence and thoughtfulness to these concepts.

Changes that are bug fixes should not introduce any issues unless you were treating a bug as a feature. Changes that are new features should not break existing functionality. If a new feature is implemented, it should be introduced in such a way that nothing about existing code changes since it isn’t providing inputs related to the feature.

When breaking changes can exist, then your code should be thoroughly tested against the new major version. This is why it is important to have a good test suite for your code.

Terraform Versioning

Terraform offers three situations that can control version:

  • Terraform Core
  • Providers
  • Modules

Versions are specified with different arguments that include string expressions of the version constaints. Multiple constraints can be specified by separating them with a comma. Examples of version constraints:

  • Exact version: version = "1.2.3" or version = "=1.2.3"
  • Not equal to a version: version = "!=1.2.4"
  • Greater than or equal to a version: version = ">=1.2.3"
  • Greater than or equal to a version, but less than another version: version = ">=1.2.3, <2.0.0"
  • Using the pessimistic operation to acheive a similar result as the previous example: version = "~>1.2"

The pessimistic operator relies on the principles of semantic version and only allows the last portion of the version constraint that is provided to increment. If all three parts of the version are provided, only the PATCH portion can increment. If the PATCH portion is ommitted from the constraint, thent he MINOR portion can increment and any PATCH version is allowed.

If no version constraint is provided, the latest version is used. This is not recommended for production code. It is recommended to use a version constraint to ensure that the code is stable and predictable.

Version constraints must be satisfied throughout the codeset. This includes the root module and any modules that are called.

Terraform Core

Terraform Core is the main Terraform binary. It is the tool that you use to interact with Terraform. Terraform Core is versioned using semantic versioning. You can use the terraform version command to see the version of Terraform Core that you are using. Tools like tfenv or establishing Developer Containers allow better control of the version of Terraform Core that you are using.

Terraform code can specify the version of the Terraform Core binary within the terraform block with the required_version attribute. If the version of Terraform used to execute the code is outside of the version constraints, Terraform will exit with an error:

Error: Unsupported Terraform Core version
│ on main.tf line 2, in terraform:
│ 2: required_version = "~> 2.0"
view raw shell.sh hosted with ❤ by GitHub

Implementation of required_version:

terraform {
required_version = "~> 1.7"
}
view raw main.tf hosted with ❤ by GitHub

Providers

Providers and modules work a bit differently from the constraints on Terraform Core. Version constraints are implemented within the required_providers nested block inside the terraform block, but use the same version constraints as Terraform Core. The version constraints control the version of the provider downloaded when terraform init is run.

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
view raw providers.tf hosted with ❤ by GitHub

If the current version of the provider that is cached is outside the version constraints, Terraform will exit with an error:

│ Error: Inconsistent dependency lock file
│ The following dependency selections recorded in the lock file are inconsistent with the current
│ configuration:
│ – provider registry.terraform.io/hashicorp/azurerm: locked version selection 3.91.0 doesn't match the updated version constraints "~ 4.0"
│ To update the locked dependency selections to match a changed configuration, run:
│ terraform init -upgrade
view raw shell.sh hosted with ❤ by GitHub

This could be fixed by the command recommended in the error, so long as there is a valid version that meets the constraints: terraform init -upgrade

In addition, the version constraints must be met not only within the code in the root module, but each module that is called.

Modules

Modules work similarly to providers with the exception to how modules are sourced. The source attribute in the `module` block is used to specify the location of the module. The version attribute is used to specify the version of the module to use. The version attribute can only be used with modules that are sourced from a registry that implements the Module Registry Protocol, which would include the public Terraform Registry, and private registries supported by Terraform Enterprise and Terraform Cloud.

However, modules can be sourced from a Git repository or locally from the filesystem.

In a Git repository, the version attribute cannot be used. Instead, the query string of the Git URL can include a ref tag to specify the version.

In order to control the version for a locally sourced module, the code within the directory would need to match what is expected. If multiple versions need to be maintained locally, they could be in different directories.

module "local" {
source = "./modules/name/v1.0.0"
}
view raw local_module.tf hosted with ❤ by GitHub

Keep in mind that this an anti-pattern. It would require storing modules on a filesystem in a directory structure, rather than using a version control system. Locally sourcing modules is useful while at the beginning of module development before it has been used. However, using tests is a better way to support that process, then commit to a repository. However, the best practice is to source from a module repository, with the public registry or a private registry hosted in Terraform Cloud or Enterprise.

Using a private registry is a best practice for any organization that desires stability. The public registry doesn’t offer many assurances. A supply-chain attack could leave to the compromise of a module that is published. Using the HTTP provider, an attacker could exfiltrate information from code. In additional, if Terraform execution is triggered by events, such as using Consul-Terraform-Sync in order to update load balancers, firewall rules, or more based on scaling events observed by Consul, if the registry is inaccessible or offline, then Terraform will fail to execute. A private registry offers the ability to currate public modules and have them stored in a location with greater assurances of availability. Private registries can be used for modules and providers.

Final Thoughts

Versioning in Terraform is a critical aspect of infrastructure management. It ensures that the correct versions of providers and modules are used, which is crucial for the stability and reliability of the infrastructure. Providers are cached based on their version constraints, and if these are not met, Terraform will exit with an error. This can be fixed by upgrading the Terraform initialization.

Leave a comment