DEV Community

Let's Do Tech
Let's Do Tech

Posted on • Updated on • Originally published at letsdotech.dev

Terraform Syntax

This post is intended to give a brief overview of the configuration syntax of Terraform. We would go through an example and touch up on some of the important aspects of Terraform configuration language, to successfully create an IaC and see that in action. This by no means is an attempt to rewrite all the technical details available in Terraform docs, however, if you want to get up and running with Terraform, this is the right place to get the direction.

Note: As a prerequisite, it is assumed that Terraform is installed on your local system, you have access to the AWS management console, and have set up and configured an IAM user for Terraform.

Arguments and Blocks

Referring to an example in the previous post, we created an EC2 instance with below code.

provider “aws” {
  region = “us-west-1”
}

resource “aws_instance” “myec2” {
  ami = “ami-12345qwert”
  instance_type = “t2.micro”
}
Enter fullscreen mode Exit fullscreen mode

The code consists of 2 blocks wrapped in curly braces ({}), and each of these blocks has certain arguments defined. Just like most programming languages, arguments are used to assign values to variables. In Terraform configuration language, these variables are attributes associated with a particular type of block. provider "aws" block has one argument - " region = "us-west-1"", where the region is an attribute associated with the block, and it is assigned a value "us-west-1". The value is of the type string, thus it is enclosed in a pair of double quotes ( "" ). Similarly, the resource block has 2 arguments that set the values of associated attributes.

Terraform configuration language makes use of various types of blocks. Based on the type, blocks represent and enclose a set of attributes and functions. In the given example, we have a block of type provider and another of type resource.

Terraform makes use of certain types of blocks ( provider and resource, in the example), and each block has its identifier and a set of input labels. The provider block takes one input label - that is the name of the provider. In this case " aws". It also informs Terraform to install aws provider plugin, during init phase. Resource block takes 2 inputs labels - the type of resource and the name of the resource. In this case - the type is " aws_instance " and the name is "myec2". What follows is the block body enclosed in curly braces.

Where to start?

So, how do we start expressing our infrastructure as code and make use of it? Let us take an example of creating a simple EC2 instance on AWS. Let us start by creating a directory of your choice where you would place all the configuration code required to create an EC2 instance. By default, Terraform assumes that all the files with .tf* extensions in a directory are part of the configuration, irrespective of the file names. Create a file by name main.tf in this directory.

**Note: **The code used in this example can be referred from this commit on Github.

The very first thing which we need to declare is — which providers are we going to use? Since we are going to spin an EC2 instance on AWS, we declare the same as below.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = “us-west-1”
}
Enter fullscreen mode Exit fullscreen mode

We have declared 2 blocks — terraform and provider. terraform is a top-most block, but it is optional as well. It is a good practice to specify this, especially when we work with remote state management. We will talk about remote state management in upcoming posts.

terraform block has a nested block that specifies required_providers. We require aws provider.aws within required_providers is a map, which specifies the source and version of the provider. Next, we have a provider block for aws, which specifies the region.

Generally, this is how every Terraform code would start. Of course, there would be some variations, and the best way to be sure about it is to refer to the Terraform registry for specific versions of Terraform as well as the provider plugin itself. For the sake of the current example, we are referring to AWS plugin documentation. The Terraform registry documents usage of all the resources of various cloud providers with example and it is a great resource for Terraform reference.

Providers

Installing Terraform on the system is not enough. To make configurations work, Terraform makes use of provider plugins. These plugins are installed in the initialization phase. Provider plugins come with their own set of configurations, resource types, and data sources. Terraform registry documents all the details for a given provider.

Resources

Every provider comes with a set of resources. resource, as the name suggests, represents the actual cloud resource to be created in the configuration language. Providers enable resources. In the given example, aws is a provider and aws_instance is a resource provided by the AWS provider. The resource has its attributes. These attributes are documented on the Terraform registry. Out of all the attributes, some of the attributes are required for the Terraform to be able to process the configuration. Resources are the exact constructs that are executed by Terraform.

Continuing with the example, let us define an AWS EC2 instance resource by appending the below code into our main.tf file.

resource "aws_instance" "demo" {
 ami = “ami-00831fc7c1e3ddc60”
 instance_type = “t2.micro”

 tags = {
   name = "Demo System"
 }
}
Enter fullscreen mode Exit fullscreen mode

We start with a resource block named “ aws_instance" and we pass a second label and name it as " demo". The second label is the name of your choice. Next, open the block using curly braces and specify the required attributes used by the resource aws_instance. The first attribute is ami which specifies the Amazon machine image ID for the EC2 instance. The second attribute is the instance_type which specifies the size of the machine to be created. We are also passing tags which is an optional argument. As a tag, we pass "name" in the key and "Demo System" in the value. That's it we have defined our resource.

We are now technically ready with the configuration and we can go ahead and initialize the Terraform into this directory so that it installs the provider plugin for AWS and we can then plan and apply this configuration. Save the file, go ahead and run terraform init and see if it installs AWS provider plug-in. Once that is done successfully run terraform plan and observe the output.

Let us put everything into perspective — providers let the Terraform know which plugins need to be installed to execute the configuration. resources represent the actual cloud resources to be created. Generally, every resource has a name (" aws_instance"). The initial part of the name of the resource is the provider identifier (" aws ") which is separated by an underscore.

Variables

By now, we know that Terraform is a declarative language. In the example, we have declared the final state of a desired virtual machine on the desired cloud. Now it is up to Terraform to take this configuration and execute it to create the virtual resource. Having said that Terraform gives us the ability to specify input variables to its configuration.

Input variables are like parameters for a given function just like in any programming language.

It is particularly useful when you have to specify the same value at multiple places in your code. As the project grows in size, it becomes easier to change certain values that might be used in multiple places, using variables.

Terraform supports primitive types of variables such as string, number, boolean, and several complex types such as list, set, map, object, and tuple.

Let us define some variables into our code as below:

variable "region" {
 default = "us-west-1"
 description = "AWS Region"
}

variable "ami" {
 default = "ami-00831fc7c1e3ddc60"
 description = "Amazon Machine Image ID for Ubuntu Server 20.04"
}

variable "type" {
 default = "t2.micro"
 description = "Size of VM"
}
Enter fullscreen mode Exit fullscreen mode

As you can see we have introduced three new variables for the region, the ami, and the type. Let us use this in our configuration so far. The values of the variables can be referred to using var.<variable name>.

Terraform configuration also gives us the ability to return values. These values are known as output values. When Terraform completes the execution of the configuration, the output values are made available which can be used as input to other interfaces. We have defined one output variable “ instance_id" into our code. The value of this output variable is set using attribute reference of " aws_instance.demo ". Similarly, we can refer to other output variables available from any resource in the configuration.

Below is the updated code of our main.tf. We have made use of three variables at appropriate places.

terraform {
 required_providers {
   aws = {
     source  = "hashicorp/aws"
     version = "~> 3.0"
   }
 }
}

provider "aws" {
 region = var.region
}

variable "region" {
 default = "us-west-1"
 description = "AWS Region"
}

variable "ami" {
 default = "ami-00831fc7c1e3ddc60"
 description = "Amazon Machine Image ID for Ubuntu Server 20.04"
}

variable "type" {
 default = "t2.micro"
 description = "Size of VM"
}

resource "aws_instance" "demo" {
 ami = var.ami
 instance_type = var.type

 tags = {
   name = "Demo System"
 }
}

output "instance_id" {
 instance = aws_instance.demo.id
}
Enter fullscreen mode Exit fullscreen mode

Save the file and run terraform plan. Notice that Terraform has taken note of the output variable this time. It states that the output is known after apply, which is kind of obvious.

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + instance_id = (known after apply)
Enter fullscreen mode Exit fullscreen mode

Go ahead and do terraform apply, and let me know the output. Don't forget to run terraform destroy after every successful apply.

Lastly, Terraform also supports local variables, which are temporary values used locally by functions and blocks.

Provisioners

So before we conclude this part, let’s take a look at provisioners for a while. Provisioning means to install, update and maintain required software once the hardware or virtual machine is successfully made ready. Terraform can trigger software provisioning processes once a virtual machine is ready, but that does not mean it is a full-time provisioning tool. This ability of Terraform can be used to make the infrastructure ready for management by installing smaller but essential software components.

There exist tools like Salt Stack, Ansible, Chef, etc., and most of these tools are agent-based. Terraform ability to run initial scripts to install some patch updates, agent software, or even set some user access policies to make sure machines are ready to be used.

Terraform comes bundled with generic provisioners as well as it supports vendor-specific provisioners. This is a topic for a future post.

Before we proceed, let us first organize our code into multiple files. As a general practice, the Terraform codebase is divided into multiple files based on the providers, resources, and variables. Let us create 3 files as below:

  1. variables.tf – This file would contain all the declared input variables. In our example, we have input variables defined for region, ami, and type and output variable instance_id.

  2. provider.tf – This file would contain declarations for providers being used. In our case, we have terraform, and the provider aws blocks.

  3. main.tf – This file would contain the declarations for actual resources to be created.

Refer to this commit on Github repository.

By default, Terraform assumes all the code placed in a particular directory as part of the same configuration. So technically it doesn’t make much of a difference if you put the code in a single file or divide it into multiple files and sub-directories. From the maintainability point of view, it makes a lot of sense to do so.

Meta-Arguments

Note: While working through the examples, please make sure to run “terraform destroy” after every terraform apply run.

Meta-arguments are special constructs provided for resources. We have seen that resource blocks are the actual cloud resources that are created by Terraform. Often, it becomes tricky to declare resources in a way that satisfies certain requirements. Meta-arguments come in handy in situations like creating resources in the same cloud provider but in different regions, or when we are creating multiple identical resources with different names, or when we have to declare implicit dependencies at places where Terraform is not able to identify the dependency itself.

There aren’t many but a few meta-arguments available currently. They are as follows:

PROVIDER:

The provider meta-argument is used when we have multiple provider configurations in a given Terraform config. Terraform automatically maps the given resource to the default provider identified by the resource’s identifier. For example, the default provider for “aws_instance” is “aws”. This aws provider is currently configured to deploy a resource in a particular region. However, if we would want to have another aws provider for another region, or with a different configuration setting, we can write another provider block.

Even though it is possible to write multiple provider configs, Terraform by default would pick the same provider for aws for creating resources. This is where aliases come into the picture. Every provider configuration can be tagged with an alias and the value of this alias is used in our **provider **meta-argument in the resource block to specify different provider configurations for identical resources.

In the given example, let us duplicate the aws provider and give them appropriate aliases. Modified providers with an alias should look like below in provider.tf file.

provider “aws” {
 alias = “aws_west”
 region = var.region_west
}

provider “aws” {
 alias = “aws_east”
 region = var.region_east
}
Enter fullscreen mode Exit fullscreen mode

Notice that, we have also modified variables for the region to represent 2 different regions — west and east. Do the corresponding changes to variables.tf file as below:

variable "region_west" {
  default     = "us-west-1"
  description = "AWS West Region"
}

variable "region_east" {
  default     = "us-east-1"
  description = "AWS East Region"
}
Enter fullscreen mode Exit fullscreen mode

One final change that we need to do is in the main.tf file. Where we can now use provider meta-argument to specify a specific provider alias. We can mention the desired provider config by specifying <provider>.<alias> in the meta-argument. Refer to the modified main.tf file below:

One final change that we need to do is in the main.tf file. Where we can now use provider meta-argument to specify a specific provider alias. We can mention the desired provider config by specifying <provider>.<alias> in the meta-argument. Refer to the modified main.tf file below:

resource "aws_instance" "demo" {
  provider      = aws.aws_west
  ami           = var.ami
  instance_type = var.type

  tags = {
    name = "Demo System"
  }
}
Enter fullscreen mode Exit fullscreen mode

Validate the final configuration by running terraform validate, and it should say “Success!

LIFECYCLE

The lifecycle meta-argument specifies the settings related to the lifecycle of resources managed by Terraform. By default, whenever a configuration is changed and applied, Terraform operates in the sequence below.

  1. Create new resources.

  2. Destroy those resources which do not exist in config anymore.

  3. Update those resources which can be updated without destruction.

  4. Destroy and re-create change resources that cannot be changed on the fly.

A lifecycle meta-argument can be used if we would like to alter this default behavior. These meta-arguments are used in resource blocks similar to provider meta-argument. There are 3 lifecycle meta-argument settings:

  1. create_before_destroy: Used when we want to avoid accidental loss of infrastructure when a changed config is applied. This setting when set to true, Terraform will first create the new resource before destroying the older resource.

  2. prevent_destroy: When set to true, any attempt to destroy this in the config would result in an error. This is often useful in the case of those resources where reproduction can prove to be expensive.

  3. ignore_changes: This is a list typed meta-argument which specifies the attributes of a specific resource in the form of a list. During the update operations, often there is a situation where we would like to prevent changes caused by external factors. In those cases, it becomes essential to declare the list of attributes that should not be changed without being reviewed.

lifecycle meta-arguments come in very handy when we are in the process of setting up complex infrastructure. By altering the default behavior of Terraform, we can put some protection in the form of lifecycle meta-arguments for confirmed and finalized resource blocks. In our example, we would not use any lifecycle meta-argument.

DEPENDS_ON

Generally, Terraform is aware of dependencies while performing the creation or modification of resources and takes care of the sequence by itself. However, in certain cases Terraform cannot deduce the implicit dependencies and just moves on creating the resources parallelly if it doesn’t see any dependency.

Let us take, for example, a Terraform configuration for 2 EC2 instances enclosed in a VPC. When this configuration is applied, Terraform automatically knows that the creation of VPC should be done before spinning the EC2 instances. This is general knowledge and Terraform knows it very well. In situations where dependencies are not so obvious, the depends_on meta-argument comes to the rescue. It is a list type of argument that takes in the list of resource identifiers declared in the configuration.

COUNT

Imagine a situation where you would like to create multiple similar resources. By default, Terraform creates one real resource for a single resource block. But in the case of multiple resources, Terraform provides a meta-argument named count. As the name suggests, the count can be assigned with a whole number, to represent multiple resources.

In our example, let us create 3 similar EC2 instances. Into your main.tf file, add an attribute count to the resource aws_instance.demo, and assign it with a value of 3. It should look something like the below.

resource "aws_instance" "demo" {
  count         = 3
  provider      = aws.aws_west
  ami           = var.ami
  instance_type = var.type

  tags = {
    name = "Demo System"
  }
}
Enter fullscreen mode Exit fullscreen mode

By doing this, we let Terraform know that we need to create 3 EC2 instances with the same configuration. Save the file and execute terraform validate. It throws an error saying “Missing resource instance key”. Remember in our variables.tf file we have mentioned an output variable to output the id of the created resource. Since we have asked Terraform to create 3 instances, it is not very clear – ID of which of the 3 instances should be printed?

To get around this problem, we would use a special expression called “splat” expression. The ideal case here would be to run a for loop over the instance set and print out the ID property. Splat expression is a better way to do the same task with lesser lines of code. All you need to do is – in the variables.tf file, replace the output value code to below:

output "instance_id" {
  value = aws_instance.demo[*].id
}
Enter fullscreen mode Exit fullscreen mode

Save this file and run terraform validate to see if everything is okay. Once successful, go ahead and run terraform plan and apply and check your AWS management console in us-west-1 region a.k.a aws_west. Let me know the IDs too.

Splat expression is one of its kind and we would take a better look at expressions in upcoming sections.

FOR_EACH

for_each, as the name suggests, is essentially a “for each” loop. for_each meta-argument is used to create multiple similar cloud resources. Yes, it does sound similar to count meta-argument but there is a difference.

Firstly, for_each and count cannot be used together.

Secondly, you can say this is an enhanced version of the count. Count meta-argument is a number type. Terraform simply creates those many resources. However, if you would like to create these resources with some customizations in the output, or if you already have an object of type map or list based on which you want to create resources, then for_each meta-argument is the way to go.

As mentioned earlier, for_each can be assigned a map and list type of values. A map is a collection of key-value pairs, whereas a list is a collection of values (in this case string values).

for_each comes with a special object “each”. This is the iterator in the loop which can be used to refer to the key or value, or only key in case of list. Let us take a look at our example. We would like to create EC2 instances for the given map. The map is assigned to for_each meta-argument and Terraform creates an EC2 instance for each key-value pair in the map. Lastly, we use the key and value information using each object to set the name attribute in the tag.

The resource block in main.tf now looks something like this.

resource "aws_instance" "demo" {
  for_each = {
    fruit = "apple"
    vehicle = "car"
    continent = "Europe"
  }
  provider      = aws.aws_west
  ami           = var.ami
  instance_type = var.type

  tags = {
    name = "${each.key}: ${each.value}"
  }
}
Enter fullscreen mode Exit fullscreen mode

Execute terraform validate and observe the output. It throws an error for the output variable – “This object does not have an attribute named id”. A quick note here – splat expressions work for the list type of variables. Since we have used map while setting our for_each meta-argument, we need to change the return value expression to for each, as below:

output "instance_id" {
  //value = aws_instance.demo[*].id
  value = [for b in aws_instance.demo : b.id]
}
Enter fullscreen mode Exit fullscreen mode

Execute terraform validate again, if successful, go ahead and apply the configuration. Check the AWS management console for the machines created and the names assigned to them.

Expressions

Expressions are ways to make the Terraform code dynamic. Expressions come in 2 forms, simple and complex. Till now in our examples, we have mostly dealt with simple expressions. A simple expression is any argument used as part of some block. Writing down an argument with a primitive value assigned to is a form of expression.

We have made use of a complex expression called splat ( * ) in our example while working with meta-arguments. However, there are even more complex expressions that can be used to make the Terraform code more dynamic, readable, and flexible. There are various types of expressions that you can take a look at in the Terraform documentation.

Functions

Terraform has built-in functions that can be used with expressions. These are utility functions that are useful in number and string manipulations. There are functions to work with file systems, date and time, network, type conversion, etc.

Functions along with expressions make it super easy to write a really dynamic IaC. You can refer to the list of functions here.

This brings us to the end of Terraform Syntax. Next, we would take a look at Terraform CLI.

Originally published at http://letsdotech.dev on January 4, 2021.

Discussion (0)