Here is some information about this architecture.
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 ].
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
}
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"
}
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"
}
}
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 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
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"]])
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 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 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 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 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 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"
}
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.
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.
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"
}
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.
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.
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.
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”}
.