In this blog, we are going to go through the OpenTofu language in depth by focusing on the various options you have when writing OpenTofu code. Let's kick into it! The following concepts will be covered:
Blocks
Resource Blocks
Datasource Blocks
Provider Blocks
Variables & Outputs
Modules
Imports
Functions
Terraform Settings
First, before we talk about these different types of blocks we need to understand what a block is comprised of.
Type: the type of block that is being declared, as examples; resource, data, check
Label: his depends on the type of block, both data and resource blocks require the use of a label as they instruct OpenTofu which service resource to operate on. As an example data "scratch_string" "" {} the label for thatdata block is scratch_string which indicates both the provider and the provider resource.
Identifier: the unique ID that Terraform and engineers will use to reference this particular instance as an example data "scratch_string" "unique_name" {}.
Arguments: a block will contain n-number of arguments, these are use to compose the properties of the block type.
Body: the body of a block is demarcated by the use of a pair of curly braces { }, everything that is between those braces makes up the body.
Blocks: inside a block can be any number of nested blocks, these apply by the same rules as their parent.
The Resource Block uses the same format that we defined earlier for all blocks. These blocks are responsible for provisioning resources on a platform or service, and example of this might be a workspace on a Scalr instance which would use a resource "scalr_workspace" "this" block. Let's dive a little deeper into the example:
From the above definition of a block we can break up the resource into it's components:
Type:resource
Label:scalr_workspace
Identifier:this
Arguments:name, environment_id and working_directory
These components all make up what is a resource in OpenTofu and it is how we easily and
consistently describe what a service resource is.
Data Source Blocks
Data Source blocks are how we get information about resources that already exist on a platform, perhaps they were created by another OpenTofu definition or even manually, or we just want to query an external API to get some information. All of this would be done using a data block. As a follow on example to our above Resource Block example instead of statically putting in the environment_id we can use a Data Source to retrieve that for us, this ensure that the information is always up to date and correct.
data "scalr_environment" "dev" {
name = "dev"
account_id = "acc-xxxxxxxxxx"
}
In this example we can see the type as defined by the keyword data and that it's going to be identified as dev. Taking this new data block we can refactor the resource block:
data "scalr_environment" "dev" {
name = "dev"
account_id = "acc-xxxxxxxxxx"
}
resource "scalr_workspace" "this" {
name = "Meoforce One"
environment_id = data.scalr_environment.dev.id
working_directory = "opentofu/"
}
As can now be seen instead of having that static reference to env-xxxxxxxxxxxxxxx we are using data.scalr_environment.dev.id , the data source lets us access any attributes defined on the data source, or the entire data source itself. This ability to reference between resources and data sources is part of the extreme power of OpenTofu, it enables extremely easy passing around of important information.
Provider Blocks
If we were to think about the hierarchy of blocks in OpenTofu then the Provider Block sits at the top, the reason being is that in all instances a provider block is required in order to use a resource or data block. However, that provider block can be implicit or explicit. Some providers either have no configuration requirements or have sensible defaults for everything and thus do not require declaration of the block itself however it does always exist in some form.
If we continue down our example pathway we would need to have provider block for the scalr provider, it would look like:
The provider and by extension the provider block is what creates a client to the external services API that we want to interact with, in the above example we are providing the scalr provider the credentials required for the provider to authenticate with Scalr so that we are able to perform operations on that platform.
Variables & Outputs
Variables in OpenTofu come in two flavours; Input Variables and Local Variables. The former is what is used to define the interface of your OpenTofu code, think of it as the API to anyone who will consume that OpenTofu code. The latter are variables defined within the codebase, you could think of these as constants, as they can only be set once, however the value of a Local Variable can also be mutating meaning that the value within can change over time based on the values within.
Input Variables
When defining an Input Variable it is best practice to put those within a variables.tf file, the variable block itself has a number of properties available to us:
default - sets a default value for the variable, this makes the variable an optional variable
type - the data type of the variable, the following are valid types:
string — basic string value, such as "Hello, world!"
number - basic number value, such as 2024
bool — basic boolean value, such as true
list(<TYPE>) - a non-unique collection of typed mutable elements, defined as list(string) which would be ["Hello,", "World!"]
set(<TYPE>)- a unique collection of immutable typed elements, defined as set(bool) which would have a value of [true, false]
map(<TYPE>) — a collection of typed key-value pairs, each key in a map is unique and associated with a single value, defined as map(string) which would have a value of { cat = "meow", dog = "woof" }
object({<PROPERTY_NAME> = <TYPE>, ...}) — similar to a map however the keys are all predefined and can have different types from each other, defined as object({ animal = string, sound = string }) which would have a value of { animal = "cat", sound = "meow" }
tuple([<TYPE>, ...]) — a collection of ordered, immutable elements. It could be thought of as a list with multiple types, where the order matters.
any — this type should never be used as it means that any type can be passed into the variable. As these variables are the interface to our code it should be as specific and explicit as possible.
description - the description is extremely important this is how we inform our consumers about what is required when it comes to to the variable, these can be documented in two ways; simple strings or heredoc strings the latter allows us to have more complete documentation.
validation - this allows us to ensure that the data being passed into the code is of the correct format, it also allows returning an error when the data does not match the validation
sensitive — marks the variable as sensitive ensuring that it's value won't be shown in any logs
nullable - demarcates that the variable is able to be set to null
Lets dive into some examples of declaring different types of variables, the examples we are going to look at are; environments, firewall rules.
The first example is for ensuring that we have an environment passed in with the right format:
variable "environment" {
type = string
description = <<-DESC
(Optional) The three letter description for the environment.
Default:
`dev`
DESC
default = "dev"
validation {
condition = contains(["dev", "uat", "prd"], var.environment)
error_message = "Err: the environment value must be valid."
}
}
In this next example we are requiring that a set of firewall rules be passed in, the use of a set here ensures that there are no duplicate rules as any duplicates detected will be ignored. As can be seen the description is very descriptive as to the requirements of the interface, it could even be more descriptive if we wanted but at a bear minimum it should have the below details. The default is there to demonstrate what the value would look like. You can also see that we have two validation blocks, this ensures that the priority is correct as well as making sure we are only allowing certain values for the action property. It is also worth noting that the port property for our source property is optional and it will default to 0 if not provided. This ability to have optional on specific properties within an object is extremely powerful.
variable "firewall_rules" {
type = set(object({
name = string
priority = number
source = object({
address = string
port = optional(number, 0)
})
destination = object({
address = string
port = number
})
action = string
}))
description = <<-DESC
(Required) a set of firewall rules with the following properties.
Properties:
- `name` (Required): unique name for the rule
- `priority` (Required): an integer for the priority, must be above 1,000
- `source` (Required): the source object
- `address` (Required): an IPv4 address space
- `port` (Optional): an integer port
- `destination` (Required): the destination object
- `address` (Required): an IPv4 address space
- `port` (Required): an integer port
- `action` (Required): a valid action; `block`, `allow`
DESC
default = [
{
name = "example-rule"
priority = 1001
source = {
address = "10.0.0.0/16"
}
destination = {
address = "192.168.0.0/24"
port = 443
}
action = "allow"
}
]
validation {
condition = alltrue([
for rule in var.firewall_rules :
contains(["allow", "block"], rule.action) ? true : false
])
error_message = "Err: invalid action provided, it must be: 'allow',
'block'."
}
validation {
condition = alltrue([
for rule in var.firewall_rules :
rule.priority > 1000 ? true : false
])
error_message = "Err: invalid priorty provided, the value must be over
1,000."
}
}
Local Variables
Local variables are a staple in OpenTofu for constructing, mutating and storing static data within the code. You should think of these as constants in a programming language, once they're set they cannot change. (With the exception of mutating local variables)
Let's dive into some examples.
locals {
default_port = 443
}
In the above example we are defining a local variable called default_port this allows us to define what we would expect to be the default port for services that are constructed by the remaining OpenTofu. The value is never going to change, that's why making it a local variable makes sense. In the next example we will look at a mutating variable that constructs a name from a number of variables.
This shows a local variable that mutates with the data being passed into it as well as calling a builtin function to ensure that the project acronym is all in lower case. Mutating local variables like this canbe a massive time saver when it comes to massaging data into the right format for multiple scenarios within your code.
You can use the string, number, bool, list and map types out of the box, it is possible to cast a list to a set using the toset() function.
Outputs
Outputs allow engineers to return data/information back to the caller of the OpenTofu code, this is especially useful when your code produces some assets that are a dependency for other resources. The output block contains the following properties:
description — a useful description of what the output is returning, given that this output forms part of the API you provide it should be descriptive as to what the output is
sensitive— this marks the value as sensitive which then ensures it is not returned in any outputs
value — the value of the output, this could be any from a static string to the result of a resource being created
depends_on — a list of resource references that are required to be in existence prior to the output returning a value
precondition — similar to variable validation this ensures that the data that is to be returned is as expected or an error is returned, you can have zero to many precondition blocks
In the following example we will look at an output that is returning the value from a locals block declaring animal sounds:
locals {
sounds = {
cat = "meow"
}
}
output "cat_sound" {
description = "The sound a cat makes"
value = local.sounds.cat
precondition {
condition = local.sounds.cat == "meow"
error_message = "Err: the defined cat sound is incorrect."
}
}
Modules
Modules sit at the core of OpenTofus power, they allow us to reduce both complexity and repetition. You could think of them as similar to a class in another language. They allow engineers to encapsulate technical and business logic within itself only providing a slim and easy to use interface for the modules consumer. There are three types of modules:
The Root Module — this term is use to refer to any directory that contains .tf files, the root module is what we call when we want to provision resources
Child Module(s) — a child module refers to modules that are defined locally within a root module, these modules are normally declared within a modules directory and can only be consumed by that root module
Published Modules — these are modules that have been published in some mechanism, such as; git, a registry or a filesystem. These modules are designed to be consumed by others.
You could think of a child module as a private class, and a published module as a public class.
When it comes to modules there are two concepts, developing the module and calling the module. Since developing the module is like writing any other OpenTofu code we won't go into that we will however look at how to call a module.
The module block itself has some properties out of the box:
source — the source of the module, this would be a registry address, a file path, a git address amongst other things. This is how OpenTofu knows where to
version — on sources that support versioning (e.g. registry) you can specify the explicit version or a version constraint to ensure you're getting the right version of the module
for_each — an optional property that allows us to iterate of a map to produce multiple instances of a module, in the same way as a resource
count — an optional property that allows us to define the number of instances to create(for_each is nearly always a better alternative)
depends_on — a list of explicit resource references required to exist before the module will be created
providers — a map of providers and aliases to pass to the module
Lets look at an example module that creates servers somewhere this will use a pretend registry:
module "servers" {
for_each = var.server_config
source = "https://registry.meowforce.one"
version = "1.0.0"
name = each.key
type = each.value.type
size = each.value.size
}
The above will create n-number of servers as defined by the server_config variable, from this we also grab the respective properties to satisfy the API of the module itself. It is also explicitly defined that 1.0.0 is the version to be used we do have other options such as the recommended approach of the use of pessimistic version constraints:
module "servers" {
...
version = "~> 1.0"
...
}
The above will allow for module versions for the range of version >= 1.0.0 and version < 2.0.0, this ensures that you're getting updates to the module but not receiving breaking changes. The breaking changes would require manual intervention by an engineer, which is the desired state.
Imports
OpenTofu allows engineers to import existing resources into its state meaning it can be managed by OTF going forward. This can be done in two ways, by using the Import CLI command or the import block. In this section we will look at the import block.
In order for resources to be imported into state and continue to be managed corresponding OpenTofu resources must also exist. These can be auto-generated using existing open source tools or the code can be manually written
In this example we have a single Azure Resource Group sitting in a subscription that requires to be managed by OpenTofu going forwards, we would construct a main.tf file that looks like:
import {
to = azurerm_resource_group.this
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rgmeow"
}
resource "azurerm_resource_group" "this" {
name = "rg-meow"
location = "AustraliaSoutheast"
}
The import block itself contains two properties to be set:
to — the OpenTofu resource reference constructed by the resource type and its name
id — the resource ID, this can usually be identified by looking at the resource in situ on the service it comes from, the registry or state of a resource managed by OpenTofu already
Once an apply has been completed the import block can be removed from the code. It is also recommended to keep that block as close to the resource it is importing as possible to ensure that it is clear what is happening for other engineers.
Functions
OpenTofu has rather a large number of builtin functions, this is part of the power of the language. Rather than going through each one by one instead we will describe the categories and then select some of the post useful functions for examples.
Numeric Functions — this is where any function pertaining to numbers lives, if you're looking for min, max or parseint this is where you should look.
String Functions — these are some of the most useful as they allow engineers to manipulate and validate strings.
Collection Functions — these functions allow for augmentation, validation and combination of the collection types (list, set, map).
Encoding Functions — these functions allow an engineer to encode data from one type into another, or decode. As an example you may read in a file and decode that from yaml or json.
Filesystem Functions — working with files, or the filesystem in general this is where you will want to go.
Date & Time Functions — here lives date formatting, and computation.
Hash & Crypto Functions — these provide a good set of useful hashing functions as well as ID generation.
IP Network Functions — these functions allow for the slicing, dicing and calculation of IP networks.
Type Conversion Functions — want one type but have another this is the place to start for explicit type conversion.
Here are some of the most useful functions within OpenTofu:
format (String) — this allows engineers to easily and powerfully format strings in all number of ways, I prefer this over string interpolation
split (String) — if you have structured string data (perhaps like the name above) and you need to get a singular component the split function allows you to turn the string into list based on a separator
locals {
name = "proj-dev-otf" environment = split("-", local.name)[1]
# Result: dev
}
alltrue (Collection) — this function is really useful when you're trying to check multiple things on variable validation
variable "security_rules" {
description = <<-DESC
(Optional) Map of security rule objects for restricting access between things.
Properties:
- `name`: (Required) Name of the security rule
- `description: (Optional) Description of the security rule
- `priority`: (Required) The rule priority, lower the priorty number the higher the priority.
- `source_ports`: (Required) A list of source ports.
- `source_address`: (Required) The source address.
- `destination_ports`: (Required) List of destination ports.
- `destination_address`: (Required) Destination address.
[Default: {}]
DESC
type = map(object({
name = string
description = optional(string)
priority = number
source_ports = list(number)
source_address = string
destination_ports = list(number)
destination_address = string
}))
default = {
first = {
name = "first" priority = 1000
source_ports = [443]
source_address = "10.0.0.0" destination_ports = [443]
destination_address = "10.0.0.0" }
second = {
name = "second" priority = 1001
source_ports = [443]
source_address = "10.0.0.0" destination_ports = [443]
destination_address = "10.0.0.0" }
third = {
name = "second" priority = 1001
source_ports = [443]
source_address = "10.0.0.0" destination_ports = [443]
destination_address = "10.0.0.0" }
}
validation {
condition = alltrue([
for i in flatten([
for priority in distinct([
for rule, values in var.security_rules :
values.priority
]) : {
key = priority
value = length([
for valueRule, valueValue in var.security_rules :
valueValue.priority
if valueValue.priority == priority
])
}
]) : i.value == 1 ? true : false ])
error_message = "Err: there is a duplicate priority." }
}
contains (Collection) — another really powerful function often used for validation incoming data, an example of this might be for the environment variable
variable "environment" {
type = string
description = "The environment for resources to be provisioned in."
validation {
condition = contains(["prd", "uat", "dev"], var.environment)
error_message = "Err: invalid environment provided" }
}
yamldecode (Encoding) — this function is used to decode a yaml file, or raw representation into an openTofu object allowing for easy interaction. The example for this will be coupled with the next function as they go hand-in-hand.
file (Filesystem) — use this to retrieve the contents of a given file into an OpenTofu object, this is commonly used with the Encoding functions as you will see in the example.
try (Type Conversion) — this powerful function allows you to attempt to access a piece of data from something and return a default value if none exists. Using the above example we will try access a property from the config yaml file that doesn't exist.
output "config_environment" {
value = try(local.config.environment, "dev")
# Result: dev
}
Terraform Settings
NOTE: This particular key is a hold over from the migration away from Terraform, this may change into the future with the addition of a tofu block.
The primary function of the terraform configuration block is for enabling backends, providing provider constraints and enabling experimental features.
We will look into a complete example and dive through the different parts:
Note: While this blog references Terraform, everything mentioned in here also applies to OpenTofu. New to OpenTofu? It is a fork of Terraform 1.5.7 as a result of the license change from MPL to BUSL by HashiCorp. OpenTofu is an open-source alternative to Terraform that is governed by the Linux Foundation. All features available in Terraform 1.5.7 or earlier are also available in OpenTofu. Find out the history of OpenTofu here.