Terraform HCL Intro 6: Nested Loops
In this post, we’ll take on nested loops with Terraform. Terraform is declarative, so a nested loop can be tricky. This post hopes to help with that.
Previous Posts Review
We’ve covered loops fundamentals in the previous two blog posts:
We’re building on top of those learnings, so if you have not read those posts yet, it’ll be helpful to go back and understand those posts.
Resource Loop Starter Code
First, we’ll create 2 security groups with a for_each
loop at the resource-level using what we learned from: Terraform Intro 4: Loops with Count and For Each.
locals {
names = ["demo-example-1", "demo-example-2"]
}
resource "aws_security_group" "names" {
for_each = toset(local.names)
name = each.value # key and value is the same for sets
description = each.value
}
output "security_groups" {
value = aws_security_group.names
}
Expand to see terraform apply results.
$ terraform apply
Outputs:
security_groups = {
"demo-example-1" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05cec80cee397e0ef"
"description" = "demo-example-1"
"egress" = []
"id" = "sg-05cec80cee397e0ef"
"ingress" = []
"name" = "demo-example-1"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"tags" = {}
"vpc_id" = "vpc-11111111"
}
"demo-example-2" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0b6bd59983e8b3389"
"description" = "demo-example-2"
"egress" = []
"id" = "sg-0b6bd59983e8b3389"
"ingress" = []
"name" = "demo-example-2"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"tags" = {}
"vpc_id" = "vpc-11111111"
}
}
Dynamic Nested Block
Now, let’s “naively” add a dynamic nested block configuration using what we learned from: Terraform Intro 5: Loops with Dynamic Block.
locals {
names = ["demo-example-1", "demo-example-2"]
}
locals {
ports = [80, 81]
}
resource "aws_security_group" "names" {
for_each = toset(local.names)
name = each.value # key and value is the same for sets
description = each.value
dynamic "ingress" {
for_each = local.ports
content {
description = "description ${ingress.key}"
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
output "security_groups" {
value = aws_security_group.names
}
Expand to see terraform apply results.
$ terraform apply
Outputs:
security_groups = {
"demo-example-1" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05cec80cee397e0ef"
"description" = "demo-example-1"
"egress" = []
"id" = "sg-05cec80cee397e0ef"
"ingress" = [
{
"cidr_blocks" = [
"0.0.0.0/0",
]
"description" = "description 0"
"from_port" = 80
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 80
},
{
"cidr_blocks" = [
"0.0.0.0/0",
]
"description" = "description 1"
"from_port" = 81
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 81
},
]
"name" = "demo-example-1"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"tags" = {}
"vpc_id" = "vpc-11111111"
}
"demo-example-2" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0b6bd59983e8b3389"
"description" = "demo-example-2"
"egress" = []
"id" = "sg-0b6bd59983e8b3389"
"ingress" = [
{
"cidr_blocks" = [
"0.0.0.0/0",
]
"description" = "description 0"
"from_port" = 80
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 80
},
{
"cidr_blocks" = [
"0.0.0.0/0",
]
"description" = "description 1"
"from_port" = 81
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 81
},
]
"name" = "demo-example-2"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"tags" = {}
"vpc_id" = "vpc-11111111"
}
}
We did is “naive” because currently, the dynamic nested block has the same ingress security rules for every security group. IE: cidr_blocks = ["0.0.0.0/0"]
. That’s not very useful.
Combining the Nested Loops Properly
The key to a nested loop is having the proper data structure. Let’s combine and move the ingress rules into the primary data structure, the security groups themselves.
locals {
groups = {
example0 = {
description = "sg description 0"
rules = [{
description = "rule description 0",
port = 80,
cidr_blocks = ["10.0.0.0/16"],
},{
description = "rule description 1",
port = 81,
cidr_blocks = ["10.1.0.0/16"],
}]
},
example1 = {
description = "sg description 1"
rules = [{
description = "rule description 0",
port = 80,
cidr_blocks = ["10.2.0.0/16"],
},{
description = "rule description 1",
port = 81,
cidr_blocks = ["10.3.0.0/16"],
}]
}
}
}
resource "aws_security_group" "this" {
for_each = local.groups
name = each.key # top-level key is security group name
description = each.value.description
dynamic "ingress" {
for_each = each.value.rules # List of Maps with rule attributes
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
}
output "security_groups" {
value = aws_security_group.this
}
Expand to see terraform apply results.
$ terraform apply
Outputs:
security_groups = {
"example0" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-05a49ba9dd159608c"
"description" = "sg description 0"
"egress" = []
"id" = "sg-05a49ba9dd159608c"
"ingress" = [
{
"cidr_blocks" = [
"10.0.0.0/16",
]
"description" = "rule description 0"
"from_port" = 80
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 80
},
{
"cidr_blocks" = [
"10.1.0.0/16",
]
"description" = "rule description 1"
"from_port" = 81
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 81
},
]
"name" = "example0"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"tags" = {}
"vpc_id" = "vpc-11111111"
}
"example1" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0ba2e3ff20952cc52"
"description" = "sg description 1"
"egress" = []
"id" = "sg-0ba2e3ff20952cc52"
"ingress" = [
{
"cidr_blocks" = [
"10.2.0.0/16",
]
"description" = "rule description 0"
"from_port" = 80
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 80
},
{
"cidr_blocks" = [
"10.3.0.0/16",
]
"description" = "rule description 1"
"from_port" = 81
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 81
},
]
"name" = "example1"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"tags" = {}
"vpc_id" = "vpc-11111111"
}
}
We can now have different ingress rules for each security group. The ingress rules are no longer hardcoded. This achieves the nested loop.
Combined the Nested Loops with Flatter Data Structures
While some folks like heirarchal data structures, some prefer to “flatten” the data structure into 2 different variables. Here’s an example of that:
locals {
groups = {
example0 = {
description = "sg description 0"
},
example1 = {
description = "sg description 1"
}
}
rules = {
example0 = [{
description = "rule description 0",
port = 80,
cidr_blocks = ["10.0.0.0/16"],
},{
description = "rule description 1",
port = 81,
cidr_blocks = ["10.1.0.0/16"],
}]
example1 = [{
description = "rule description 0",
port = 80,
cidr_blocks = ["10.2.0.0/16"],
},{
description = "rule description 1",
port = 81,
cidr_blocks = ["10.3.0.0/16"],
}]
}
}
resource "aws_security_group" "this" {
for_each = local.groups
name = each.key # top-level key is security group name
description = each.value.description
dynamic "ingress" {
for_each = local.rules[each.key] # List of Maps with rule attributes
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
}
output "security_groups" {
value = aws_security_group.this
}
Expand to see terraform apply results.
$ terraform apply
Outputs:
security_groups = {
"example0" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-0cbf2a34134e612e9"
"description" = "sg description 0"
"egress" = []
"id" = "sg-0cbf2a34134e612e9"
"ingress" = [
{
"cidr_blocks" = [
"10.0.0.0/16",
]
"description" = "rule description 0"
"from_port" = 80
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 80
},
{
"cidr_blocks" = [
"10.1.0.0/16",
]
"description" = "rule description 1"
"from_port" = 81
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 81
},
]
"name" = "example0"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"vpc_id" = "vpc-11111111"
}
"example1" = {
"arn" = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-092de6661edd12b9b"
"description" = "sg description 1"
"egress" = []
"id" = "sg-092de6661edd12b9b"
"ingress" = [
{
"cidr_blocks" = [
"10.2.0.0/16",
]
"description" = "rule description 0"
"from_port" = 80
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 80
},
{
"cidr_blocks" = [
"10.3.0.0/16",
]
"description" = "rule description 1"
"from_port" = 81
"ipv6_cidr_blocks" = []
"prefix_list_ids" = []
"protocol" = "tcp"
"security_groups" = []
"self" = false
"to_port" = 81
},
]
"name" = "example1"
"name_prefix" = ""
"owner_id" = "111111111111"
"revoke_rules_on_delete" = false
"vpc_id" = "vpc-11111111"
}
}
We’ve achieved the same result: a nested loop that can create as many security groups as we want with different ingress rules for each security group. This time with two different variables and flatter data structures.
Consideration: Updates with Removal
There’s a subtle but important consideration with the current code. It happens when the code gets updated, particularly when previously added elements are removed.
For example, let’s say we first use the code above and run a terraform apply
. That creates security groups with rules. Then we delete the rules from the code. Running terraform apply
again will not remove the rules.
This is because when there’s an empty List, the for_each
loop never iterates. If you wish for the security group rules to maintain its current state set outside of Terraform, you may want this behavior. However, this is probably unexpected and undesirable behavior.
If you want to have Terraform remove all the security group rules, then ingress
needs to be assigned directly with a List. We’ll cover how to do that shortly.
Configuration Blocks Are Syntactical Sugar
First, it is useful to understand that the configuration block syntax ability is syntactical sugar. We covered this in the previous post, and it’s worth highlighting again. Essentially, we can also assign the attribute directly with a List of Maps. Example:
resource "aws_security_group" "direct" {
name = "demo-direct"
description = "demo-direct"
ingress = [{
description = "description 0"
from_port = "80"
to_port = "80"
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = null
prefix_list_ids = null
security_groups = null
self = null
},{
description = "description 1"
from_port = "81"
to_port = "81"
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = null
prefix_list_ids = null
security_groups = null
self = null
}]
}
Here, ingress
is directly assigned with the equal sign. There are also extra attributes that must be explicitly be set like ipv6_cidr_blocks
and prefix_list_ids
. When using the syntactical sugar version, defaults are set for us. When assigned directly, we must set additional attributes because we’re setting the raw values.
Understanding that configuration blocks can be assigned directly will be useful for resetting and removing elements.
Direct Assignment Approach
For attributes designed for the block configuration syntax, we can also directly assign the attribute with an List of Maps instead. Here’s an example where we directly assign ingress:
locals {
groups = {
example0 = {
description = "sg description 0"
},
example1 = {
description = "sg description 1"
}
}
rules = {
example0 = [{
description = "rule description 0"
to_port = 80
from_port = 80
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
ipv6_cidr_blocks = null
prefix_list_ids = null
security_groups = null
self = null
},{
description = "rule description 1"
to_port = 80
from_port = 80
protocol = "tcp"
cidr_blocks = ["10.1.0.0/16"]
ipv6_cidr_blocks = null
prefix_list_ids = null
security_groups = null
self = null
}]
example1 = [] # empty List removes previous rules
}
}
resource "aws_security_group" "this" {
for_each = local.groups
name = each.key # top-level key is security group name
description = each.value.description
# direct assignment of List of Maps
ingress = lookup(local.rules, each.key, null) == null ? [] : local.rules[each.key]
}
Expand to see terraform plan results.
$ terraform plan
Terraform will perform the following actions:
# aws_security_group.this["example1"] will be updated in-place
~ resource "aws_security_group" "this" {
arn = "arn:aws:ec2:us-west-2:111111111111:security-group/sg-020565b90055f8df1"
description = "sg description 1"
egress = []
id = "sg-020565b90055f8df1"
~ ingress = [
- {
- cidr_blocks = [
- "10.2.0.0/16",
]
- description = "rule description 0"
- from_port = 80
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "tcp"
- security_groups = []
- self = false
- to_port = 80
},
- {
- cidr_blocks = [
- "10.3.0.0/16",
]
- description = "rule description 1"
- from_port = 80
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "tcp"
- security_groups = []
- self = false
- to_port = 80
},
]
name = "example1"
owner_id = "111111111111"
revoke_rules_on_delete = false
tags = {}
vpc_id = "vpc-11111111"
}
Plan: 0 to add, 1 to change, 0 to destroy.
There are advantages to this approach:
- When rules are removed, Terraform will remove the rules also.
- We’ve removed the second inner loop. It’s simpler in the sense that there’s no nested loop anymore. There’s only one outer loop at the resource level.
There are also some cons, though:
- A bothersome thing about this code is we must to set default values for attributes like
ipv6_cidr_blocks
andprefix_list_ids
. It introduces duplication. - We did not have to set these extra attributes when we were using the configuration block syntax.
- In the next few posts, we’ll cover the
for .. in
loop, which can be used to remove this duplication.
Cleanup
Note, remember to clean up the resources:
terraform destroy
Summary
This post covered how to perform nested loops with an outer resource-level for_each
loop and an inner dynamic nested block loop. We showed examples of hierarchical and flat data structures. We pointed out that the for_each
technique does not remove existing elements. We then covered the direct assignment approach, which will remove existing elements. Terraform’s declarative loops can be tricky for those used to the procedural language loops, so hopefully, this post is helpful. In the next few posts, we’ll cover the for ... in
loop, Terraform HCL Intro 7: For In Loop Basics.
The source code for the examples is available for BoltOps Learn Subscribers: terraform-hcl-tutorials/6-nested-loops
Want It to be Easier to Work with Terraform?
Check out Terraspace: The Terraform Framework.
The Terraform HCL Language Intro Tutorials
Thanks for reading this far. If you found this article useful, I'd really appreciate it if you share this article so others can find it too! Thanks 😁 Also follow me on Twitter.
Got questions? Check out BoltOps.
You might also like
More tools:
-
Kubes
Kubes: Kubernetes Deployment Tool
Kubes is a Kubernetes Deployment Tool. It builds the docker image, creates the Kubernetes YAML, and runs kubectl apply. It automates the deployment process and saves you precious finger-typing energy.
-
Jets
Jets: The Ruby Serverless Framework
Ruby on Jets allows you to create and deploy serverless services with ease, and to seamlessly glue AWS services together with the most beautiful dynamic language: Ruby. It includes everything you need to build an API and deploy it to AWS Lambda. Jets leverages the power of Ruby to make serverless joyful for everyone.
-
Lono
Lono: The CloudFormation Framework
Building infrastructure-as-code is challenging. Lono makes it much easier and fun. It includes everything you need to manage and deploy infrastructure-as-code.