Expressions

Sign Up to Build

About this Architecture

Here is some information about this architecture.

How to Build This Solution

Here are the steps you can follow to build this solution on your own.

Within Terraform language, expressions are used to reference or compute values. These values could be very basic, like a string or a number. They can also be complex, such as a reference to exported data, conditional evaluations, or other functions.

Expressions are a very useful and are a commonly used feature in Terraform. They are used in creating resources, in outputs, and all along the way.

There are many forms of expressions available in Terraform. Let us discuss the several expression types available in Terraform and examples of how they are used [1 ].

Types and Values

Terraform expressions output a value. Values have to be one of the supported types. You can set the types on the expression. Available types – string, number, bool, list, and map.

In this example we are defining two variables; one has a string type, the other is a number.

# A variable with "string" type
variable "subject" {
  type    = string
  default = "Status"
}

# A variable with "number" type
variable "count" {
  type    = number
  default = 8
}

Strings

Like in any other programming language, strings are available in Terraform. A string is created by putting quotes around text like “howdy”.

String literal values can accept features like escape sequences that start with the backslash. For example, if you want to make a new line in the string, do this: “first line n second line”. Full escape sequence docs are here.

variable "project" {
  type    = string
  default = "manhattan"
}

Templates

You can create string templates as well. There are two types; interpolation and directives.

To use interpolation you use the format ${..}. This will evaluate the expression and generate a string from it. Let’s say you want to modify part of the string based on a variable, or function output. You can use the format “${var.var_name}/path” to do so.

You can also use a directive with the %{...} format. This allows for conditional results and iteration.

Here’s an example using both interpolation and directives.

data "archive_file" "fleet_status_zip" {
  type        = "zip"

  // using interpolation
  output_path = "${path.module}/files/fleet_status.zip" 

  source {

    // using directive 
    content  = file("%{ if var.name == "prod" } ${var.name} %{ endif }/files/check_fleet_status.py")
    filename = "check_fleet_status.py"
  }
}

References to Named Values

As TF creates many named values, those names act as a reference to the respective value. For example, if you create an AWS instance using the “aws_instance” resource and name it as “my_instance”, This can be considered as a reference to named values like → aws_instace.my_instance. If you create a variable and name it as ami_id, you can refer to the variable using var.ami_id (See the example below).

Available types – Resources, Data sources, Input variables, Child module outputs, etc.

# Variable set as "ami_id" is used to set the ami value as var.ami_id
resource "aws_instance" "my-instance" {
    ami                    = var.ami_id
    instance_type          = "t2.small"
}

Operators

Operators in TF are just as same as operators in any other programming language. They are used to combine or transform two or more values and outputs a single value.

Available operators

  • Arithmetic operators - +, -, *, /, %

  • Equality operators - ==,!=

  • Comparison operators - <, >, <=, >=

  • Logical operators - ||, &&,!

If you need to concatenate two values, you can use the “+” operator. If you want to compare two different values, you can use the “=” operator. The below example shows a comparison. It checks the var.ebs_volume_size value and decides whether it is greater than 0 or not. If yes, it will set the ebs_enabled attribute to “true”, and if not, it will set it to “false”.

# if ebs volume_size is greater than 0, the ebs_enabled option is set to true
ebs_enabled = var.ebs_volume_size > 0 ? true : false

Function Calls

Terraform comes with a number of built-in functions. A function call is also known as an expression type. In this example, we are using the join() function. projectName and envName are joined together using the “-” separator. If you set the projectName = skillmix and envName = dev, the end result will be skillmix-dev.

join("-", [var.tags["projectName"], var.tags["envName"]])

Conditional Expressions

A conditional expression selects one of two values depending on the outcomes of a Boolean expression. This is done in the format of: condition ? true_value : false_value. This was discussed in the earlier “Operators” section as well. If the ebs_volume size is greater than 0, it will set the ebs_enabled attribute to “true”, and if not, it will set it to “false”.

# if ebs volume_size is greater than 0, the ebs_enabled option is set to true
ebs_enabled = var.ebs_volume_size > 0 ? true : false

For Expressions

For expressions acts as a loop and iterates until the given condition meets. In the given below scenario, if var.list were a list of strings, then the following expression would produce a tuple of strings with all-uppercase letters. For example, if var.list = [“Anne”, “Bob”] after the for loop is executed, it will become [“ANNE”, “BOB”].

# A for loop to set values in a list to uppercase
[for s in var.list : upper(s)]

Splat Expressions

Splat expressions work hand in hand with for expressions. It allows expressing a common operation in a more thorough way than iterating over a loop in a for expression. The special [ * ] sign iterates through all of the entries in the list to its left, accessing the attribute name on the right from each one.

# Uses * to iterate through eks_cluster return value to set the arn
output "cluster_arn" {
  description = "The Amazon Resource Name (ARN) of the cluster."
  value       = element(concat(aws_eks_cluster.this.*.arn, list("")), 0)
}

Dynamic Blocks

Dynamic blocks are supported in the resource, data, provider, and provisioner blocks. It is a way of setting nested blocks dynamically. Although this is similar to for expressions, dynamic blocks are used to create much more complex values.

# A dynamic block to set ingress rules in a security group
dynamic "ingress" {
      for_each = local.ingress_rules

      content {
         description = ingress.value.description
         from_port   = ingress.value.port
         to_port     = ingress.value.port
         protocol    = "tcp"
         cidr_blocks = ["10.0.0.0/0"]
      }
}

Type Constraints

Type constraints are represented using a combination of type keywords and type constructors, which are function-like constructions.

Available types – Primitive types (string, number, true), complex types (list(string), map ()), structural types (object (), tuple ())

If you refer the example provided below, the first variable (instance_list)type is set to list. Therefore, it can only hold lists. If you assign a string value, it will error out. The second variable type is set to any. That means that it can hold any type of variable. Whether it is a string, number or a list, the no_type_constraint variable will take the provided value type as its type.

# Since type is set to "list" this variable can hold only list type of values
variable "instance_list" {
    type = list
    default = []
}

# This variable can hold any type of value  
variable "no_type_constraint" {
    type = any
}

Version Constraints

Version constraints are used to set specific versions in a provider or a module. These are often used in Terraform projects. If you need to use a specific version of a provider or a module, you can set it using version constraints. As shown below in the example, the AWS provider version is set to 3.0 or higher.

provider "aws" {
    # Sets the version value to 3.0 or higher
    region = "us-east-1"
    version = "~> 3.0"
}

Lab Time!

Let’s get some hands-on experience working with expressions. Given that there are many different kinds of expressions, we’ll only use some of them in this lab. However, this should give you some experience and confidence in using expressions in general.

Get Your AWS Credentials

If you're using the Skillmix Labs feature, open the lab settings (the beaker icon) on the right side of the code editor. Then, click the Start Lab button to start hte lab environment.

Wait for the credentials to load. Then run this in the terminal.

Be sure to enter in your own access key and secret key and name your profile 'smx-lab'.

$ aws configure --profile smx-lab
AWS Access Key ID [None]: 
AWS Secret Access Key [None]: 
Default region name [None]: us-west-2
Default output format [None]: 

Note: If you're using your own AWS account you'll need to ensure that you've created and configured a named AWS CLI profile named smx-lab.

Create a Directory & Main File

Use your terminal or command prompt to create a working directory and main.tf file. We will do all of our work in one file for this lab.

$ mkdir expressions_lab

$ cd expressions_lab

$ touch main.tf

Open the main.tf file and add this code:

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

provider "aws" {
  profile = "skillmix-lab"
  region = "us-west-2"
}

Add Project Variables

Open the main.tf file and add these project variables. These variables will be used throughout our project.

// previous code

# create the variables
variable "project" {
  type = string
  default = "apollo"
}

variable "env" {
  type = string
  default = "dev"
}

variable "high_avail" {
  type = bool
  default = true
}

variable "iam_user_names" {
  description = "List containing IAM users names"
  type        = list(string)
  default     = ["Anne", "Bob", "Carl"]
}

Code Review

  • The project variable will be used to add the project name to resource tags.

  • The env variable will also be used in resource tags.

  • The high_avail variable will be used to indicate we want a high availability config e.g. more than one EC2 instance. Notice that this is a bool type.

  • The iam_user_names sets a list of user names for IAM resources. Notice that this is a list type.

Add Data Sources

We need to fetch a few values from the AWS account. We need an AMI ID, the VPC ID, for the EC2 instance resource. Append this code to the main.tf file.

// previous code

# get the AMI ID
data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
  owners = ["099720109477"] # Canonical
}

# get the VPC subnet and security group
data "aws_vpc" "lab_vpc" {
  filter {
    name = "tag:Name"
    values = ["Skillmix Lab"]
  }
}

# 
data "aws_subnet" "lab_subnet" {
  filter {
    name = "tag:Name"
    values = ["Skillmix Lab Public Subnet (AZ1)"]
  }
}

Code Review

  • The aws_ami data source fetches the latest Ubuntu AMI of the jammy release.

  • The aws_vpc data source gets the VPC resource. This VPC resource was created on your lab account.

  • The aws_subnet resource retrieves one of the subnets in the VPC.

Create the Resources

We will create several resources. First, we’ll create an IAM User resource for every name in the iam_user_names variable. Next, we’ll create a security group and an EC2 instance.

Open the main.tf file and append this code.

// previous code

# create three IAM users
resource "aws_iam_user" "users" {
  count = length(var.iam_user_names)
  name  = var.iam_user_names[count.index]
}

# create a security group
resource "aws_security_group" "web_instance_sg" {
  name        = "web-server-security-group"
  description = "Allowing requests to the web servers"
  vpc_id = data.aws_vpc.lab_vpc.id

  tags = {
    Name = "web-server-security-group"
  }
}

# create the instances
resource "aws_instance" "lab_server" {
  count = var.high_avail ? 2 : 1
  ami = data.aws_ami.ubuntu.id
  instance_type = "t2.micro"
  vpc_security_group_ids = [aws_security_group.web_instance_sg.id]
  subnet_id              = data.aws_subnet.lab_subnet.id

  tags = {
    Name = join("-", [var.project, var.env])
  }
}

Code Review

  • The aws_iam_user resource uses the count attribute to set how many resources to create. It uses the name attribute to set the resource name, based on what the count index is.

  • The aws_security_group creates a security group, and references the aws_vpc data source expression to get the VPC ID.

  • The aws_instance resources has a lot going! Here is a recap:

  • The count attribute sets how many instances are created based on the value of our high_avail variable. If the variable is set to true, then we launch two instances. If false, just one.

  • The ami attribute references the AMI ID from the ubuntu data source.

  • The vpc_security_group_ids references the security group resource created above.

  • The subnet_id references the subnet data source.

  • The Name tag uses the join function to combine the variables we created.

Create the Outputs

Lastly, we will create the outputs. The outputs will use different expressions to determine what is included in the output.

// previous code

# one output for bob's IAM user
output "bob_arn" {
  value       = aws_iam_user.users[1].arn
  description = "The ARN for user Bob"
}

# all user outputs using splat (*)
output "all_arns" {
  value       = aws_iam_user.users[*].arn
  description = "The ARNs for all users"
}

output "instance_ip_addresses" {
  value = {
    for instance in aws_instance.lab_server:
      instance.id => instance.private_ip
  }
}

Code Review

  • The bob_arn output uses a specific index number to fetch the ARN.

  • The all_arns output uses the Splat expression ( *) to output all ARNs for IAM Users.

  • The instance_ip_addresses uses the for expression to iterate over all of the instances created, and output a key/value pair of {“instance.id” : “private_ip”}.