
This post is part of a series on What is OpenTofu?.
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:
First, before we talk about these different types of blocks we need to understand what a block is comprised of.
resource, data, checkdata 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 that data block is scratch_string which indicates both the provider and the provider resource.data "scratch_string" "unique_name" {}.{ }, everything that is between those braces makes up the body.<BLOCK_TYPE> "<BLOCK_LABEL>" "<BLOCK_IDENTIFIER>" {
<ARGUMENT_NAME> = <ARGUMENT_VALUE>
<BLOCK_TYPE> "<BLOCK_LABEL>" {
# ...
}
# ...
}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:
resource "scalr_workspace" "this" {
name = "Meowforce One"
environment_id = "env-xxxxxxxxxxxxxxx"
working_directory = "opentofu/"
}From the above definition of a block we can break up the resource into its components:
resourcescalr_workspacethisname, environment_id and working_directoryThese 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 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 ensures 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 = "Meowforce 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.
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.

Example of structure
If we continue down our example pathway we would need to have provider block for the scalr provider, it would look like:
provider "scalr" {
hostname = var.hostname
token = var.token
}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 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.
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 variabletype - the data type of the variable, the following are valid types:description - the description is extremely important this is how we inform our consumers about what is required when it comes 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 validationsensitive — marks the variable as sensitive ensuring that it's value won't be shown in any logsnullable - demarcates that the variable is able to be set to nullLet's 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 bare 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 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.
locals {
resource_prefix = format(
"%s-%s",
var.environment,
lower(var.project.acronym)
)
}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 can be 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 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 issensitive — this marks the value as sensitive which then ensures it is not returned in any outputsvalue — the value of the output, this could be any from a static string to the result of a resource being createddepends_on — a list of resource references that are required to be in existence prior to the output returning a valueprecondition — 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 blocksIn 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 sit at the core of OpenTofu's 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:
.tf files, the root module is what we call when we want to provision resourcesmodules directory and can only be consumed by that root moduleYou 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 find the module.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 modulefor_each — an optional property that allows us to iterate over a map to produce multiple instances of a module, in the same way as a resourcecount — 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 createdproviders — a map of providers and aliases to pass to the moduleLet's 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.
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 nameid — 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 alreadyOnce 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.
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 most useful functions for examples.
min, max or parseint this is where you should look.list, set, map).yaml or json.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
alltrue (Collection) — this function is really useful when you're trying to check multiple things on variable validation
contains (Collection) — another really powerful function often used for validating incoming data, an example of this might be for the environment variable
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.
NOTE: This particular key is a hold over from the migration away from Terraform, this may change in the future with the addition of a
tofublock.
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:
required_version — allows for a version constraint to be placed on OpenTofu itself, the example shows a pessimistic version constraint.backend — allows for configuration of an OpenTofu compatible backend where state will be stored.required_providers — lists out each provider that is required by the OpenTofu root module, each provider can also have a version constraint.Backends come in a wider variety of flavours, more detail can be found on them with the following post: How to use Terraform remote backends. For some more advanced reading on Terraform Backends How to set Terraform backend configuration dynamically provides a good follow on.
