Everything available in this article applies to OpenTofu as well.
Table of contents:
1. What is Terraform?
Once upon a time, if you worked in the IT industry, there was a big chance you faced different challenges when provisioning or managing infrastructure resources. It often felt like spinning plates, trying to keep everything running smoothly and making sure that all the resources are properly configured.
Then, Terraform came to the rescue and saved us from this daunting task that took a lot of time.
So what is Terraform? Terraform started as an open-source infrastructure as code (IaC) tool, developed by Hashicorp, that makes it easy to create and take care of your infrastructure resources. Now, it changed it license to BSL. If you want to learn more about the license change, and how it compares to OpenTofu checkout this article.
It’s built in Golang (Go), which gives it a lot of power to create different infrastructure pieces in parallel, making it reliable by taking advantage of Go’s strong type-checking and error-handling capabilities.
Terraform uses HCL (Hashicorp Configuration Language) code to define its resources, but even JSON can be used for this, if you, of course, hate your life for whatever reason. Let’s get back to HCL. It is a human-readable, high-level programming language that is designed to be easy to use and understand:
resource "resource_type" "resource_name" {
param1 = "value1"
param2 = "value2"
param3 = "value3"
param4 = "value4"
}
resource "cat" "british" {
color = "orange"
name = "Garfield"
age = 5
food_preference = ["Tuna", "Chicken", "Beef"]
}
I don’t want to get into too much detail about what a resource is in this article, as I plan to build a series around this, but the above code, with a pseudo real-life example, is pretty much self-explanatory.
To make it as simple as possible for now to understand, when you are using HCL and declaring something, it will have a type (let’s suppose there is a resource type cat, for example) and a name on the first line. Inside the curly brackets, you are going to specify how you want to configure that type of “something”.
In our example, we will create a “cat”, that will be named inside of terraform as “british”. After that, we are configuring the cat’s real name, the one that everyone will know about, the color, the age, and what it likes to eat.
As you see, the language, at a first glance seems to be pretty close to English. There is more to it, of course, but you are going to see it in the next articles.
One of the main benefits of using Terraform is that it is platform-agnostic. This means that people that are coding in Terraform, don’t need to learn different programming languages to provision infrastructure resources in different cloud providers. However, this doesn’t mean that if you develop the code to provision a VM instance in AWS, you can use the same one for Azure or GCP.
Nevertheless, this can save a lot of time and effort, as engineers won’t need to constantly switch between a lot of tools (like going from Cloudformation for AWS to ARM templates for Azure).
A consistent experience is offered across all platforms.
Terraform is stateful. Is this a strength or is this a weakness? This topic is highly subjective and it depends on your use case.
One of the main benefits of statefulness in Terraform is that it allows it to make decisions about how to manage resources based on the current state of the infrastructure. This ensures that Terraform does not create unnecessary resources and helps to prevent errors and conflicts. This can save time and resources, make the provisioning process more efficient and also encourage collaboration between different teams.
Terraform keeps its state in a state file and uses it and your configuration, to determine the actions that need to be taken to reach the desired state of the resources.
Even though I’ve presented only strengths until now, being stateful has a relatively big weakness: the managing of the state file. This adds complexity to the system and also in the case that the state file gets corrupted or deleted, it can lead to conflicts and errors in the provisioning process.
We will tackle this subject in detail in the next articles.
You may now ask, how can I easily install it? Well, Hashicorp’s guide does a great job of helping you install it, just select your platform and you are good to go.
Ok, now you have a high-level idea about what Terraform is, how to install it, but it’s ok if you still have a lot of questions, as all the answers will come shortly.
Originally posted on Medium.
2. What is a Terraform provider?
In short, Terraform providers are plugins that allow Terraform to interact with specific infrastructure resources. They act as an interface between Terraform and the underlying infrastructure, translating the Terraform configuration into the appropriate API calls and allowing Terraform to manage resources across a wide variety of environments. Each provider has its own set of resources and data sources that can be managed and provisioned using Terraform.
One of the most common mistakes that people make when they are thinking about Terraform providers, is the fact that they assume that Terraform providers exist only for Cloud Vendors such as Amazon Web Services (AWS), Microsoft Azure, Google Cloud Platform (GCP), or Oracle Cloud Infrastructure (OCI). There are a lot of other providers that can be used that don’t belong to a Cloud Vendor as Template, Kubernetes, Helm, Spacelift, Artifactory, VSphere, and Aviatrix, to name a few.
Each provider has its own set of resources and data sources that can be managed and provisioned using Terraform. For example, the AWS provider has resources for managing EC2 instances, EBS volumes, and ELB load balancers.
Another great thing related to this feature is the fact that you can build your own Terraform provider. While it has an API, you can translate it to Terraform, so that’s just great. However, there are thousands of providers already available in the registry, so you don’t need to reinvent the wheel.
Before jumping in and showing you examples of how to use providers, let’s discuss about how to use the documentation.
After you are selecting your provider from the registry, you will be redirected to the provider’s page.
In the above view, click on documentation:
Usually, the first tab you are directed to will explain how to configure and use the provider and some simple examples of how to create some simple resources. Of course, this view is different from one provider to another, but usually, you will see these examples.
We will get back to how to use the documentation when we will talk about resources in the next article.
Even though AWS is the biggest player in the market when it comes to cloud vendors, in this article I will show an example provider with OCI and one with Azure.
You can choose your own, of course, by going to the registry and selecting whatever suits you.
OCI
You have multiple ways to connect to the OCI provider as stated in the documentation, but I will only discuss about the default one:
API Key Authentication (default)
Based on your tenancy and your user will have to specify the following details:
tenancy_ocid
user_ocid
private_key or private_key_path
private_key_password (optional, only required if your password is encrypted)
fingerprint
region
provider "oci" {
tenancy_ocid = "tenancy_ocid"
user_ocid = "user_ocid"
fingerprint = "fingerprint"
private_key_path = "private_key_path"
region = "region"
}
After you specify the correct values for all of the values mentioned above you can interact with your Oracle Cloud Infrastructure’s cloud account.
You will be able to define resources and datasources to create and configure different pieces of infrastructure.
AZURE
Similar to the OCI provider, the Azure provider, can be configured in multiple ways:
Authenticating to Azure using a Service Principal and a Client Certificate
Authenticating to Azure using a Service Principal and a Client Secret
I will discuss the Azure CLI option which is the easiest way to authenticate by leveraging the az login
command.
provider "azurerm" {
features {}
}
This is the only configuration you have to do in the Terraform code and after running az login
and following the prompt you are good to go.
Originally posted on Medium.
3. What is a Terraform Resource?
Resources in Terraform refer to the components of infrastructure that Terraform is able to manage, such as virtual machines, virtual networks, DNS entries, pods, and others. Each resource is defined by a type, such as “aws_instance” or “google_dns_record”, “kubernetes_pod”, “oci_core_vcn”, and has a set of configurable properties, such as the instance size, vcn cidr, etc. Remember the cat example from the first article in this series.
Terraform can be used to create, update, and delete resources, managing dependencies between them and ensuring they are created in the correct order. You can also create explicit dependencies between some of the resources if you would like to do that by using depends_on
Let’s go in-depth and try to understand how to create these resources and how can we leverage the documentation.
I will start with something simple, an aws_vpc
First things first, whenever you are creating a resource, you will need to go to the documentation. It is really important to understand what you can do for a particular resource and I believe that you should try to build a habit around this.
On the right-hand side, you have the On this page
space, with 4 elements that you should know like you know to count:
Example Usage → this will show you a couple of examples of how to use the resource
Argument Reference → in this section you are going to see all the parameters that can be configured for a resource. Some parameters are mandatory and others are optional (these parameters will be signaled with an
Optional
placed between brackets)Attributes Reference → here you will find out what a resource exposes and I will talk about this in more detail when I get to the Outputs article
Import → Until now, if you didn’t know anything about Terraform and you’ve just read my articles, you are possibly thinking that it’s possible to import different resources in the state and that is correct. In this section, you are going to find out how you can import that particular resource type
So let’s create a VPC. First, we need to define the provider as specified in the last article, the only caveat now, is that we are doing it for a different cloud provider. If you forgot how to do it just reference the previous article as it has two examples for Azure and OCI and you easily do it for AWS.
Nevertheless, I will show you an option for AWS too using the credentials file that is leveraged by aws cli also. The provider will automatically read the AWS_ACCESS_KEY and AWS_SECRET_ACCESS_KEY from the ~/.aws/credentials
, so make sure you have that configured. An example can be found here.
We are then going to add the vpc configuration. Everything should be saved in a .tf
file.
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "example" {
cidr_block = "10.0.0.0/16"
}
As we see in the documentation, all the parameters for the vpc are optional, as AWS will assign everything that you don’t specify for you.
Now it is time to run the code. For this, we are going to learn some terraform essential commands and I’m going just to touch upon the basics of these commands:
terraform init
→ Initializes a working directory with Terraform configuration files. This should be the first command executed after creating a new Terraform configuration or cloning an existing one from version control. It also downloads the specified provider and modules if you are using any and saves them in a generated .terraform directory.terraform plan
→ generates an execution plan, allowing you to preview the changes Terraform intends to make to your infrastructure.terraform apply
→ executes the actions proposed in a Terraform plan. If you don’t provide it a plan file, it will generate an execution plan when you are running the command, as ifterraform plan
ran. This prompts for your input so don’t be scared to run it.terraform destroy
→ is a simple way to delete all remote objects managed by a specific Terraform configuration. This prompts for your input so don’t be scared to run it.
We will get into more details when we are going to tackle all Terraform commands during this series.
Ok, now that you know the basics, let’s run the code.
Go to the directory where you’ve created your terraform file with the above configuration and run terraform init
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v4.50.0...
- Installed hashicorp/aws v4.50.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
After that, let’s run terraform plan
to see what is going to happen:
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_vpc.example will be created
+ resource "aws_vpc" "example" {
+ arn = (known after apply)
+ cidr_block = "10.0.0.0/16"
+ default_network_acl_id = (known after apply)
+ default_route_table_id = (known after apply)
+ default_security_group_id = (known after apply)
+ dhcp_options_id = (known after apply)
+ enable_classiclink = (known after apply)
+ enable_classiclink_dns_support = (known after apply)
+ enable_dns_hostnames = (known after apply)
+ enable_dns_support = true
+ enable_network_address_usage_metrics = (known after apply)
+ id = (known after apply)
+ instance_tenancy = "default"
+ ipv6_association_id = (known after apply)
+ ipv6_cidr_block = (known after apply)
+ ipv6_cidr_block_network_border_group = (known after apply)
+ main_route_table_id = (known after apply)
+ owner_id = (known after apply)
+ tags_all = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
───────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
In the plan, we see that we are going to create one vpc with the above configuration. You can observe that the majority of the parameters will be known after apply, but the cidr block is the one that we’ve specified.
Let’s apply the code and create the vpc with terraform apply:
Terraform will perform the following actions:
# aws_vpc.example will be created
+ resource "aws_vpc" "example" {
+ arn = (known after apply)
+ cidr_block = "10.0.0.0/16"
+ default_network_acl_id = (known after apply)
+ default_route_table_id = (known after apply)
+ default_security_group_id = (known after apply)
+ dhcp_options_id = (known after apply)
+ enable_classiclink = (known after apply)
+ enable_classiclink_dns_support = (known after apply)
+ enable_dns_hostnames = (known after apply)
+ enable_dns_support = true
+ enable_network_address_usage_metrics = (known after apply)
+ id = (known after apply)
+ instance_tenancy = "default"
+ ipv6_association_id = (known after apply)
+ ipv6_cidr_block = (known after apply)
+ ipv6_cidr_block_network_border_group = (known after apply)
+ main_route_table_id = (known after apply)
+ owner_id = (known after apply)
+ tags_all = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
Enter yes at the prompt and you will be good to go.
Enter a value: yes
aws_vpc.example: Creating...
aws_vpc.example: Creation complete after 3s [id=vpc-some_id]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Woohoo, we have created a vpc using Terraform. You can then go into the AWS console and see it in the region you’ve specified for your provider.
Very nice and easy, I would say, but before destroying the vpc, let’s see how we can create a resource that references this existing vpc.
An AWS internet gateway exists only inside of a vpc. So let’s go and check the documentation for the internet gateway.
From the documentation, we see in the example directly that we can reference a vpc id for creating the internet gateway:
resource "aws_internet_gateway" "gw" {
vpc_id = ""
}
But the 100-point question is, how can we reference the above-created vpc? Well, that is not very hard if you remember the following:
type.name.attribute
All resources have a type, a name, and some attributes they expose. The exposed attributes are part of the Attributes reference section in the provider. Some providers will explicitly mention they are exposing everything from Argument reference + Attribute reference.
Let’s take our vpc as an example, its type is aws_vpc, its name is exampleand it exposes a bunch of things (remember, the documentation is your best friend).
So, as the internet gateway requires a vpc_id and we want to reference our existing one, our code will look like this in the end:
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "example" {
cidr_block = "10.0.0.0/16"
}
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.example.id
}
We can then reapply the code with terraform apply
and terraform will simply compare what we have already created with what we have in our configuration and create only the internet gateway.
aws_vpc.example: Refreshing state... [id=vpc-some_id]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_internet_gateway.gw will be created
+ resource "aws_internet_gateway" "gw" {
+ arn = (known after apply)
+ id = (known after apply)
+ owner_id = (known after apply)
+ tags_all = (known after apply)
+ vpc_id = "vpc-some_id"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_internet_gateway.gw: Creating...
aws_internet_gateway.gw: Creation complete after 2s [id=igw-some_igw]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Pretty neat, right?
Once we are done with our infrastructure, we can destroy it, using terraform destroy
aws_vpc.example: Refreshing state... [id=vpc-some_vpc]
aws_internet_gateway.gw: Refreshing state... [id=igw-some_igw]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# aws_internet_gateway.gw will be destroyed
- resource "aws_internet_gateway" "gw" {
- all parameters are specified here
}
# aws_vpc.example will be destroyed
- resource "aws_vpc" "example" {
- all parameters are specified here
}
Plan: 0 to add, 0 to change, 2 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
aws_internet_gateway.gw: Destroying... [id=igw-some_igw]
aws_internet_gateway.gw: Destruction complete after 2s
aws_vpc.example: Destroying... [id=vpc-some_vpc]
aws_vpc.example: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.
It can be a little overwhelming, but bear with me and understand the key points:
A resource is a component that can be managed with Terraform (a VM, a Kubernetes Pod, etc)
Documentation is your best friend, understand how the use those 4 sections from it
There are 4 essential commands that help you provision and destroy your infrastructure: init/plan/apply/destroy
When referencing a resource from a configuration we are using
type.name.attribute
Originally posted on Medium.
4. Data Sources and Outputs
Terraform resources are great and you can do a bunch of stuff with them. But did I tell you can use Data Sources and Outputs in conjunction with them to better implement your use case? Let’s jump into it.
To put it as simply as possible, A data source is a configuration object that retrieves data from an external source and can be used in resources as arguments when they are created or updated. When I am talking about an external source, I am referring to absolutely anything: manually created infrastructure, resources created from other terraform configurations, and others.
Data sources are defined in their respective providers and you can use them with a special block called data. The documentation of a data source is pretty similar to one of a resource, so if you’ve mastered how to use that one, this will be a piece of cake.
Let’s take an example of a data source that returns the most recent ami_id (image id) of an Ubuntu image in AWS.
provider "aws" {
region = "us-east-1"
}
data "aws_ami" "ubuntu" {
filter {
name = "name"
values = ["ubuntu-*"]
}
most_recent = true
}
output "ubuntu" {
value = data.aws_ami.ubuntu.id
}
I am putting a filter on the image name and I’m specifying all the image names that start with ubuntu-
. I’m adding the most_recent = true
to get only one image, as the aws_ami data source doesn’t support returning multiple image ids. So this data source will return only the most recent Ubuntu ami.
In the code example, there is also one reference to an output, but I haven’t exactly told you what an output is, did I?
An output is a way to easily view the value of a specific data source, resource, local, or variable after Terraform has finished applying changes to infrastructure. It can be defined in the Terraform configuration file and can be viewed using the terraform output
command, but just to reiterate, only after a terraform apply
happens. Outputs can be also used to expose different resources inside a module, but we will discuss this in another post.
Outputs don’t depend on a provider at all, they are a special kind of block that works independently from them.
The most important parameters an output supports are value, for which you specify what you want to see, and description (optional) in which you explain what that output wants to achieve.
Let’s go back to our example.
In the output, I have specified a reference to the above data source. When we are referencing a resource, we are using type.name.attribute
, for data sources, it’s pretty much the same but we have to prefix it with data, so data.type.name.attribute
will do the trick.
As I mentioned above, in order to see what this returns, you will first have to apply the code. You are not going to see the contents of a data source without an output, so I encourage you to use them at the beginning when you are trying to get familiar with them.
This is the output of a terraform apply
:
data.aws_ami.ubuntu: Reading...
data.aws_ami.ubuntu: Read complete after 3s [id=ami-0f388924d43083179]
Changes to Outputs:
+ ubuntu = "ami-0f388924d43083179"
You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
ubuntu = "ami-0f388924d43083179"
In an apply, the first state a resource goes through is creating, a data source is going through a reading state, just to make the differences between them clearer.
Now, let’s use this image to create an ec2 instance:
provider "aws" {
region = "us-east-1"
}
data "aws_ami" "ubuntu" {
filter {
name = "name"
values = ["ubuntu-*"]
}
most_recent = true
}
output "ubuntu" {
value = data.aws_ami.ubuntu.id
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
}
Just by referencing the ami id from our data source and an instance type, we are able to create an ec2 instance with a terraform apply
Don’t forget to delete your infrastructure if you are practicing, as everything you create will incur costs. Do that with a terraform destroy
.
Originally posted on Medium.
5. Terraform Variables and Locals
Terraform variables and locals are used to better organize your code, easily change it, and make your configuration reusable.
Before jumping into variables and locals in Terraform, let’s first discuss their supported types.
Usually, in any programming language, when we are defining a variable or a constant, we are assigning it, or it infers a type.
Supported types in Terraform:
Primitive:
String
Number
Bool
Complex — These types are created from other types:
List
Set
Map
Object
Null — Usually represents absence, really useful in conditional expressions.
There is also the any
type, in which you basically add whatever you want without caring about the type, but I really don’t recommend it as it will make your code harder to maintain.
Variables
Every variable will be declared with a variable
block and we will always use it with var.variable_name
. Let’s see this in action:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
}
variable "instance_type" {
description = "Instance Type of the variable"
type = string
default = "t2.micro"
}
I have declared a variable called instance_type
and in it, I’ve added 3 fields, all of which are optional, but usually, it is a best practice to add these, or at least the type and description. Well, there are three other possible arguments (sensitive, validation, and nullable), but let’s not get too overwhelmed by this.
In the resource block above, I’m referencing the variable, with var.instance_type
and due to the fact I’ve set the default value to t2.micro, my variable will get that particular value and I don’t need to do anything else. Cool, right?
Well, let’s suppose we are not providing any default value and we are not doing anything else and we run a terraform apply
. As Terraform does not know the value of the variable, it will ask you to provide a value for it. Pretty neat, that means you can forget to assign it. This is not a best practice, though.
There are a couple of other ways you can assign values to variables. If you happen to specify a value for a variable in multiple ways, Terraform will use the last value it finds, by taking into consideration their precedence order. I’m going to present these to you now:
using a default → as in the example above, this will be overwritten by any other option
using a
terraform.tfvars
file → this is a special file in which you can add values to your variables
instance_type = "t2.micro"
using a
*.auto.tfvars
file → similar to the terraform.tfvars file, but will take precedence over it. The variables' values will be declared in the same way. The “*” is a placeholder for any name you want to useusing
-var
or-var-file
when running terraform plan/apply/destroy. When you are using both of them in the same command, the value will be taken from the last option.
terraform apply -var="instance_type=t3.micro" -var-file="terraform.tfvars"
→ This will take the value from the var file, but if we specify the -var
option last, it will get the value from there.
Some other variables examples:
variable "my_number" {
description = "Number example"
type = number
default = 10
}
variable "my_bool" {
description = "Bool example"
type = bool
default = false
}
variable "my_list_of_strings" {
description = "List of strings example"
type = list(string)
default = ["string1", "string2", "string3"]
}
variable "my_map_of_strings" {
description = "Map of strings example"
type = map(string)
default = {
key1 = "value1"
key2 = "value2"
key3 = "value"
}
}
variable "my_object" {
description = "Object example"
type = object({
parameter1 = string
parameter2 = number
parameter3 = list(number)
})
default = {
parameter1 = "value"
parameter2 = 1
parameter3 = [1, 2, 3]
}
}
variable "my_map_of_objects" {
description = "Map(object) example"
type = map(object({
parameter1 = string
parameter2 = bool
parameter3 = map(string)
}))
default = {
elem1 = {
parameter1 = "value"
parameter2 = false
parameter3 = {
key1 = "value1"
}
}
elem2 = {
parameter1 = "another_value"
parameter2 = true
parameter3 = {
key2 = "value2"
}
}
}
}
variable "my_list_of_objects" {
description = "List(object) example"
type = list(object({
parameter1 = string
parameter2 = bool
}))
default = [
{
parameter1 = "value"
parameter2 = false
},
{
parameter1 = "value2"
parameter2 = true
}
]
}
In the above example, there are two variables of a simple type (number and bool), and a couple of complex types. As the simple ones are pretty easy to understand, let’s jump into the others.
list(string)
— in this variable, you can declare how many strings you want inside the list. You are going to access an instance of the list by using var.my_list_of_strings[index]
. Keep in mind that lists start from 0. var.my_list_of_strings[1]
will return string2
.
map(string)
— in this variable, you can declare how many key:value pairs you want. You are going to access an instance of the map by using var.my_map_of_strings[key]
where key is on the left-hand side from the equal sign. var.my_map_of_strings["key3"]
will return value
object({})
— inside of an object, you are declaring parameters as you see fit. You can have simple types inside of it and even complex types and you can declare as many as you want. You can consider an object to be a map having more explicit types defined for the keys. You are going to access instances of an object, by using the same logic as you would for a map.
map(object({}))
— I’ve specified this complex build, because this is something I am using a lot inside of my code because it works well with for_each
(don’t worry, we will talk about this in another post). You are going to access a property of an object in the map by using var.my_map_of_objects["key"]["parameter"]
and if there are any other complex parameters defined you will have to go deeper. var.my_map_of_objects["elem1"]["parameter1"]
will return value
. var.my_map_of_objects["elem1"]["parameter3"]["key1"]
will return value1
.
list(object({}))
— This is something I’m using in dynamic blocks
(again, we will discuss this in detail in another post). You are going to access a property of an object in the list, by using var.my_list_of_objects[index]["parameter"]
. Again, if there are any parameters that are complex, you will have to go deeper. var.my_list_of_objects[0]["parameter1"]
will return value
.
One important thing to note is the fact that you cannot reference other resources or data sources inside a variable, so you cannot say that a variable is equal to a resource attribute by using the type.name.attribute
.
Locals
On the other hand, a local variable
assigns a name to an expression, making it easier for you to reference it, without having to write that expression a gazillion times. They are defined in a locals block, and you can have multiple local variables defined in a single local block. Let’s take a look:
locals {
instance_type = "t2.micro"
most_recent = true
}
data "aws_ami" "ubuntu" {
filter {
name = "name"
values = ["ubuntu-*"]
}
most_recent = local.most_recent
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = local.instance_type
}
As you see, we are defining inside the locals block two local variables and we are referencing them throughout our configuration with local.local_variable_name
.
As opposed to variables, inside of a local, you can define whatever resource or data source attribute you want. We can even define more complex operations inside of them, but for now, let’s just let this sync in as we are going to experiment with these some more in the future.
Originally posted on Medium.
6. Terraform Provisioners and Null Resource
Terraform provisioners have nothing in common with providers. You can use provisioners to run different commands or scripts on your local machine or a remote machine, and also copy files from your local machine to a remote one. Provisioners, exist inside of a resource, so in order to use one, you will simply have to add a provisioner block in that particular resource.
One thing worth mentioning is the fact that a provisioner is not able to reference the parent resource by its name, but they can use the self object which actually represents that resource.
They are considered a last resort, as they are not a part of the Terraform declarative model.
There are 3 types of provisioners:
local-exec
file (should be used in conjunction with a connection block)
remote-exec (should be used in conjunction with a connection block)
All provisioners support two interesting options when and on_failure.
You can run provisioners either when the resource is created (which is, of course, the default option) or if your use case asks for it, run it when a resource is destroyed.
By default, on_failure is set to fail, which will fail the apply if the provisioner fails, which is expected Terraform behaviour, but you can set it to ignore a fail by setting it to continue.
From experience, I can tell you that sometimes provisioners fail for no reason, or they can even appear to be working and not doing what they are expected to. Still, I believe it is still very important to know how to use them, because, in some of your use cases, you may not have any alternatives.
Before jumping into each of the provisioners, let’s talk about null resources. A null resource is basically something that doesn’t create anything on its own, but you can use it to define provisioners blocks. They also have a “trigger” attribute, which can be used to recreate the resource, hence to rerun the provisioner block if the trigger is hit.
Local-Exec
As its name suggests, a local-exec block is going to run a script on your local machine. Nothing too fancy about it. Apart from the when and on_failure options, there are a couple of other options you can specify:
command — what to run; this is the only required argument.
working_dir — where to run it
interpreter — what interpreter to use (e.g /bin/bash), by default terraform will decide based on your system os
environment — key/value pairs that represent the environment
Let’s see this in action in a null resource and observe the output of a terraform apply
resource "null_resource" "this" {
provisioner "local-exec" {
command = "echo Hello World!"
}
}
# null_resource.this: Creating...
# null_resource.this: Provisioning with 'local-exec'...
# null_resource.this (local-exec): Executing: ["/bin/sh" "-c" "echo Hello World!"]
# null_resource.this (local-exec): Hello World!
# null_resource.this: Creation complete after 0s [id=someid]
You can use this to run different scripts before or after an apply of a specific resource by using depends_on (we will talk about this in another article in more detail).
Connection Block
Before going into the other two provisioners, remote-exec and file, let’s take some time and understand the connection block. In order to run or copy something on a remote vm, you will first have to connect to it, right?
Connection blocks, support both ssh and winrm, so you can easily connect to both your Linux and Windows vms.
You even have the option to connect via a bastion host or a proxy, but I will just show you a simple connection block for a Linux VM.
connection {
type = "ssh"
user = "root"
private_key = "private_key_contents"
host = "host"
}
File
The file provisioner is used to copy a file from your local vm to a remote vm. There are three arguments that are supported:
source (what file to copy)
content (the direct content to copy on the destination)
destination (where to put the file)
As mentioned before, file needs a connection block to make sure it works properly. Let’s see an example on an ec2 instance.
provider "aws" {
region = "us-east-1"
}
locals {
instance_type = "t2.micro"
most_recent = true
}
data "aws_ami" "ubuntu" {
filter {
name = "name"
values = ["ubuntu-*"]
}
most_recent = local.most_recent
}
resource "aws_key_pair" "this" {
key_name = "key"
public_key = file("~/.ssh/id_rsa.pub")
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = local.instance_type
key_name = aws_key_pair.this.key_name
}
resource "null_resource" "copy_file_on_vm" {
depends_on = [
aws_instance.web
]
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = aws_instance.web.public_dns
}
provisioner "file" {
source = "./file.yaml"
destination = "./file.yaml"
}
}
# null_resource.copy_file_on_vm: Creating...
# null_resource.copy_file_on_vm: Provisioning with 'file'...
# null_resource.copy_file_on_vm: Creation complete after 2s [id=someid]
Remote-Exec
Remote-Exec is used to run a command or a script on a remote-vm.
It supports the following arguments:
inline → list of commands that should run on the vm
script → a script that runs on the vm
scripts → multiple scripts to run on the vm
You have to provide only one of the above arguments as they are not going to work together.
Similar to file, you will need to add a connection block.
resource "null_resource" "remote_exec" {
depends_on = [
aws_instance.web
]
connection {
type = "ssh"
user = "ubuntu"
private_key = file("~/.ssh/id_rsa")
host = aws_instance.web.public_dns
}
provisioner "remote-exec" {
inline = [
"mkdir dir1"
]
}
}
# null_resource.remote_exec: Creating...
# null_resource.remote_exec: Provisioning with 'remote-exec'...
# null_resource.remote_exec (remote-exec): Connecting to remote host via SSH...
# null_resource.remote_exec (remote-exec): Host: somehost
# null_resource.remote_exec (remote-exec): User: ubuntu
# null_resource.remote_exec (remote-exec): Password: false
# null_resource.remote_exec (remote-exec): Private key: true
# null_resource.remote_exec (remote-exec): Certificate: false
# null_resource.remote_exec (remote-exec): SSH Agent: true
# null_resource.remote_exec (remote-exec): Checking Host Key: false
# null_resource.remote_exec (remote-exec): Target Platform: unix
# null_resource.remote_exec (remote-exec): Connected!
# null_resource.remote_exec: Creation complete after 3s [id=someid]
In order to use the above code, just use the file example and change the copy_file_on_vm null resource with this one and you are good to go.
You have to make sure that you can connect to your vm, so make sure you have a security rule that permits ssh access in your security group.
Even though I don’t recommend provisioners, keep in mind they may be a necessary evil.
Originally posted on Medium.
7. Terraform Loops and Conditionals
In this post, we will talk about how to use Count, for_each, for loops, ifs, and ternary operators inside of Terraform. It will be a long journey, but this will help a lot when writing better Terraform code.
As I am planning to use the Kubernetes provider in this lesson, you can easily create your own Kubernetes cluster using Kind. More details here.
👉 COUNT 👈
If I say I hate count, that would be an understatement. I get why people use it, but in my book, since for_each was released, I never looked back. This is my really unpopular opinion, so don’t take my word for it.
Let’s see what we can do with count. Using count we can, you guessed it, create multiple resources of the same type. Every terraform resource supports the count block. Count exposes a count.index
object, which can be used in the same way you would use an iterator in any programming language.
resource "kubernetes_namespace" "this" {
count = 5
metadata {
name = format("ns%d", count.index)
}
}
The above block will create 5 namespaces in Kubernetes with the following names: ns1, ns2, ns3, ns4, ns5. All fun and games until now. If you change the count to 4, the last namespace, ns5 will be deleted if you re-apply the code.
In any case, when you are using count, you can address a particular index of your resource by using type.name[index]
. In our case that means individual resources can be accessed with kubernetes_namespace.this[0]
to kubernetes_namespace.this[4]
.
Let’s suppose you want to customize, a little bit the names of the namespaces. For that, we can you use a local or a variable in conjunction with a function. Don’t worry if you see a couple of functions now, we will discuss functions in a separate article in detail.
locals {
namespaces = ["frontend", "backend", "database"]
}
resource "kubernetes_namespace" "this" {
count = length(local.namespaces)
metadata {
name = local.namespaces[count.index]
}
}
The above will create three namespaces called frontend, backend, and database. Let’s suppose for whatever reason, you want to remove the backend namespace and keep only the other two.
What is going to happen when you reapply the code?
Plan: 1 to add, 0 to change, 2 to destroy.
Let’s break this down. We are using a list with 3 elements, which means that our list has 3 indexes: 0, 1, and 2. If we remove the element from index 1, backend, the element from index 2, becomes the element from index 1.
To make it even more clear, initially, we have the following resources:
kubernetes_namespace.this[0] → frontend
kubernetes_namespace.this[1] → backend
kubernetes_namespace.this[2] → database
After we remove backend:
kubernetes_namespace.this[0] → frontend
kubernetes_namespace.this[1] → database
Due to the fact that database changes its terraform identity by moving from index 2 to index 1, it will be recreated, and imagine all the problems you will have when you are recreating a namespace with a ton of things inside.
Also, let’s suppose you are creating a hundred ec2 instances and you are using a list for that to better configure them. For some reason, let’s suppose you want to remove the instance with index 13 (see what I did there?), what’ going to happen with the ones from index 14 to 100? They will be recreated because all of them are going to change their index to what it was minus 1.
And that’s why I hate count. It doesn’t give you the flexibility to create very generic resources and in my book, that’s a hard pass.
👉 FOR_EACH 👈
I am a big fan of using for_each on all of the resources, as you never know when you want to create multiple resources of the same kind. For_Each can be used with map and set variables, but I don’t remember a use case in which I used a set. So what I’m always doing is using for_each on maps, and more specifically on map(object)
. I’ll show you what that looks like in a bit.
For_each exposes one attribute called each
. This attribute contains a key and value which can be used witheach.key
and each.value
.
With for_each, you will reference an instance of your resource with type.name[key]
.
Let’s use a variable this time to create the namespaces, and let’s configure some more information for them in order to see why for_each is superior from my point of view.
resource "kubernetes_namespace" "this" {
for_each = var.namespaces
metadata {
name = each.key
annotations = each.value.annotations
labels = each.value.labels
}
}
variable "namespaces" {
type = map(object({
annotations = optional(map(string), {})
labels = optional(map(string), {})
}))
default = {
namespace1 = {}
namespace2 = {
labels = {
color = "green"
}
}
namespace3 = {
annotations = {
imageregistry = "https://hub.docker.com/"
}
}
namespace4 = {
labels = {
color = "blue"
}
annotations = {
imageregistry = "my_awesome_registry"
}
}
}
}
We have defined a variable called namespaces
and we are going to iterate through it on the kubernetes_namespace
resource. This variable has a map(object)
type and inside of it, we’ve defined two optional properties: annotations and labels.
Optional can be used on parameters inside object variables to give the possibility to omit that particular parameter and to provide a default value for it instead. As this feature is available from Terraform 1.3.0, I believe it will soon be embraced by the community as a best practice (for me, it is already). Inside this variable, we have added a default value, just for demo purposes, in the real world, you are going to anyway separate resources from variables in their own files, and you are going to provide default values as empty maps if you are using the above approach with optionals on your parameters, but that’s a completely different story.
Let’s go a little bit through our default value:
All namespaces will be our
each.key
Everything after namespaceX = will be our
each.value
→ Meaning, that if we want to reference labels or annotations, we are going to useeach.value.labels
oreach.value.annotations
Due to the fact that both parameters inside of our variable have been defined with the optional block, we can omit them, meaning that namespace1 = {}
is a valid configuration. This is happening because, in our resource, we’ve assigned the name in the metadata block to each.key
.
The entire configuration translates into:
namespace1
→ will have no labels and no annotationsnamespace2
→ will have only labelsnamespace3
→ will have only annotationsnamespace4
→ will have both labels and annotations
If I want to remove namespace2
for whatever reason, what is going to happen to namespace3
and namespace4
? Absolutely nothing. Due to the fact that we are not using a list anymore and we are using a map, by removing an element of the map, there is going to be no change to the others (remember, Terraform identifies our resources with kubernetes_namespace.this["namespace1"]
to kubernetes_namespace.this["namespace4"]
).
And this is why I will always vouch for for_each
instead of count.
👉 Ternary Operators 👈
I’ve seen plenty of people arguing that Terraform doesn’t have an ifinstruction. Well, it does, but you can use that only when you are building complex instructions in for loops (not for_each), which I will discuss soon. For any other type of condition, you have ternary operators and the syntax is:
condition ? val1 : val2
The above means if the condition is true, use val1, if the condition is false, use val2. Let’s see it in action:
locals {
use_local_name = false
name = "namespace1"
}
resource "kubernetes_namespace" "this" {
metadata {
name = local.use_local_name ? local.name : "namespace2"
}
}
In the above example, I’m checking if local.use_local_name
is equal to true, and if it is, I’m going to provide to my namespace the name that is in local.name
otherwise, I am going to provide it namespace2
.
Of course, due to the fact that I’ve set use_local_name
to false, this means that the name of my namespace will be namespace2
.
The beautiful or the ugliest part of ternary operators (depending on who reads the code) is the fact that you can use nested conditionals.
Let’s build a local variable that uses nested conditionals:
locals {
val1 = 1
val2 = 2
val3 = 3
nested_conditional = local.val2 > local.val1 ? local.val3 > local.val2 ? local.val3 : local.val2 : local.val1
}
In the above operation, we are checking initially if val2
is greater than val1
:
if it’s not, then nested_conditional will go to the last “:” and assign the value to
local.val1
if it is, then we are checking if
val3
is greater thanval2
and:
- ifval3
is greater thanval2
the value of nested _conditional will beval3
- ifval3
is less thanval2
the value of nested_conditional will beval2
These nested conditionals can get pretty hard to understand and usually if you see something that goes more than 3 or 4 levels, there is almost always an error in judgment somewhere or you should do some changes to the variable or expression that you are using when you are building this as it will get almost impossible to maintain in the long run.
👉 For loops and Ifs 👈
If you are familiar with Python, you are going to notice pretty easily that for loops and ifs in Terraform are pretty similar to Python’s list comprehensions.
Let me show you what I’m talking about:
locals {
list_var = range(5)
map_var = {
cat1 = {
color = "orange",
name = "Garfield"
},
cat2 = {
color = "blue",
name = "Tom"
}
}
for_list_list = [for i in local.list_var : i * 2]
for_list_map = { for i in local.list_var : format("Number_%s", i) => i }
for_map_list = [for k, v in local.map_var : k]
for_map_map = { for k, v in local.map_var : format("Cat_%s", k) => v }
for_list_list_if = [for i in local.list_var : i if i > 2]
for_map_map_if = { for k, v in local.map_var : k => v if v.color == "orange" }
}
We’ve defined a list variable that generates numbers from 0 to 4 list_var
and a map variable with two elements map_var
. As you can see, for the other 6 locals defined in the code snippet, you can build both lists and maps by using this type of loop. By starting the value with [
you are creating a list, and by starting the value with {
you are creating a map.
The difference from a syntax standpoint is that when you are building a map you have to provide the =>
attribute. The sky is the limit when it comes to these expressions, you can nest them on how many levels you want depending on the structure you are iterating through, but this will become, again, very hard to maintain.
If you are cycling through a map variable, and you are using a single iterator, you will actually cycle only through the values of the map, by using two, you will cycle through both keys and variables (the first iterator will be the key, the second iterator will be the value).
for_list_list = [
0,
2,
4,
6,
8,
]
for_list_map = {
"Number_0" = 0
"Number_1" = 1
"Number_2" = 2
"Number_3" = 3
"Number_4" = 4
}
for_map_list = [
"cat1",
"cat2",
]
for_map_map = {
"Cat_cat1" = {
"color" = "orange"
"name" = "Garfield"
}
"Cat_cat2" = {
"color" = "blue"
"name" = "Tom"
}
}
for_list_list_if = [
3,
4,
]
for_map_map_if = {
"cat1" = {
"color" = "orange"
"name" = "Garfield"
}
}
Above are all the values of the locals defined with for loops and ifs. Let’s discuss the last two:
for_list_list_if = [for i in local.list_var : i if i > 2]
This is cycling through our initial list that holds the numbers from 0 to 4 and is creating a new list with only the elements that are greater than 2.
for_map_map_if = { for k, v in local.map_var : k => v if v.color == "orange" }
This one is cycling through our initial map variable and will recreate a new map with all the elements that have the color equal to orange.
There is another operator called splat(*)
that can help with providing a more concise way to reference some common operations that you would usually do with a for. This operator works only on lists, sets, and tuples.
splat_list = [
{
name = "Mike"
age = 25
},
{
name = "Bob"
age = 29
}
]
splat_list_names = local.splat_list[*].name
If for example, you would’ve had a list of maps in the above format, you can easily build a list of all the names or ages from it, by using the splat operator.
Originally posted on Medium here.
8. Terraform CLI Commands
Throughout my posts, I said I don’t want to repeat myself or reinvent the wheel, right?
Well, I am keeping my promise, so if you want to see all commands that you can use and even download an awesome cheatsheet with them, you can follow this article which really nails it: https://spacelift.io/blog/terraform-commands-cheat-sheet
9. Terraform Functions
Terraform functions are built-in, reusable code blocks that perform specific tasks within Terraform configurations. They make your code more dynamic and ensure your configuration is DRY. Functions allow you to perform various operations, such as converting expressions to different data types, calculating lengths, and building complex variables.
These functions are split into multiple categories:
String
Numeric
Collection
Date and Time
Crypto and Hash
Filesystem
IP Network
Encoding
Type Conversion
This split, however, can become overwhelming to someone who doesn’t have that much experience with Terraform. For example, the formatlist list function is considered to be a string function even though it modifies elements from a list. A list is a collection though; some may argue that this function should be considered a collection function, but still, at its core, it does changes to strings.
For that particular reason, I won’t specify the function type when I’ll describe them, but just go with what you can do with them. Of course, I will not go through all of the available functions, but through the ones, I am using throughout my configurations.
ToType Functions
ToType is not an actual function; rather, many functions can help you change the type of a variable to another type.
tonumber(argument)
→ With this function you can change a string to a number, anything else apart from another number and null will result in an error
tostring(argument)
→ Changes a number/bool/string/null to a string
tobool(argument)
→ Changes a string (only “true” or “false”)/bool/null to a bool
tolist(argument)
→ Changes a set to a list
toset(argument)
→ Changes a list to a set
tomap(argument)
→ Converts its argument to a map
In Terraform, you are rarely going to need to use these types of functions, but I still thought they are worth mentioning.
format(string_format, unformatted_string)
The format function is similar to the printf function in C and works by formatting a number of values according to a specification string. It can be used to build different strings that may be used in conjunction with other variables. Here is an example of how to use this function:
locals {
string1 = "str1"
string2 = "str2"
int1 = 3
apply_format = format("This is %s", local.string1)
apply_format2 = format("%s_%s_%d", local.string1, local.string2, local.int1)
}
output "apply_format" {
value = local.apply_format
}
output "apply_format2" {
value = local.apply_format2
}
# Result in:
apply_format = "This is str1"
apply_format2 = "str1_str2_3"
formatlist(string_format, unformatted_list)
The formatlist function uses the same syntax as the format function but changes the elements in a list. Here is an example of how to use this function:
locals {
format_list = formatlist("Hello, %s!", ["A", "B", "C"])
}
output "format_list" {
value = local.format_list
}
# Result in:
format_list = tolist(["Hello, A!", "Hello, B!", "Hello, C!"])
length(list / string / map)
Returns the length of a string, list, or map.
locals {
list_length = length([10, 20, 30])
string_length = length("abcdefghij")
}
output "lengths" {
value = format("List length is %d. String length is %d", local.list_length, local.string_length)
}
# Result in:
lengths = "List length is 3. String length is 10"
join(separator, list)
Another useful function in Terraform is “join”. This function creates a string by concatenating together all elements of a list and a separator. For example, consider the following code:
locals {
join_string = join(",", ["a", "b", "c"])
}
output "join_string" {
value = local.join_string
}
# Result in:
The output of this code will be “a, b, c”.
try(value, fallback)
Sometimes, you may want to use a value if it is usable, but fall back to another value if the first one is unusable. This can be achieved using the “try” function. For example:
locals {
map_var = {
test = "this"
}
try1 = try(local.map_var.test2, "fallback")
}
output "try1" {
value = local.try1
}
# Result:
The output of this code will be “fallback”, as the expression local.map_var.test2 is unusable.
can(expression)
A useful function for validating variables is “can”. It evaluates an expression and returns a boolean indicating if there is a problem with the expression. For example:
variable "a" {
type = any
validation {
condition = can(tonumber(var.a))
error_message = format("This is not a number: %v", var.a)
}
default = "1"
}
# Result:
The validation in this code will give you an error: “This is not a number: 1”.
flatten(list)
In Terraform, you may work with complex data types to manage your infrastructure. In these cases, you may want to flatten a list of lists into a single list. This can be achieved using the “flatten” function, as in this example:
locals {
unflatten_list = [[1, 2, 3], [4, 5], [6]]
flatten_list = flatten(local.unflatten_list)
}
output "flatten_list" {
value = local.flatten_list
}
# Result:
The output of this code will be [1, 2, 3, 4, 5, 6].
keys(map) & values(map)
It may be useful to extract the keys or values from a map as a list. This can be achieved using the “keys” or “values” functions, respectively. For example:
locals {
key_value_map = {
"key1" : "value1",
"key2" : "value2"
}
key_list = keys(local.key_value_map)
value_list = values(local.key_value_map)
}
output "key_list" {
value = local.key_list
}
output "value_list" {
value = local.value_list
}
# Result:
key_list = ["key1", "key2"]
value_list = ["value1", "value2"]
slice(list, startindex, endindex)
Slice returns consecutive elements from a list from a startindex (inclusive) to an endindex (exclusive).
locals {
slice_list = slice([1, 2, 3, 4], 2, 4)
}
output "slice_list" {
value = local.slice_list
}
# Result:
slice_list = [3]
range
Creates a range of numbers:
one argument(limit)
two arguments(initial_value, limit)
three arguments(initial_value, limit, step)
locals {
range_one_arg = range(3)
range_two_args = range(1, 3)
range_three_args = range(1, 13, 3)
}
output "ranges" {
value = format("Range one arg: %v. Range two args: %v. Range three args: %v", local.range_one_arg, local.range_two_args, local.range_three_args)
}
# Result:
range = "Range one arg: [0, 1, 2]. Range two args: [1, 2]. Range three args: [1, 4, 7, 10]"
lookup(map, key, fallback_value)
Retrieves a value from a map using its key. If the value is not found, it will return the default value instead
locals {
a_map = {
"key1" : "value1",
"key2" : "value2"
}
lookup_in_a_map = lookup(local.a_map, "key1", "test")
}
output "lookup_in_a_map" {
value = local.lookup_in_a_map
}
# Result:
This will return: lookup_in_a_map = "key1"
concat(lists)
Takes two or more lists and combines them in a single one
locals {
concat_list = concat([1, 2, 3], [4, 5, 6])
}
output "concat_list" {
value = local.concat_list
}
# Result:
concat_list = [1, 2, 3, 4, 5, 6]
merge(maps)
The merge
function takes one or more maps and returns a single map that contains all of the elements from the input maps. The function can also take objects as input, but the output will always be a map.
Let’s take a look at an example:
locals {
b_map = {
"key1" : "value1",
"key2" : "value2"
}
c_map = {
"key3" : "value3",
"key4" : "value4"
}
final_map = merge(local.b_map, local.c_map)
}
output "final_map" {
value = local.final_map
}
# Result:
final_map = {
"key1" = "value1"
"key2" = "value2"
"key3" = "value3"
"key4" = "value4"
}
zipmap(key_list, value_list)
Constructs a map from a list of keys and a list of values
locals {
key_zip = ["a", "b", "c"]
values_zip = [1, 2, 3]
zip_map = zipmap(local.key_zip, local.values_zip)
}
output "zip_map" {
value = local.zip_map
}
# Result
zip_map = {
"a" = 1
"b" = 2
"c" = 3
}
expanding function argument …
This special argument works only in function calls and expands a list into separate arguments. Useful when you want to merge all maps from a list of maps
locals {
list_of_maps = [
{
"a" : "a"
"d" : "d"
},
{
"b" : "b"
"e" : "e"
},
{
"c" : "c"
"f" : "f"
},
]
expanding_map = merge(local.list_of_maps...)
}
output "expanding_map" {
value = local.expanding_map
}
# Result
expanding_map = {
"a" = "a"
"b" = "b"
"c" = "c"
"d" = "d"
"e" = "e"
"f" = "f"
}
file(path_to_file)
Reads the content of a file as a string and can be used in conjunction with other functions like jsondecode / yamldecode.
locals {
a_file = file("./a_file.txt")
}
output "a_file" {
value = local.a_file
}
# Result
The output would be the content of the file called a_file as a string.
templatefile(path, vars)
Reads the file from the specified path and changes the variables specified in the file between the interpolation syntax ${ … } with the ones from the vars map.
locals {
a_template_file = templatefile("./file.yaml", { "change_me" : "awesome_value" })
}
output "a_template_file" {
value = local.a_template_file
}
# Result
This will change the ${change_me} variable to awesome_value.
jsondecode(string)
Interprets a string as json.
locals {
a_jsondecode = jsondecode("{\"hello\": \"world\"}")
}
output "a_jsondecode" {
value = local.a_jsondecode
}
# Result
jsondecode = {
"hello" = "world"
}
jsonencode(string)
Encodes a value to a string using json
locals {
a_jsonencode = jsonencode({ "hello" = "world" })
}
output "a_jsonencode" {
value = local.a_jsonencode
}
# Result
a_jsonencode = "{\"hello\":\"world\"}"
yamldecode(string)
Parses a string as a subset of YAML, and produces a representation of its value.
locals {
a_yamldecode = yamldecode("hello: world")
}
output "a_yamldecode" {
value = local.a_yamldecode
}
# Result:
a_yamldecode = {
"hello" = "world"
}
yamlencode(value)
Encodes a given value to a string using YAML.
locals {
a_yamlencode = yamlencode({ "a" : "b", "c" : "d" })
}
output "a_yamlencode" {
value = local.a_yamlencode
}
# Result:
a_yamlencode = <<EOT
"a": "b"
"c": "d"
EOT
You can use Terraform functions to make your life easier and to write better code. Keeping your configuration as DRY as possible improves readability and makes updating it easier, so functions are a must-have in your configurations.
These are just the functions that I am using the most, but some honorable mentions are element
, base64encode
, base64decode
, formatdate
, uuid
, and distinct
.
Originally posted on Medium here.
10. Working with files
When it comes to Terraform, working with files is pretty straightforward. Apart from the .tf and .tfvars files, you can use json, yaml, or whatever other file types that support your needs.
In the last article, I mentioned a couple of functions that interact with files, like file & template_file
, and right now I want to show you how easy it is to interact with a lot of files and use some of their content as variables for your configuration.
For the following examples, let’s suppose we are using this yaml file:
namespaces:
ns1:
annotations:
imageregistry: "https://hub.docker.com/"
labels:
color: "green"
size: "big"
ns2:
labels:
color: "red"
size: "small"
ns3:
annotations:
imageregistry: "https://hub.docker.com/"
Reading a file only if it exists
locals {
my_file = fileexists("./my_file.yaml") ? file("./my_file.yaml") : null
}
output "my_file" {
value = local.my_file
}
We are using two functions for this and a ternary operator. The fileexists
function checks if the file exists and returns true or false based on the result. In our case above, if the file exists, my_file
will be the content of my_file.yaml
as a string, otherwise will be null.
Let’s take this up a notch and transform this into a real use case.
Reading the yaml file and using it in a configuration if it exists
locals {
namespaces = fileexists("./my_file.yaml") ? yamldecode(file("./my_file.yaml")).namespaces : var.namespaces
}
output "my_file" {
value = local.namespaces
}
resource "kubernetes_namespace" "this" {
for_each = local.namespaces
metadata {
name = each.key
annotations = lookup(each.value, "annotations", {})
labels = lookup(each.value, "labels", {})
}
}
variable "namespaces" {
type = map(object({
annotations = optional(map(string), {})
labels = optional(map(string), {})
}))
default = {
ns1 = {}
ns2 = {}
ns3 = {}
}
}
In the example above, we are checking if the file exists, and if it does we are going to load the namespaces from the yaml file, otherwise use a variable for that. As the file exists, we are using yamldecode
on the loaded string to create a map variable and we are passing it to for_each. This can be extremely useful in some use cases.
Example Terraform plan:
# kubernetes_namespace.this["ns1"] will be created
+ resource "kubernetes_namespace" "this" {
+ id = (known after apply)
+ metadata {
+ annotations = {
+ "imageregistry" = "https://hub.docker.com/"
}
+ generation = (known after apply)
+ labels = {
+ "color" = "green"
+ "size" = "big"
}
+ name = "ns1"
+ resource_version = (known after apply)
+ uid = (known after apply)
}
}
# kubernetes_namespace.this["ns2"] will be created
+ resource "kubernetes_namespace" "this" {
+ id = (known after apply)
+ metadata {
+ generation = (known after apply)
+ labels = {
+ "color" = "red"
+ "size" = "small"
}
+ name = "ns2"
+ resource_version = (known after apply)
+ uid = (known after apply)
}
}
# kubernetes_namespace.this["ns3"] will be created
+ resource "kubernetes_namespace" "this" {
+ id = (known after apply)
+ metadata {
+ annotations = {
+ "imageregistry" = "https://hub.docker.com/"
}
+ generation = (known after apply)
+ name = "ns3"
+ resource_version = (known after apply)
+ uid = (known after apply)
}
}
Using templatefile on a yaml file and use its configuration if it exists
We can go further on this example and add some variables that we will change with templatefile. To do that, we must first make some changes to our initial yaml file.
namespaces:
ns1:
annotations:
imageregistry: ${image_registry_ns1}
labels:
color: "green"
size: "big"
ns2:
labels:
color: ${color_ns2}
size: "small"
ns3:
annotations:
imageregistry: "https://hub.docker.com/"
The only change that we will have to do for the above code will be a change to the locals:
locals {
namespaces = fileexists("./my_file.yaml") ? yamldecode(templatefile("./my_file.yaml", { image_registry_ns1 = "ghcr.io", color_ns2 = "black" })).namespaces : var.namespaces
}
Those two variables defined inside the yaml file using the ${}
syntax, will be changed with the ones from the templatefile functions.
Fileset
The fileset
function helps with identifying all files inside a directory that respect a pattern.
locals {
yaml_files = fileset(".", "*.yaml")
}
The above will show all the yaml files, inside the current directory. This output will be a list of all those files. Not very useful on its own, right?
Well, you can use the file
function to load the content of these files as strings. You can take them one by one, using list indexes, but if you want to take it up a notch, what you can do is use a for loop and group them together in something that makes sense.
Let’s suppose you have 2 other yaml files that are similar to the one we used at the beginning of the post. We can group them all together in a single variable:
locals {
namespaces = merge([for my_file in fileset(".", "*.yaml") : yamldecode(file(my_file))["namespaces"]]...)
}
output "namespaces" {
value = local.namespaces
}
As the files are exactly the same, only the names of the namespaces are different, this will result in:
namespaces = {
"ns1" = {
"annotations" = {
"imageregistry" = "https://hub.docker.com/"
}
"labels" = {
"color" = "green"
"size" = "big"
}
}
"ns2" = {
"labels" = {
"color" = "red"
"size" = "small"
}
}
"ns3" = {
"annotations" = {
"imageregistry" = "https://hub.docker.com/"
}
}
"ns4" = {
"annotations" = {
"imageregistry" = "https://hub.docker.com/"
}
"labels" = {
"color" = "green"
"size" = "big"
}
}
"ns5" = {
"labels" = {
"color" = "red"
"size" = "small"
}
}
"ns6" = {
"annotations" = {
"imageregistry" = "https://hub.docker.com/"
}
}
"ns7" = {
"annotations" = {
"imageregistry" = "https://hub.docker.com/"
}
"labels" = {
"color" = "green"
"size" = "big"
}
}
"ns8" = {
"labels" = {
"color" = "red"
"size" = "small"
}
}
"ns9" = {
"annotations" = {
"imageregistry" = "https://hub.docker.com/"
}
}
}
You can check another cool example here.
Other Examples
You can get filesystem-related information using these key expressions:
path.module — This function returns the path of the current module being executed. This is useful for accessing files or directories that are relative to the module being executed.
path.root — This function returns the root directory of the current Terraform project. This is useful for accessing files or directories located at the project's root.
path.cwd — This function returns the current working directory where Terraform is being executed before any chdir operations happened. This is useful for accessing files or directories that are relative to the directory where Terraform is running from.
There are some other file functions that can be leveraged in order to accommodate some use cases, but to be honest I’ve used them only once or twice.
Still, I believe mentioning them, will bring some value.
basename
— takes a path and returns everything apart from the last part of it
E.G: basename("/Users/user1/hello.txt")
will return hello.txt.
dirname
— behaves exactly opposite to basename, returns all the directories until the file
E.G: dirname("/Users/user1/hello.txt")
will return /Users/user1
pathexpand
— takes a path that starts with a ~
and expands this path adding the home of the logged in user. If the path, doesn’t use a ~
this function will not do anything
E.G: You are logged in as user1 on a Mac: pathexpand("~/hello.txt")
will return /Users/user1/hello.txt
filebase64
— reads the content of a file and returns it as base64 encoded text.
abspath
— takes a string containing a filesystem path and returns the absolute path
Originally posted on Medium here.
11. Understanding Terraform State
Terraform state is a critical component of Terraform that enables users to define, provision, and manage infrastructure resources using declarative code. In this blog post, we will explore the importance of Terraform state, how it works, and best practices for managing it.
It is a json file that tracks the state of infrastructure resources managed by Terraform. By default, the name of the file is terraform.tfstate
and whenever you update the first state, a backup is generated called terraform.tfstate.backup
.
This state file is stored locally by default, but can also be stored remotely using a remote backend such as Amazon S3, Azure Blob Storage, Google Cloud Storage, or HashiCorp Consul. The Terraform state file includes the current configuration of resources, their dependencies, and metadata such as resource IDs and resource types. There are a couple of products that help with managing state and provide a sophisticated workflow around Terraform like Spacelift or Terraform Cloud.
How does it work?
When Terraform is executed, it reads the configuration files and the current state file to determine the changes required to bring the infrastructure to the desired state. Terraform then creates an execution plan that outlines the changes to be made to the infrastructure. If the plan is accepted, Terraform applies the changes to the infrastructure and updates the state file with the new state of the resources.
You can use the terraform state
command to manage your state.
terraform state list
: This command lists all the resources that are currently tracked by Terraform state.terraform state show
: This command displays the details of a specific resource in the Terraform state. The output includes all the attributes of the resource.terraform state pull
: This command retrieves the current Terraform state from a remote backend and saves it to a local file. This command is useful when you want to make manual operations in a remote state.terraform state push
: This command uploads the local Terraform state file to the remote backend. This command is useful after you made manual changes to your remote state.terraform state rm
: This command removes a resource from the Terraform state. This doesn’t mean the resource will be destroyed, it won’t be managed by Terraform after you’ve removed it.terraform state mv
: This command renames a resource in the Terraform state.terraform state replace-provider
: This command replaces the provider configuration for a specific resource in the Terraform state. This command is useful when switching from one provider to another or upgrading to a new provider version.
Supported backends
Amazon S3 Backend
The Amazon S3 backend is a popular choice for remote state storage. To configure the Amazon S3 backend, you will need to create an S3 bucket and an IAM user with permissions to access the bucket. Here is an example of how to configure the Amazon S3 backend in Terraform:
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "terraform.tfstate"
region = "us-west-2"
}
}
The S3 backend supports locking, but to do that you will need also need a dynamodb table. The table must have a partition key named LockID
as a string. If this is not configured, state locking will be disabled.
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "terraform.tfstate"
region = "us-west-2"
dynamodb_table = "terraform-state-lock"
}
}
Azure Blob Storage Backend
The Azure Blob Storage backend is similar to the Amazon S3 backend and provides a remote storage solution for Terraform state files. To configure the Azure Blob Storage backend, you will need to create an Azure Storage Account and a container to store the Terraform state file. This backend supports locking by default, so you won’t need to configure anything else for locking.
Here is an example of how to configure the Azure Blob Storage backend in Terraform:
terraform {
backend "azurerm" {
storage_account_name = "mytfstateaccount"
container_name = "tfstate"
key = "terraform.tfstate"
}
}
Google Cloud Storage Backend
The Google Cloud Storage backend is a popular choice for remote state storage for users of Google Cloud Platform. To configure the Google Cloud Storage backend, you will need to create a bucket and a service account with access to the bucket. Again there is no need to do anything else to enable locking. Here is an example of how to configure the Google Cloud Storage backend in Terraform:
terraform {
backend "gcs" {
bucket = "my-terraform-state-bucket"
prefix = "terraform/state"
credentials = "path/to/credentials.json"
}
}
HTTP Backend
It is a simple backend that can be useful for development or testing, but it is not recommended for production use because it does not provide the same level of security and reliability as other backends.
To configure the HTTP backend, you will need to have access to a web server that can serve files over HTTP or HTTPS. Here is an example of how to configure the HTTP backend in Terraform:
terraform {
backend "http" {
address = "https://my-terraform-state-server.com/terraform.tfstate"
}
}
In this example, the address
parameter specifies the URL of the Terraform state file on the web server. You can use any web server that supports HTTP or HTTPS to host the state file, including popular web servers like Apache or Nginx.
When using the HTTP backend, it is important to ensure that the state file is protected with appropriate access controls and authentication mechanisms. Without proper security measures, the state file could be accessed or modified by unauthorized users, which could lead to security breaches or data loss.
Remote State Data Source
The Terraform Remote State Data Source, like any other data source, retrieves existing information. This special data source, doesn’t depend on any provider, this allows you to retrieve state data from a previously deployed Terraform infrastructure. This can be useful if you need to reference information from one infrastructure to another.
To use a remote state data source in Terraform, you first need to configure the remote state backend for the infrastructure you want to retrieve data from. This is done in the backend
block in your Terraform configuration as specified above. After you create your infrastructure for the first configuration, you can reference it in the second one using the remote data source.
data "terraform_remote_state" "config1" {
backend = "local"
config = {
path = "../config1"
}
}
resource "null_resource" "this" {
provisioner "local-exec" {
command = format("echo %s", data.terraform_remote_state.config1.outputs.var1)
}
}
In the above example, we are supposing that we have a configuration in the directory “../config1” that has some Terraform code up and running. In that code, we have declared a “var1” output, that we are referencing in our null resource.
As mentioned in some of the previous articles, Terraform documentation is your best friend when it comes to understanding what you can do with a resource or data source, or what it exposes.
This data source exposes:
Best Practices
There are several best practices for managing Terraform state, including:
Use a remote backend: Storing the Terraform state file remotely provides several benefits, including better collaboration, easier access control, and improved resilience. Remote backends such as Amazon S3 or HashiCorp Consul can be used to store the state file securely and reliably.
Use locking: When multiple users are working on the same Terraform project, locking is necessary to prevent conflicts. Locking ensures that only one user can modify the state file at a time, preventing conflicts and ensuring changes are applied correctly. As shown before, there are many backends that support locking.
Use versioning: Your configuration should always be versioned, as this will make it easier to achieve an older version of the infrastructure if something goes wrong with the changes you are making.
Isolate state:
Don’t add hundreds of resources in the same state: Making a mistake with one resource can potentially hurt all your infrastructure
Have a single state file per environment: When making changes, it is usually a best practice to first make the change on a lower environment and after that promote it to the higher one
Use Terraform workspaces: Terraform workspaces allow users to manage multiple environments, such as development, staging, and production, with a single Terraform configuration file. Each workspace has its own state file, allowing changes to be made to each of them
5. Use modules: Versioned modules will make it easier to make changes to your code, hence changes will be easier to promote across environments, making operations to the state less painful.
erraform state is a critical component that enables users to define, provision, and manage infrastructure resources using declarative code. Terraform state ensures that resources are created, updated, or destroyed only when necessary and in the correct order. Remote backends such as Amazon S3, Azure Blob Storage, Google Cloud Storage, or HashiCorp Consul can be used to store the state file securely and reliably, while state file locks can prevent conflicts when multiple users are working with the same Terraform configuration. Consistent naming conventions and the terraform state
command can help to ensure that Terraform state files are easy to manage and understand.
By following these best practices for managing Terraform state, users can ensure that their infrastructure resources are managed effectively and efficiently using Terraform.
Originally posted on Medium here.
12. Terraform Depends_on and Lifecycle block
Understanding how to use depends_on
and the lifecycle
block can help you better manage complex infrastructure dependencies and handle resource updates and replacements. In this post, I will provide an overview of what these features are, how they work, and best practices for using them effectively in your Terraform code.
Depends_on
The depends_on
meta-argument in Terraform is used to specify dependencies between resources. When Terraform creates your infrastructure, it automatically determines the order in which to create resources based on their dependencies. However, in some cases, you may need to manually specify the order in which resources are created, and that's where depends_on
comes in.
depends_on
works on all resources, data sources, and also modules. You are going to most likely use depends_on
whenever you are using null_resources
with provisioners
to accomplish some use cases.
Let’s look into one simple example:
resource "kubernetes_namespace" "first" {
metadata {
name = "first"
}
}
resource "kubernetes_namespace" "second" {
depends_on = [
kubernetes_namespace.first
]
metadata {
name = "second"
}
}
Even though these namespaces resources are the same, the one named “second” will get created after the one named “first”.
As the depends_on
argument uses a list, this means that your resources can depend on multiple things before they are getting created.
resource "kubernetes_namespace" "first" {
metadata {
name = "first"
}
}
resource "kubernetes_namespace" "second" {
depends_on = [
kubernetes_namespace.first,
kubernetes_namespace.third
]
metadata {
name = "second"
}
}
resource "kubernetes_namespace" "third" {
metadata {
name = "third"
}
}
# kubernetes_namespace.first: Creating...
# kubernetes_namespace.third: Creating...
# kubernetes_namespace.third: Creation complete after 0s [id=third]
# kubernetes_namespace.first: Creation complete after 0s [id=first]
# kubernetes_namespace.second: Creating...
# kubernetes_namespace.second: Creation complete after 0s [id=second]
As we’ve made the second namespace depend also on the third now, you can see from the above apply output that the first
and third
race to get created first, and after they finished, the second one started the creation process.
What about depends_on
on modules? It works exactly the same:
module "null" {
depends_on = [
resource.null_resource.this
]
source = "./null"
}
resource "null_resource" "this" {
provisioner "local-exec" {
command = "echo this resource"
}
}
# null_resource.this: Creating...
# null_resource.this: Provisioning with 'local-exec'...
# null_resource.this (local-exec): Executing: ["/bin/sh" "-c" "echo this resource"]
# null_resource.this (local-exec): this resource
# null_resource.this: Creation complete after 0s [id=1259986187217330742]
# module.null.null_resource.this: Creating...
# module.null.null_resource.this: Provisioning with 'local-exec'...
# module.null.null_resource.this (local-exec): Executing: ["/bin/sh" "-c" "echo this module"]
# module.null.null_resource.this (local-exec): this module
# module.null.null_resource.this: Creation complete after 0s [id=3893065594330030689]
As you see, the null resource from the module gets created after the other one. And of course, modules can even depend on other modules, but you got the drill.
Do I use depends_on
now? Not that much, but back in the day on Terraform 0.10, whenever there were problems related to dependencies this was used to fix them.
Lifecycle Block
In Terraform, a lifecycle
block is used to define specific behaviors for a resource during its lifecycle. This block is used to manage the lifecycle of a resource in Terraform, including creating, updating, and deleting resources.
The lifecycle block can be added to a resource block and includes the following arguments:
create_before_destroy: When set to true, this argument ensures that a new resource is created before the old one is destroyed. This can help avoid downtime during a resource update.
prevent_destroy: When set to true, this argument prevents a resource from being destroyed. This can be useful when you want to protect important resources from being accidentally deleted.
ignore_changes: This argument specifies certain attributes of a resource that Terraform should ignore when checking for changes. This can be useful when you want to prevent Terraform from unnecessarily updating a resource.
replace_triggered_by: This is relatively new, came up in Terraform 1.2, and it is used to replace a resource if any attributes of that resource have changed, or even other resources have changed. Also, if you use count or for_each on the resource, you can even retrigger the recreation if there is a change to an instance of that resource (using count.index or each.key)
To be honest, I’ve used prevent_destroy
only once or twice, ignore_changes,
and create_before_destroy
a couple of times, and I’ve just found out about replace_triggered_by
as I was writing the article.
Let’s see a lifecycle block in action:
resource "kubernetes_namespace" "first" {
metadata {
name = "first"
labels = {
color = "green"
}
}
lifecycle {
ignore_changes = [
metadata[0].labels
]
}
}
output "namespace_labels" {
value = kubernetes_namespace.first.metadata[0].labels
}
# kubernetes_namespace.first: Creating...
# kubernetes_namespace.first: Creation complete after 0s [id=first]
# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
# Outputs:
# namespace_labels = tomap({
# "color" = "green"
# })
I’ve applied the above code for the first time and created the namespace with the above labels. In the lifecycle block, I’ve added the necessary configuration to ignore changes related to labels.
Now, let’s suppose someone comes in and tries to make a change to the labels:
resource "kubernetes_namespace" "first" {
metadata {
name = "first"
labels = {
color = "blue"
}
}
lifecycle {
ignore_changes = [
metadata[0].labels
]
}
}
output "namespace_labels" {
value = kubernetes_namespace.first.metadata[0].labels
}
# kubernetes_namespace.first: Refreshing state... [id=first]
# No changes. Your infrastructure matches the configuration.
# Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.
# Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
# Outputs:
# namespace_labels = tomap({
# "color" = "green"
# })
I’ve tried to reapply the code, but Terraform detects there is no change required due to the fact that we have the lifecycle block in action.
Let’s add to the lifecycle block a create_before_destroy
option. With this option enabled it doesn’t mean that you won’t ever be able to destroy the resource, but whenever there is a change that dictates the resource has to be recreated, it will first create the new resource and after that delete the existing one.
With the namespace resource already created, I’ve changed its name to induce a breaking change:
# kubernetes_namespace.first must be replaced
+/- resource "kubernetes_namespace" "first" {
~ id = "first" -> (known after apply)
~ metadata {
- annotations = {} -> null
~ generation = 0 -> (known after apply)
~ name = "first" -> "second" # forces replacement
~ resource_version = "816398" -> (known after apply)
~ uid = "684f7401-1554-46fb-b21f-8e49329e76fa" -> (known after apply)
# (1 unchanged attribute hidden)
}
}
Plan: 1 to add, 0 to change, 1 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
kubernetes_namespace.first: Creating...
kubernetes_namespace.first: Creation complete after 0s [id=second]
kubernetes_namespace.first (deposed object 725799ef): Destroying... [id=first]
kubernetes_namespace.first: Destruction complete after 6s
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
Outputs:
namespace_labels = tomap({
"color" = "blue"
})
As you can see from the Terraform apply output, another resource was created first and after that, the old one was deleted.
Overall, depends_on
and the lifecycle
block can help you in some edge-case situations you may get in with your IaC. I don’t really use them frequently, but sometimes they are a necessary evil.
Orginally posted on Medium here.
13. Dynamic Blocks
Dynamic Blocks in Terraform let you repeat configuration blocks inside of a resource based on a variable/local/expression that you are using inside of them. They make your configuration DRY (Don’t Repeat Yourself).
Oh, I remember the days before they introduced this feature. I was working for Oracle at the time and was in charge of building reusable modules for our cloud components. Let’s get this straight, you CANNOT truly build reusable modules without dynamic blocks. It really is impossible, it’s like you would say that humans can breathe underwater without any equipment.
How Dynamic Blocks work
In a dynamic block, you can use the following parameters:
for_each (required)
→ iterates over the value you are providingcontent (required)
→ block containing the body of each block that you are going to createiterator (optional)
→ temporary variable used as an iteratorlabels (optional)
→ list of strings that define the block labels. Never used them, tbh.
You can have nested dynamic blocks, or you can use dynamic blocks to avoid generating an optional block inside configurations.
Let’s take a look at the following example:
resource "helm_release" "example" {
name = "my-redis-release"
repository = "https://charts.bitnami.com/bitnami"
chart = "redis"
set {
name = "cluster.enabled"
value = "true"
}
set {
name = "metrics.enabled"
value = "true"
}
set {
name = "service.annotations.prometheus\\.io/port"
value = "9127"
type = "string"
}
}
The above block is just a simplified version taken out of Terraform’s documentation and as you can see it repeats the set block three times. This set block, for a helm release, is just used to add custom values to be merged with the yaml values file.
Let’s see how we can rewrite this using dynamic blocks:
locals {
set = {
"cluster.enabled" = {
value = true
}
"metrics.enabled" = {
value = true
}
"service.annotations.prometheus\\.io/port" = {
value = "9127"
type = "string"
}
}
}
resource "helm_release" "example" {
name = "my-redis-release"
repository = "https://charts.bitnami.com/bitnami"
chart = "redis"
values = []
dynamic "set" {
for_each = local.set
content {
name = set.key
value = set.value.value
type = lookup(set.value, "type", null)
}
}
}
The above code accomplishes the same thing, as the one that was repeating the blocks three times. Since we are using the for_each on the local variable, we are going to create the block three times.
When you are not defining an iterator, your iterator name will be exactly the name of the block, in our case is set
.
Let’s use an iterator to make this clear. The dynamic block will change to:
dynamic "set" {
for_each = local.set
iterator = i
content {
name = i.key
value = i.value.value
type = lookup(i.value, "type", null)
}
}
As mentioned before, you can use dynamic blocks to avoid generating blocks altogether, so to achieve this in our example, what we can do is just make the local variable empty:
locals {
set = {}
}
resource "helm_release" "example" {
name = "my-redis-release"
repository = "https://charts.bitnami.com/bitnami"
chart = "redis"
values = []
dynamic "set" {
for_each = local.set
iterator = i
content {
name = i.key
value = i.value.value
type = lookup(i.value, "type", null)
}
}
}
The OCI Security List Problem
A security list in Oracle Cloud Infrastructure is pretty similar to a network access control list in AWS. When I started building the initial network module for OCI, security groups were not available so the only way to restrict network-related rules was to use security lists. I was using Terraform 0.11 and in that version, there weren’t that many features available (no dynamic blocks, no for_each) so it was pretty hard to build a reusable module.
Because security list rules were embedded inside the security list resource as blocks, it was impossible to make something generic out of them.
So basically, there was no real module built, before dynamic blocks, but after they were released, this bad boy was created:
resource "oci_core_security_list" "this" {
for_each = var.sl_params
compartment_id = oci_core_virtual_network.this[each.value.vcn_name].compartment_id
vcn_id = oci_core_virtual_network.this[each.value.vcn_name].id
display_name = each.value.display_name
dynamic "egress_security_rules" {
iterator = egress_rules
for_each = each.value.egress_rules
content {
stateless = egress_rules.value.stateless
protocol = egress_rules.value.protocol
destination = egress_rules.value.destination
}
}
dynamic "ingress_security_rules" {
iterator = ingress_rules
for_each = each.value.ingress_rules
content {
stateless = ingress_rules.value.stateless
protocol = ingress_rules.value.protocol
source = ingress_rules.value.source
source_type = ingress_rules.value.source_type
dynamic "tcp_options" {
iterator = tcp_options
for_each = (lookup(ingress_rules.value, "tcp_options", null) != null) ? ingress_rules.value.tcp_options : []
content {
max = tcp_options.value.max
min = tcp_options.value.min
}
}
dynamic "udp_options" {
iterator = udp_options
for_each = (lookup(ingress_rules.value, "udp_options", null) != null) ? ingress_rules.value.udp_options : []
content {
max = udp_options.value.max
min = udp_options.value.min
}
}
}
}
}
As you can see, in this one, I’m also using nested dynamics, but because I’m using lookups, you don’t even need to specify the “tcp_options” or “udp_options” inside the “ingress_rules” if you don’t want to specify them for one of your rules. This could’ve been done more elegantly using optionals
.
Keep in mind this was developed more than 2.5 years ago, back in my Oracle days.
You can also check the OCI Adoption framework Thunder, for more Oracle Cloud modules, but again, this is more than 2.5 years old so it will need an update.
I’m also planning to revive it and update the modules to the latest features available, so send me a message if you want to take part in this revamp.
Dynamic blocks offer a powerful way to manage complex infrastructure resources in Terraform and have become a key feature of the tool since their introduction in version 0.12. By enabling you to generate blocks dynamically based on input data, dynamic blocks make it easier to automate infrastructure management and eliminate manual processes, making Terraform an even more powerful and flexible infrastructure-as-code tool.
Originally posted on Medium here.
14. Terraform Modules
Terraform modules are one of the most important features that Terraform has to offer. They make your code reusable, can be easily versioned and shared with others, and act as blueprints for your infrastructure.
In this post, I am just going to scratch the surface of Terraform modules, I want to go into more detail in the last two articles of this series.
I like to think of Terraform modules as you would think about an Object Oriented Programming Class. In OOP, you define a class and after that, you can create multiple objects out of it. The same goes for Terraform modules, you define the module, and after that, you can reuse it as many times as you want.
Why should you use modules?
There are several reasons to use Terraform modules in your IaC projects:
Code Reusability: Modules help you avoid duplicating code by allowing you to reuse configurations across multiple environments or projects. This makes your infrastructure code more maintainable and easier to update.
Separation of Concerns: Modules enable you to separate your infrastructure into smaller, more focused units. This results in cleaner, more organized code that is easier to understand and manage.
Versioning: I cannot stress the importance of versioning enough. Modules support versioning and can be shared across teams, making it easier to collaborate and maintain consistency across your organization’s infrastructure.
Simplified Configuration: By encapsulating complexity within modules, you can simplify your root configurations, making them easier to read and understand.
Faster Development: With modules, you can break down your infrastructure into smaller, reusable components. This modular approach accelerates development, as you can quickly build upon existing modules rather than starting from scratch for each new resource or environment.
Scalability: Modules enable you to build scalable infrastructure by allowing you to replicate resources or environments easily. By reusing modules, you can ensure that your infrastructure remains consistent and manageable even as it grows in size and complexity.
Minimal module structure
A typical module should contain the following files:
main.tf
: Contains the core resource declarations and configurations for the module.variables.tf
: Defines input variables that allow users to customize the module's behavior.outputs.tf
: Specifies output values that the module returns to the caller, providing information about the created resources.README.md
: Offers documentation on how to use the module, including descriptions of input variables and outputs.
What I also like to do, when I’m building modules, is to create examples for those modules.
So in each module, what I typically do, is create an examples folder in which I define at least a main.tf
in which I create an object for that module.
For the Readme file, I usually leverage terraform-docs
to get the documentation automated, but nevertheless, I also explain what the module does, how to leverage the examples and deep dive into why I took some decisions related to the code.
Module Example
Let’s create a simple module for generating config maps in Kubernetes.
# Module main code
resource "kubernetes_namespace" "this" {
for_each = var.namespaces
metadata {
name = each.key
labels = each.value.labels
annotations = each.value.annotations
}
}
resource "kubernetes_config_map" "this" {
for_each = var.config_maps
metadata {
name = each.key
namespace = each.value.use_existing_namespace ? each.value.namespace : kubernetes_namespace.this[each.value.namespace].metadata[0].name
labels = each.value.labels
annotations = each.value.annotations
}
data = each.value.data
binary_data = each.value.binary_data
}
# Module Variable code
variable "namespaces" {
description = "Namespaces parameters"
type = map(object({
labels = optional(map(string), {})
annotations = optional(map(string), {})
}))
default = {}
}
variable "config_maps" {
description = "Config map parameters"
type = map(object({
namespace = string
labels = optional(map(string), {})
annotations = optional(map(string), {})
use_existing_namespace = optional(bool, false)
data = optional(map(string), {})
binary_data = optional(map(string), {})
}))
}
# Module outputs code
output "config_maps" {
description = "Config map outputs"
value = { for cfm in kubernetes_config_map.this : cfm.metadata[0].name => { "namespace" : cfm.metadata[0].namespace, "data" : cfm.data } }
}
The above module code will create how many namespaces and config maps you want in your Kubernetes cluster. You can even create your config maps in existing namespaces, as you are not required to create namespaces if you don’t want to.
I’m taking advantage of optionals, to avoid passing parameters in some of the cases.
# example main.tf code
provider "kubernetes" {
config_path = "~/.kube/config"
}
module "config_maps" {
source = "../"
namespaces = {
ns1 = {
labels = {
color = "green"
}
}
}
config_maps = {
cf1 = {
namespace = "ns1"
data = {
api_host = "myhost:443"
db_host = "dbhost:5432"
}
}
}
}
# example outputs.tf code
output "config_maps" {
description = "Config map outputs"
value = module.config_maps.config_maps
}
In the above example, I am creating one namespace and one config map into that namespace and I am also outputting the config maps.
Example terraform apply
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
module.config_maps.kubernetes_namespace.this["ns1"]: Creating...
module.config_maps.kubernetes_namespace.this["ns1"]: Creation complete after 0s [id=ns1]
module.config_maps.kubernetes_config_map.this["cf1"]: Creating...
module.config_maps.kubernetes_config_map.this["cf1"]: Creation complete after 0s [id=ns1/cf1]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
config_maps = {
"cf1" = {
"data" = tomap({
"api_host" = "myhost:443"
"db_host" = "dbhost:5432"
})
"namespace" = "ns1"
}
}
You can check out the repository and also the generated Readme.md file here.
Publishing Modules
You can easily publish your modules to a Terraform registry. If you want to share your module with the community, you can easily leverage Terraform’s Public Registry.
However, if you have an account with a sophisticated deployment tool such as Spacelift, you can take advantage of the private registry it offers and take advantage of the built-in testing capabilities.
Minimal Best Practices
If you are just starting to build Terraform module, take into consideration the following best practices:
Keep modules focused: Each module should have a specific purpose and manage a single responsibility. Avoid creating overly complex modules that manage multiple unrelated resources.
Use descriptive naming: Choose clear and descriptive names for your modules, variables, and outputs. This makes it easier for others to understand and use your module.
Document your modules: Include a
README.md
file in each module that provides clear instructions on how to use the module, input variables, and output values. In addition to this, use comments in your Terraform code to explain complex or non-obvious code.Version your modules: Use version tags to track changes to your modules and reference specific versions in your configurations. This ensures that you’re using a known and tested version of your module, and it makes it easier to roll back to a previous version if needed.
Test your modules: Write and maintain tests for your modules to ensure they work as expected. The
terraform validate
andterraform plan
commands can help you identify configuration errors, while other tools like Spacelift’s built-in module testing or Terratest can help you write automated tests for your modules.
Terraform modules are a powerful way to streamline your infrastructure management, making your IaC more reusable, maintainable, and shareable. By following best practices and leveraging the power of modules, you can improve your DevOps workflow and accelerate your infrastructure deployments.
Originally posted on Medium here.
15. Best practices for modules I
In order to build reusable Terraform modules that can be easily leveraged to achieve almost any architecture, I believe that at least the following best practices should be put in place:
Each Terraform module should exist in its own repository
Use for_each and map variables
Use dynamic blocks
Use ternary operators and take advantage of terraform built-in functions, especially lookup, merge, try, can
Build outputs
Optional: Use pre-commit
Terraform module in its own repository
You may wonder why I am suggesting that every terraform module should be in its own repository.
Well, doing so, will most definitely help you, when you are developing new features to that module because you won’t need to do big changes in a lot of places.
In addition to this, creating/updating repositories with new features of that module will occur really fast, and you will still have the possibility to keep the older versions in place for other automation that you are building.
You can easily reference a git source/registry source when you want to use a module, but tagging is essential.
E.G. Referencing a Github Terraform Module:
module "example" {
source = "git@github.com:organisation(user)/repo.git?ref=tag/branch"
}
When it comes to tagging, my recommendation is to use the following tag format: vMajor.Minor.Patch.
Use for_each and map variables
I didn’t specify count, did I?
Using count on your resources will cause more problems than offering help in the end. I’m not talking about checking if a variable is set to true and if it is, set the count to 1, else set it to 0 (not a big fan of this approach, either), I’m talking about creating multiple resources based on a list variable.
Let’s suppose you want to create 3 ec2 instances in AWS using Terraform, each of them with different images, different types, and different azs, similar to the code below.
locals {
vm_instances = [
{
ami = "ami-1"
az = "eu-west-1a"
instance_type = "t2.micro"
},
{
ami = "ami-2"
az = "eu-west-1b"
instance_type = "t3.micro"
},
{
ami = "ami-3"
az = "eu-west-1c"
instance_type = "t3.small"
}
]
}resource "aws_instance" "this" {
count = length(local.vm_instances)
ami = local.vm_instances[count.index].ami
availability_zone = local.vm_instances[count.index].az
instance_type = local.vm_instances[count.index].instance_type
}
The code is working properly, all of your 3 instances are created and everything is looking smooth after you apply it.
Let’s suppose, for some reason, you want to delete the second instance. What is going to happen to the third one? It will be recreated because that’s how a list variable works if you remember from the 7th article in this series. When you are removing the second instance (index 1), the third instance (index 2) will change its index to 1.
This is just a simple example, but how about a case in which you had 20 instances, and for whatever reason you needed the remove the first one in the list? All the other 19 instances would be recreated and that downtime could really affect you.
By leveraging for_each you can avoid this pain, and due to the fact that it exposes an each.key and each.value when you are using maps, this will greatly help you achieve almost any architecture you desire.
locals {
vm_instances = {
vm1 = {
ami = "ami-1"
az = "eu-west-1a"
instance_type = "t2.micro"
}
vm2 = {
ami = "ami-2"
az = "eu-west-1b"
instance_type = "t3.micro"
}
vm3 = {
ami = "ami-3"
az = "eu-west-1c"
instance_type = "t3.small"
}
}
}resource "aws_instance" "this" {
for_each = local.vm_instances
ami = each.value.ami
availability_zone = each.value.az
instance_type = each.value.instance_type
}
The code is pretty similar but it is now using a map variable instead of a list one, and by removing an instance now, the others are not going to be affected in any way. In the above example, the keys are vm1, vm2, and vm3 and the values are the ones that are after the equal sign.
By using the for_each approach, you have the possibility of creating the resources in any way you want, reaching a more generic state and accommodating many use cases.
Keep in mind that both examples from above are not an actual representation of modules, they are just used to point out the differences between for_each and count.
Use dynamic blocks
I remember when dynamic blocks weren’t a thing in Terraform, and I had to build a module for security lists in Oracle Cloud.
The problem was that security list rules were blocks inside of the security list resource itself, so whatever we would’ve done, that module, in the end, wasn’t very reusable.
One can argue that we could’ve prepared some jinja2 templates, had a script in place that would render those based on an input, but still, in my opinion, that isn’t a reusable terraform module.
Using dynamic blocks, you can easily repeat a block inside of a resource how many times you want based on the input variable, and that most certainly was a game changer at the time of the release.
The good part is that you can have a dynamic block in place, even when you don’t want to create that block at all. In some architectures, you will need a feature enabled many times, but in others, you won’t need that feature at all. Why build two separate modules for that when you can have only one, right?
Use Ternary operators and Terraform functions
Terraform functions complicate the code. That is most certainly a fact and if somebody doesn’t have that much experience with Terraform they will have a hard time reading the code.
Nevertheless, when you are building reusable modules, you will most likely encounter the need of having some conditions inside of your code. You will see there are some arguments that cannot exist with other ones, so for your module to accommodate both use cases, you will most likely need to leverage a ternary operator with a lookup on the variable.
Build Outputs
Inside of a module, an output is literally what that module is exposing for other modules to use.
Make sure that whenever you are building a module, you are exposing the component that can be reused by other components (e.g. subnet ids, because they may be used by vms).
By using for_each, exposing an output will become harder, but don’t worry, you will get the hang of it pretty fast.
output "kube_params" {
value = { for kube in azurerm_kubernetes_cluster.this : kube.name => { "id" : kube.id, "fqdn" : kube.fqdn } }
}
In the above example, I am exposing some azure kubernetes cluster outputs, that I consider relevant. Due to the fact that my resource has a for_each on it, I have to cycle through all of the resources of that kind to be able to access the arguments. I am creating a map variable with a name key that underneath will have an id and fqdn as part of its values.
Terraform documentation helps you a lot in knowing what a resource can export so you will only need to go to the documentation page of that particular resource.
Using Pre-commit
Pre-commit can easily facilitate your terraform development and can make sure that your code respects the standard imposed by your team before you are pushing the code to the repository.
By using pre-commit, you can easily make sure that:
your terraform code is valid
linting was done properly
documentation for all your parameters has been written
There are other things that can be done, so you should check the documentation for the following:
In order to take advantage of pre-commit, you should define a file called .pre-commit-config.yaml inside of your repository with content similar to this:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.72.2
hooks:
- id: terraform_fmt
- id: terraform_tflint
- id: terraform_docs
- id: terraform_validate
Of course, many other hooks can be added, but in the example from above, we are using just the essentials.
You should have pre-commit installed locally because as its name states, you should run it prior to making a commit. When you are doing so, all of the problems related to your code will be mentioned and some of them will even be fixed.
Originally posted on Medium here.
16. Best practices for modules II
In this last post of this series, I am going to show you how I build reusable Terraform modules by respecting the best practices.
What I’m going to build is:
A Kubernetes Module for Azure (AKS)
A Helm Module that will be able to deploy helm charts inside of Kubernetes clusters
A repository that leverages the two modules in order to deploy a Helm chart inside of AKS
Prerequisites
In order to follow this tutorial yourself, you are going to need some experience with Terraform, Kubernetes, Helm, pre-commit, and git.
I would suggest installing all of them by following the instructions present in the tools documentation:
A text editor of you choice (I’m using Visual Studio Code, but feel free to use any editor you like)
You should have an Azure account. In order to create a free one you can go here.
AKS Module
I will first start by leveraging my terraform-module-template and create a repository from it, by clicking on Use this template.
After this, I just cloned my repository, and now I’m prepared to start the development.
By using the above template, I have the following folder structure for the Terraform code:
.
├── README.md # Module documentation
├── example # One working example based on the module
│ ├── main.tf # Main code for the example
│ ├── outputs.tf # Example outputs
│ └── variables.tf # Example variables
├── main.tf # Main code for the module
├── outputs.tf # Outputs of the module
├── provider.tf # Required providers for the modules
└── variables.tf # Variables of the module
The starting point of the development will be the root main.tf file.
Let’s first understand what we have to build. To do that, Terraform documentation is our best friend.
I navigated to the resource documentation and from the start, I looked at an example to understand the minimum parameters I require in order to build the module.
As I just want to build a simple module for demo purposes and without too many fancy features, I will just get the absolute necessary parameters. In the real world, this is not going to happen, so make sure you give a thorough reading of the Argument Reference of the resource page.
resource "azurerm_kubernetes_cluster" "this" {
for_each = var.kube_params
name = each.value.name
location = each.value.rg_location
resource_group_name = each.value.rg_name
dns_prefix = each.value.dns_prefix
I’ve started pretty small, just adding some essential parameters that are required to create a cluster.
A variable called kube_params was defined in order to add all of our cluster related parameters. All the parameters from above will be mandatory in our module, due to the fact that I’ve defined them with each.value.something, where something can be whatever you want (just add something that makes sense for that particular parameter).
I’ve continued to add some mandatory blocks inside of this resource:
default_node_pool {
enable_auto_scaling = each.value.enable_auto_scaling
max_count = each.value.enable_auto_scaling == true ? each.value.max_count : null
min_count = each.value.enable_auto_scaling == true ? each.value.min_count : null
node_count = each.value.node_count
vm_size = each.value.vm_size
name = each.value.np_name
}
dynamic "service_principal" {
for_each = each.value.service_principal
content {
client_id = service_principal.value.client_id
client_secret = service_principal.value.client_secret
}
}
dynamic "identity" {
for_each = each.value.identity
content {
type = identity.value.type
identity_ids = identity.value.identity_ids
}
}
tags = merge(var.tags, each.value.tags)
}
In the above example, you are going to see many of the best practices that I’ve mentioned in my previous post: the use of dynamic blocks, ternary operators, and functions.
For the dynamic blocks, by default, I am not going to create any block if the parameter is not present.
For the tags, I’ve noticed that in many cases, companies want to be able to add some global tags to resources and, of course, individual tags for a particular resource. In order to accommodate that, I’m simply merging two map variables: one that exists inside of kube_params and another one that will exist in the tags var.
As some may want to export the kubeconfig of the cluster after it is created, I’ve provided that option also:
resource "local_file" "kube_config" {
for_each = { for k, v in var.kube_params : k => v if v.export_kube_config == true }
filename = each.value.kubeconfig_path
content = azurerm_kubernetes_cluster.this[each.key].kube_config_raw
}
To export the kubeconfig, you will simply need to have a parameter in kube_params called export_kube_config set to true.
This is how my main.tf file looks like in the end:
resource "azurerm_kubernetes_cluster" "this" {
for_each = var.kube_params
name = each.value.name
location = each.value.rg_location
resource_group_name = each.value.rg_name
dns_prefix = each.value.dns_prefix
default_node_pool {
enable_auto_scaling = each.value.enable_auto_scaling
max_count = each.value.enable_auto_scaling == true ? each.value.max_count : null
min_count = each.value.enable_auto_scaling == true ? each.value.min_count : null
node_count = each.value.node_count
vm_size = each.value.vm_size
name = each.value.np_name
}
dynamic "service_principal" {
for_each = each.value.service_principal
content {
client_id = service_principal.value.client_id
client_secret = service_principal.value.client_secret
}
}
dynamic "identity" {
for_each = each.value.identity
content {
type = identity.value.type
identity_ids = identity.value.identity_ids
}
}
tags = merge(var.tags, each.value.tags)
}
resource "local_file" "kube_config" {
for_each = { for k, v in var.kube_params : k => v if v.export_kube_config == true }
filename = each.value.kubeconfig_path
content = azurerm_kubernetes_cluster.this[each.key].kube_config_raw
}
For the variables.tf file, I just defined the kube_params and tags variables as they are the only vars that I’ve used inside of my module.
variable "kube_params" {
type = map(object({
name = string
rg_name = string
rg_location = string
dns_prefix = string
client_id = optional(string, null)
client_secret = optional(string, null)
vm_size = optional(string, "Standard_DS2_v2")
enable_auto_scaling = optional(string, true)
max_count = optional(number, 1)
min_count = optional(number, 1)
node_count = optional(number, 1)
np_name = string
service_principal = optional(list(object({
client_id = optional(string, null)
client_secret = optional(string, null)
})), [])
identity = optional(list(object({
type = optional(string, "SystemAssigned")
identity_ids = optional(list(string), [])
})), [])
kubeconfig_path = optional(string, "~./kube/config")
}))
description = "AKS params"
}
variable "tags" {
type = map(string)
description = "Global tags to apply to the resources"
default = {}
}
With the release of optional, types, I’ve not used any type for the variables anymore, because right now we can omit some of the parameters by using the optional function at the variable level. More about that here.
output "kube_params" {
value = { for kube in azurerm_kubernetes_cluster.this : kube.name => { "id" : kube.id, "fqdn" : kube.fqdn } }
}
output "kube_config" {
value = { for kube in azurerm_kubernetes_cluster.this : kube.name => nonsensitive(kube.kube_config) }
}
output "kube_config_path" {
value = { for k, v in local_file.kube_config : k => v.filename }
}
I’ve defined three outputs, one for the most relevant parameters of the Kubernetes cluster and two for the kubeconfig. The ones related to kubeconfig are giving users the possibility to login into the cluster by using the kubeconfig file or the Kubernetes user/password/certificates combination.
You can use functions inside of the outputs and you are encouraged to do so, to accommodate your use case.
The module code is now ready, but you should at least have an example inside of it to show users how can they run the code.
So in the examples folder, in the main.tf file, I’ve added the following code:
provider "azurerm" {
features {}
}
module "aks" {
source = "../"
kube_params = {
kube1 = {
name = "kube1"
rg_name = "rg1"
rg_location = "westeurope"
dns_prefix = "kube"
identity = [{}]
enable_auto_scaling = false
node_count = 1
np_name = "kube1"
export_kube_config = true
}
}
}
I’ve added the provider configuration and called the module with some of the essential parameters. You can add a variable to the kube_params and provide values in the variable directly or in a tfvars file or even in an yaml file if you want.
In the above example, you are creating only one kubernetes cluster, if you want more, just copy & paste de kube1 block and add whatever parameters you want, based on the module similar to this:
provider "azurerm" {
features {}
}
module "aks" {
source = "../"
kube_params = {
kube1 = {
name = "kube1"
rg_name = "rg1"
rg_location = "westeurope"
dns_prefix = "kube"
identity = [{}]
enable_auto_scaling = false
node_count = 1
np_name = "kube1"
export_kube_config = true
}
kube2 = {
name = "kube2"
rg_name = "rg1"
rg_location = "westeurope"
dns_prefix = "kube"
identity = [{}]
enable_auto_scaling = false
node_count = 4
np_name = "kube2"
export_kube_config = false
}
kube3 = {
name = "kube3"
rg_name = "rg1"
rg_location = "westeurope"
dns_prefix = "kuber"
identity = [{}]
enable_auto_scaling = true
max_count = 4
min_count = 2
node_count = 2
np_name = "kube3"
export_kube_config = false
}
}
}
I’ve added 3 just for reference, you have full control related to the number of clusters you want to create.
Ok, let’s get back to the initial example. Before running the code you should first login to Azure:
az login
You may think we are done, right? Well, not yet, because I like to run pre-commit with all the goodies on my code. I am using the same pre-commit file I mentioned in the previous post so before adding my code, I’m going to run:
pre-commit run --all-files
Before running this command, I made sure that in my README.md I had the following lines:
<!-- BEGINNING OF PRE-COMMIT-TERRAFORM DOCS HOOK -->
<!-- END OF PRE-COMMIT-TERRAFORM DOCS HOOK -->
Between these lines, tfdocs will populate the documentation based on your resources, variables, outputs, and provider.
This is what the README looks like after running the pre-commit command:
Now, this module is done, I’ve pushed all my changes and a new tag was created automatically in my repository (I will discuss about the pipelines I am using for that in another post).
Helm Release Module
All the steps are the same as for the other module, first we have to understand what we have to build by reading the documentation and understanding the parameters.
I’ve created a new repository using the same template and I’ve started the development per se. As I don’t want to repeat all the steps from above in this post, I will show you just the end result of the main.tf file for this module.
resource "helm_release" "this" {
for_each = var.helm
name = each.value.name
chart = each.value.chart
repository = each.value.repository
version = each.value.version
namespace = each.value.namespace
create_namespace = each.value.create_namespace ? true : false
values = [for yaml_file in each.value.values : file(yaml_file)]
dynamic "set" {
for_each = each.value.set
content {
name = set.value.name
value = set.value.value
type = set.value.type
}
}
dynamic "set_sensitive" {
for_each = each.value.set_sensitive
content {
name = set_sensitive.value.name
value = set_sensitive.value.value
type = set_sensitive.value.type
}
}
}
This module is also pushed, tagged, and good to go.
Automation based on the Two Modules
I’ve created a third repository, but this time from scratch in which I will use the two modules that were built throughout this post.
provider "azurerm" {
features {}
}
module "aks" {
source = "git@github.com:flavius-dinu/terraform-az-aks.git?ref=v1.0.3"
kube_params = {
kube1 = {
name = "kube1"
rg_name = "rg1"
rg_location = "westeurope"
dns_prefix = "kube"
identity = [{}]
enable_auto_scaling = false
node_count = 1
np_name = "kube1"
export_kube_config = true
kubeconfig_path = "./config"
}
}
}
provider "helm" {
kubernetes {
config_path = module.aks.kube_config_path["kube1"]
}
}
# Alternative way of declaring the provider
# provider "helm" {
# kubernetes {
# host = module.aks.kube_config["kube1"].0.host
# username = module.aks.kube_config["kube1"].0.username
# password = module.aks.kube_config["kube1"].0.password
# client_certificate = base64decode(module.aks.kube_config["kube1"].0.client_certificate)
# client_key = base64decode(module.aks.kube_config["kube1"].0.client_key)
# cluster_ca_certificate = base64decode(module.aks.kube_config["kube1"].0.cluster_ca_certificate)
# }
# }
module "helm" {
source = "git@github.com:flavius-dinu/terraform-helm-release.git?ref=v1.0.0"
helm = {
argo = {
name = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
create_namespace = true
namespace = "argocd"
}
}
}
So, in my repository, I am creating one Kubernetes cluster, in which I am deploying an ArgoCD helm chart. Due to the fact that the helm provider in both cases (commented and uncommented code) has an explicit dependency on module.aks.something it will first wait for the cluster to be ready and after that, the helm chart will be deployed on it.
I’ve kept the commented part in the gist because I wanted to showcase the fact that you are able to connect to the cluster using two different outputs, and both solutions are working just fine.
You should use a remote state if you are collaborating or using the code in a production environment. I haven’t done that for this example, because it is just a simple demo.
Now the automation is done, so what we can do is:
terraform init
terraform plan
terraform apply
Both components are created successfully and if you login to your AKS cluster, you are going to see the ArgoCD related resources by running:
kubectl get all -n argocd
Repository Links
Useful Documentation
If you need some help to better understand some of the concepts that I’ve used throughout this post, I’ve put together a list of useful articles and component documentation:
For Each
Dynamic blocks
Originally posted on Medium.
17. Bonus1 — OpenTofu features
In this part we will cover some OpenTofu features that Terraform doesn’t have.
So here are the standout features:
State encryption — built-in state encryption, ensuring enhanced security for sensitive infrastructure data. Unlike Terraform, which relies on third-party tools or external state backends for encryption, OpenTofu provides this feature natively, allowing users to secure their state files directly within the platform.
Support for
.tofu
Files — use of.tofu
file extensions, which can override standard.tf
files. This feature gives users greater flexibility in managing configurations, enabling easy application of specific overrides or conditional configurations. This functionality isn’t available in Terraform, which relies solely on.tf
files.for_each
in Provider Configuration Blocks — making it possible to dynamically create multiple instances of a provider configuration. This feature allows each resource instance to select a different provider configuration based on a list or map, ideal for infrastructure duplication across regions. Terraform does not support this feature in provider configurations.-exclude
Planning Option —allows users to specify objects to skip during planning and application, providing finer control over infrastructure management. Unlike Terraform’s-target
option, which only targets specific resources, the-exclude
option allows selective exclusion of specified objects and their dependencies. This capability is particularly useful for managing large, complex setups.
18. Bonus2 — Specialized Infrastructure Orchestration Platform
Now that you know how to use Terraform and OpenTofu, there are many questions that will pass through your mind:
How can I manage it at scale?
How do I enable collaboration?
How can I enforce my infrastructure process?
How can I ensure that I stay safe all the way?
How can I enable self-service?
How can I easily combine Terraform/OpenTofu with other tools that I’m using such as Ansible or Kubernetes?
How can I include security vulnerability scanning tools in my workflow?
How can I ensure that I still deliver fast?
How can I protect myself from infrastructure drift?
How can I detect and remediate drift?
How can I take advantage of a private registry?
You may have other questions as well, and to all of them you can use the same answer — “by leveraging an infrastructure orchestration platform”.
Spacelift is an infrastructure management platform that helps you with all of the above, and more. To understand more how you can leverage it, check out this article.