OpenTofu
OpenTofu
March 5, 2024

OpenTofu Language Guide

By
Brendan Thompson

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.
<BLOCK_TYPE> "<BLOCK_LABEL>" "<BLOCK_IDENTIFIER>" {
   <ARGUMENT_NAME> = <ARGUMENT_VALUE>
   <BLOCK_TYPE> "<BLOCK_LABEL>" {
     # ...
}
# ...
}

Resource Blocks

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 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.

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 & 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.

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 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

locals {
  environment = "dev"
  project = "otf"

  name = format(
    "proj-%s-%s",
    local.environment
    local.project
  )
}

# Result: proj-dev-otf

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.

name: Secret Configuration
locals {
  config = yamldecode(file("./config.yaml"))
}

output "config_name" {
  value = local.config.name
  # Result: Secret Configuration
}

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:

terraform {
  required_version = "~> 1.0"

  backend "local" {
    path = "state.tfstate"
  }

  required_providers {
    scratch = {
      source = "BrendanThompson/scratch"
    }
  }
}
  • 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.

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.

Don't take our word for it, try it for yourself.

A screenshot of the modules page in the Scalr Platform