3

Writing Efficient Terraform Code

While Terraform is a declarative language, there are times when you need to use functional constructs to write efficient code. In this chapter, we cover some of these constructs and provide some tips to write Terraform code efficiently. In particular, we introduce dynamic blocks and conditional expressions to make the code more flexible. Then, we show how to use Terraform’s built-in functions and information from resources created outside of Terraform. We conclude the chapter by discussing how to expose Terraform information using output values and provide some tricks for writing Terraform code efficiently. Thus, by the end of this chapter, you will be able to write more efficient Terraform code and do so more efficiently.

In this chapter, we cover the following main topics:

  • Terraform types and values
  • Using Terraform expressions
  • Terraform functions
  • Referencing existing data using data sources
  • Using output values
  • Tips to develop Terraform code efficiently

Technical requirements

There are no new technical requirements for this chapter. The code for this chapter can be found at https://github.com/PacktPublishing/Terraform-for-Google-Cloud-Essential-Guide/tree/main/chap03. As usual, we recommend that you run terraform destroy before moving to the next subsection to remove any resources you created, to avoid unnecessary costs.

Terraform types and values

Note

The code for this section is under chap03/types in the GitHub repo of this book.

Before we go into expressions, let’s have a closer look at Terraform types and values. We have used Terraform types in the variable declarations. Terraform has the following types:

  • String
  • Number
  • Bool(ean)
  • List
  • Map

The first three are self-explanatory. Lists are sequences of values denoted by square brackets ([ and ]) and separated by commas. Maps are groups of named values—that is, name = value. A map is surrounded by curly brackets ({ and }) and separated by commas. Lists and maps can also be nested. The following are some common types as defined in a variable definition file:

chap03/type/terraform.tfvars

string = "This is a string"
number = 14
bool   = true
list   = ["us-west1", "us-west2"]
map    = { us-west1 = "Oregon", us-west2 = "Los Angeles" }
nested_map = {
  americas = ["us-west1", "us-west2"]
  apac     = ["asia-south1", "asia-southeast1"]
}

Elements in a list are referenced by their index position, starting with 0. For example, var.list[1] refers to the second element in the list. Elements in a map are referenced by their name using the dot notation—for example, var.map.us-west1.

Objects are like maps but set constraints on the variable. In the following code segment, we define a variable object to contain two strings—name and location—and a list of strings called regions. If those three elements are not supplied, then Terraform reports an error. This is particularly useful in modules, which we will introduce in the next chapter:

chap03/type/variables.tf

variable "object" {
  type = object({
    name     = string
    location = string
    regions  = list(string)
  })
}

Terraform has a special null value, which represents absence. We use this value later in this chapter.

Using Terraform expressions

In Terraform, you use an expression to refer to a value within a configuration. Thus, a string such as "abc" or a list such as ["orange", "apple", "strawberry"] is considered a simple expression. However, some expressions are more complex and are helpful when writing more flexible Terraform code. Terraform provides two useful constructs to write more flexible code: dynamic blocks and conditional expressions.

Dynamic blocks

Note

The code for this section is under chap03/dynamic-block in the GitHub repo of this book.

In the previous chapter, we introduced the ability to create multiple instances using the count and for_each meta-arguments. Both constructs, in essence, created multiple resources using a single block. Blocks within blocks are termed nested blocks, and some nested blocks can be repeated. For example, within the google_compute_instance resource, you can attach multiple disks using the attached_disk block. Let’s say we want to create a server and attach multiple disks to that server. We want to use a variable so that we can keep the disk size and disk type, as well as the attached mode, flexible. Using a variable also allows us to easily change the number of disks.

First, you create multiple disks using a map variable:

chap03/dynamic-block/terraform.tfvars

project_id  = <PROJECT_ID>
server_name = "dynamic-block"
disks = {
  small-disk = { "type" : "pd-ssd", "size" : 10, "mode" : "READ_WRITE" },
  medium-disk = { "type" : "pd-balanced", "size" : 50, "mode" : "READ_WRITE" },
  large-disk = { "type" : "pd-standard", "size" : 100, "mode" : "READ_ONLY" },
}

Then, you use the for_each meta-argument to create the disks:

chap03/dynamic-block/disks.tf

resource "google_compute_disk" "this" {
  for_each = var.disks
  name     = each.key
  type     = each.value["type"]
  size     = each.value["size"]
  zone     = var.zone
}

Now, we could attach a disk to the server by repeating the attached_disk block, shown as follows:

chap03/dynamic-block/main.tf

resource "google_compute_instance" "this" {
  name         = var.server_name
  machine_type = var.machine_type
  zone         = var.zone
  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
    }
  }
  attached_disk {
    source = google_compute_disk.this[«small-disk»].name
    mode   = var.disks[«small-disk»][«mode»]
  }
  attached_disk {
    source = google_compute_disk.this["medium-disk"].name
    mode   = var.disks["medium-disk"]["mode"]
  }
  attached_disk {
    source = google_compute_disk.this["large-disk"].name
    mode   = var.disks["large-disk"]["mode"]
  }
  network_interface {
    network = "default"
    access_config {
      // Ephemeral public IP
    }
  }
}

However, that creates two problems. First, we would have to repeat the attached_disk block. Second, and more critically, we could not keep the number of disks flexible. If we wanted to attach two or four disks to a server, we would need to change our code depending on the number of attached disks.

To address this issue, Terraform provides a concept of a dynamic block (https://www.terraform.io/language/expressions/dynamic-blocks). A dynamic block enables you to repeat nested blocks within a top-level block. It is best to illustrate this by fixing the issues highlighted previously:

chap03/dynamic-block/main.tf

resource "google_compute_instance" "this" {
  name         = var.server_name
  machine_type = var.machine_type
  zone         = var.zone
  boot_disk {
    initialize_params {
      image = «debian-cloud/debian-11»
    }
  }
  dynamic "attached_disk" {
    for_each = var.disks
    content {
      source = google_compute_disk.this[attached_disk.key].name
      mode   = attached_disk.value["mode"]
    }
  }
  network_interface {
    network = «default»
    access_config {
      // Ephemeral public IP
    }
  }
}

You define a dynamic block with the keyword dynamic followed by a label. The label specifies the kind of nested block—in our case, attached_disk. You use the for_each construct to iterate over the values, and the content keyword defines the body of the nested block. Within the body, you use the label of the attached_disk dynamic block and the value attribute to refer to the value of the item. Thus, attached_disk.value refers to the var.disks variable, and attached_disk.value["name"] refers to small-disk, medium-disk, and large-disk, respectively.

Thus, using a dynamic block enables you to write multiple repeated blocks by defining the block only once.

Conditional expressions

Note

The code for this section is under the chap03/conditional-expression directory in the GitHub repository of this book.

Another concept that can be used to write efficient and flexible Terraform code is a conditional expression (https://www.terraform.io/language/expressions/conditionals). The syntax of conditional expressions is straightforward:

condition ? true_val : false_val

Such an expression is handy when combined with the count meta-argument to decide whether to create a resource or not. Let’s say you want the flexibility of assigning either a static or an ephemeral (temporary) IP address to our server. We declare a Boolean variable called static_ip. When set to true, we create a static IP address and assign it. When set to false, we do not create a static IP and assign an ephemeral IP address:

chap03/conditional-expression/main.tf

resource "google_compute_address" "static" {
  count = var.static_ip ? 1 : 0
  name  = "ipv4-address"
}
resource "google_compute_instance" "this" {
  name         = var.server_name
  machine_type = var.machine_type
  zone         = var.zone
  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
    }
  }
  network_interface {
    network = "default"
    access_config {
      nat_ip = var.static_ip ? google_compute_address.static[0].address : null
    }
  }
}

You can see that we use a conditional expression twice. First, we use it in conjunction with the count meta-argument. When the static_ip variable is set to false, the expression evaluates to 0, and no static IP address is created. If set to true, the expression evaluates to 1, and hence one static IP address is provisioned.

In the access_config block, we again use a conditional expression. If the variable is set to true, we use the attribute of the google_compute_address.static resource to assign the NAT_IP address. If set to false, then we assign a null value, and Terraform assigns an ephemeral IP address.

Test it out by switching the value of the static_ip variable, and then, using the web console, look at the external IP of the virtual machine (VM):

$ terraform apply -var static_ip=false
$ terraform apply -var static_ip=true

But let’s assume we either want to assign a static external IP address or no external IP address at all. This is where we can use the null value to our advantage, as illustrated in the following code example:

resource "google_compute_address" "static" {
  count = var.static_ip ? 1 : 0
  name  = "ipv4-address"
}
resource "google_compute_instance" "this" {
  name         = var.server_name
  machine_type = var.machine_type
  zone         = var.zone
  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
    }
  }
  network_interface {
    network = "default"
    dynamic "access_config" {
      for_each = google_compute_address.static
      content {
        nat_ip = google_compute_address.static[0].address
      }
    }
  }
}

Let’s examine the highlighted code. First, we use a dynamic block, as indicated by the dynamic keyword. Then, we use a for_each construct to loop over the value of the google_compute_address.static resource. If no resource was created, this evaluates to the null value, so Terraform does not generate any block. If var.static_ip is set to true, then google_compute_address.static has a value that we loop over and assign the static IP address, as in the previous example.

So now, if you run the following code, you see that the server does not have any external IP address at all:

$ terraform apply -var static_ip=false

However, if you set the static_ip variable to true, the server has a static external IP address:

$ terraform apply -var static_ip=true

You often find yourself in positions where you need to create or transform expressions, and for that, Terraform provides built-in functions.

Terraform functions

Terraform has a number of built-in functions that you can utilize to create or transform expressions. One of the best ways to learn them is to use Terraform interactively using the terraform console command. We introduced this command earlier to investigate the state interactively. Here is another way to use Terraform interactively and learn about functions:

$ terraform console
> max(2,3)
3
> lower("HELLO WORLD")
"hello world"
> index(["apple", "orange", "banana"], "orange")
1
> formatdate("HH:mm",timestamp())
"01:32"

The general syntax for Terraform functions is function_name(argument_1, argument_2, …, argument_n). Terraform has a variety of built-in functions, including string, data, numeric, and filesystem functions. Please refer to the documentation at https://www.terraform.io/language/functions for a complete reference.

Referencing existing data using data sources

Note

The code for this section is under chap03/data-source in the GitHub repo of this book.

We discussed earlier that you should not mix provisioning resources through Terraform and the Google Cloud console, as this can lead to configuration drift. However, there are cases when you want to use information from data that Google Cloud provides or cloud resources that are created outside of your configuration file or out of your control. Terraform provides data sources, which you can read more about here: https://www.terraform.io/language/data-sources.

If you look at the Google Terraform documentation, available here at https://registry.terraform.io/providers/hashicorp/google/latest/docs, you’ll see a list of Google Cloud services that you can provision using Terraform. Under each service, you generally find a resources and a data sources subsection. Resources let you provision and manage Google Cloud resources, whereas data sources let you retrieve information from existing Google Cloud resources.

For example, the google_compute_instance resource (https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_instance) creates a new VM instance, whereas the google_compute_instance data source (https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/compute_instance) lets you reference an existing VM instance that was created outside of your Terraform configuration.

Go to the web console to create a new compute instance. Use all the default values, including the name of instance-1.

Then, you can retrieve all the information of that VM instance using the google_compute_instance data source, as seen in the following code snippet. Once instantiated, you can reference any attribute of that instance as if you had created the instance within Terraform. For example, you can output the IP address of that instance as we did before:

chap03/datasource/data.tf

data "google_compute_instance" "this" {
  name = "instance-1"
}
output "ip-address" {
  value = format("IP address of existing server: %s", data.google_compute_instance.this.network_interface[0].access_config[0].nat_ip)
}

Some data sources give you access to information from your current Google project or organization. Let’s say you want to create several servers and distribute them evenly across the zones in a region.

Your first inclination is probably to use the region name and a letter as that is the naming convention of Google zones—for example, us-central1-a.

However, while most Google Cloud regions have three zones, there are some regions with four. Furthermore, the zones are not consistently named. For example, the three Singapore zones are named asia-southeast1-a, asia-southeast1-b, and asia-southeast1-c, whereas the zones in us-east1 are named us-east1-b, us-east1-c, and us-east1-d.

For this, you can use the google_compute_zones data source with no arguments. This data source retrieves all the zones in the current region as a list. Thus, once instantiated, you can then reference the zone using the name attribute, as shown in the following code snippet:

chap03/datasource/main.tf

data "google_compute_zones" "available" {
}
resource "google_compute_instance" "this" {
  count        = var.instance_number
  name         = var.server_name
  machine_type = var.machine_type
  zone         = data.google_compute_zones.available.names[count.index % length(data.google_compute_zones.available.names)]
  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
    }
  }
  network_interface {
    network = "default"
    access_config {
    }
  }
}
output "zones" {
  value = [for s in google_compute_instance.this[*] : "${s.name}: ${s.zone}"]
}

Give it a try by running terraform plan for different regions. Here’s an example:

$ terraform plan -var region=us-central1
$ terraform plan -var region=asia-southeast1

We introduce more data sources in the following chapters. Understanding the difference between data sources and resources is essential since they often share the same name. Resources create new resources, whereas data sources expose information about existing resources. However, using a data source does not import an existing resource into the Terraform state, nor can you manage resources using data sources. You can only extract information from existing resourcing using data sources.

Using output values

Note

The code for this section is under chap03/output in the GitHub repo of this book.

We used output values briefly but have not yet discussed them in detail. Output values expose information from Terraform. So far, we have used them to output IP addresses or other pertinent information on the command line. However, output values do more, as we see in the following chapters.

By convention, all output values are placed in a file called outputs.tf. An output block requires a label argument and an argument named value that outputs the value of an expression. The description argument is optional and is used to describe a short description of the output.

Now is also an excellent opportunity to introduce the splat expression (https://www.terraform.io/language/expressions/splat). A splat expression is a short form of a for expression and is best explained by the following example. The two highlighted code segments are equivalent. Splat expressions are useful in combination with other loop constructs—for example, [for s in google_compute_instance.this[*] : "${s.name}: ${s.zone}"]:

chap03/output/outputs.tf

output "zones-splat" {
  description = "List of zones using a splat expression"
  value       = google_compute_instance.this[*].zone
}
output "zones-for" {
  description = "List of zones using a for loop
  value       = [for server in google_compute_instance.this : server.zone]
}
output "zones-by-servers" {
  description = "Name of zone for each server"
  value       = [for s in google_compute_instance.this[*] : "${s.name}: ${s.zone}"]
}
output "URL_0" {
  description = "URL of first server"
  value       = format("http://%s", google_compute_instance.this[0].network_interface[0].access_config[0].nat_ip)
}

Once you run terraform apply and only want to show the outputs, you can run terraform output with various options. Without any options, terraform output produces the output in human-readable form:

$ terraform output
URL_0 = "http://34.171.168.175"
zones-by-servers = [
  "output-0: us-central1-a",
  "output-1: us-central1-b",
  "output-2: us-central1-c",
  "output-3: us-central1-f",
]
…

Whereas with the -json option, it will produce it in JSON format:

$ terraform output -json
{
  "URL_0": {
    "sensitive": false,
    "type": "string",
    "value": "http://34.171.168.175"
  },
  "zones-by-servers": {
    "sensitive": false,
    "type": [
      "tuple",
      [
        "string",
        "string",
        "string",
        "string"
      ]
    ],
    "value": [
      "output-0: us-central1-a",
      "output-1: us-central1-b",
      "output-2: us-central1-c",
      "output-3: us-central1-f"
    ]
  },
…

You can specify the name if you want to display only a single value. Using the -raw option does not display any quotes or a newline. This is useful when combining it with other commands, as in the following example:

$ curl `terraform output -raw URL_0`
<html><body><p>Hello World!</p></body></html>

Outputs are also used to expose information between different Terraform configurations, as seen in the following two chapters.

Tips to develop Terraform code efficiently

Note

The code for this section is under the chap03/error directory in the GitHub repository of this book.

Before we conclude this chapter, we want to introduce two commands that help you develop Terraform code more efficiently. The first command, terraform fmt, formats Terraform configuration files so that they follow a consistent format and indentation. Formatting makes the files more readable. However, it can also serve as an initial check, as Terraform reports some syntax errors.

The second command is terraform validate. This command performs a syntax check and checks for internal consistency. For example, consider the following file, which contains some incorrect Terraform syntax:

chap02/error/error.tf

data "google_compute_zones" "available" {
region = var.region}
data "google_compute_zones" "available" {
region = var.region
}

First, run terraform fmt, which reports an error stating } must be on a separate line. Fix the error and rerun it. No error is reported, and the file now has a proper indentation. Now, run terraform validate. Terraform will report an error as each resource name must be unique. Even though terraform plan includes a validation, this command is useful as it does not require you to initialize Terraform; hence, it is slightly faster. It is also commonly used as a separate continuous integration/continuous deployment (CI/CD) pipeline step.

When debugging Terraform, setting the log level to get more information can be useful. You can do this by setting the TF_LOG environment variable to one of the following log levels: TRACE, DEBUG, INFO, WARN, or ERROR. For more details, please visit https://www.terraform.io/internals/debugging.

Give it a try. In Linux, execute the following command:

$ export TF_LOG=DEBUG

To set the level back to the default, unset the environment variable by running the following command:

$ unset TF_LOG

Later in this book, we discuss some useful third-party tools that help you debug and improve the quality of your code by performing additional checks such as static code analysis.

Summary

This chapter introduced several new concepts that enable you to write more efficient Terraform code. Dynamic blocks are used to write repeatable nested blocks by defining a single block. While Terraform does not have an explicit if-then concept, you can use conditional expressions to effectively write if-then structures. Terraform provides several standard built-in functions to create and transform expressions. Data sources refer to resources that are defined outside your Terraform configurations and come in handy at times. Lastly, we discussed output values, which expose Terraform information. You will use these concepts later in the book to develop modules and implement more complex deployments using Terraform.

In the next chapter we will introduce Terraform modules. Modules allow you to reuse blocks of Terraform code, and hence serve a similar purpose as functions in traditional programming languages.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset