Adventures in the Terraform DSL, Part III: Iteration enhancements in Terraform 0.12
by Alex Harvey
In this third part of my blog series on the Terraform DSL, I look at for
and for_each
expressions and briefly mention further iteration enhancements not available in Terraform 0.12.11 but promised to be coming soon.
- Introduction
- Iteration in Terraform 0.12
- Summary
- See also
Introduction
In Part II, I covered traditional iteration in Terraform 0.11 and earlier. I looked at the count
meta parameter and discussed the pattern of using the length()
and element()
functions to create a list of resources, in a similar way to what was done in Puppet 3 and earlier.
In this post, I look at the enhancements to iteration introduced in Terraform 0.12, notably for
expressions, which are modelled on Python list comprehensions, and for_each
expressions and dynamic nested blocks, which for the first time allow generation of nested blocks like ingress
rules and so on.
There is also a new generalised splat operator, but that is going to have to wait until my Part IV.
Iteration in Terraform 0.12
Iteration V: Transforming lists and maps2
List comprehensions in Python
Terraform for
expressions are grammatically similar to and actually modelled on the list comprehension feature of Python and Haskell, which was specifically requested in the original feature request. And, for anyone unfamiliar with Python’s list comprehension, it is similar to the map feature of other languages like Ruby and Perl 5.
A list comprehension in Python looks like this:
>>> numbers = [1, 2, 3, 4]
>>> squares = [n**2 for n in numbers]
>>> squares
[1, 4, 9, 16]
And they can be further filtered by adding an if
expression, like this:
>>> even = [n for n in squares if n % 2 == 0]
>>> even
[4, 16]
For expressions
Terraform’s for
expression, meanwhile, is pretty much the same. A for
expression:
… creates a complex type value by transforming another complex type value. Each element in the input value can correspond to either one or zero values in the result, and an arbitrary expression can be used to transform each input element into an output element.
Example 6: Transforming a list into another list
Here is an example:
locals {
arr = ["host1", "host2", "host3"]
}
output "test" {
value = [for s in local.arr: upper(s)]
}
Applying that:
▶ terraform012 apply
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
test = [
"HOST1",
"HOST2",
"HOST3",
]
And for comparison, the same code in Python would be:
>>> arr = ["host1", "host2", "host3"]
>>> value = [s.upper() for s in arr]
>>> value
['HOST1', 'HOST2', 'HOST3']
Very pythonic.
Example 7: Filtering a list
And again, as in Python, Terraform lists can also be filtered by adding an if
in the for
expression:
locals {
arr = [1,2,3,4,5,6,7,8,9,10]
}
output "test" {
value = [for n in local.arr: n if n > 5]
}
And for reference, in Python, that would be:
>>> arr = [1,2,3,4,5,6,7,8,9,10]
>>> value = [n for n in arr if n > 5]
>>> value
[6, 7, 8, 9, 10]
Example 8: Transforming a list into a map
Map transformations in Terraform are also Pythonic. If I change my Terraform code to:
locals {
arr = ["host1", "host2", "host3"]
}
output "test" {
value = {for s in local.arr : s => upper(s)}
}
And apply that, I get:
▶ terraform012 apply
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
test = {
"host1" = "HOST1"
"host2" = "HOST2"
"host3" = "HOST3"
}
And again comparing this with Python:
>>> arr = ["host1", "host2", "host3"]
>>> value = {s: s.upper() for s in arr}
>>> value
{'host3': 'HOST3', 'host2': 'HOST2', 'host1': 'HOST1'}
Example 9: Transforming a map into a list
To iterate over a map, Terraform provides a keys()
and values()
function, similar to corresponding methods in Python. Thus, this sort of thing is possible:
locals {
mymap = {
"foo" = { "id" = 1 }
"bar" = { "id" = 2 }
"baz" = { "id" = 3 }
}
}
output "test" {
value = [for v in values(local.mymap): v["id"]]
}
Which is to Terraform as this is to Python:
>>> mymap = {'foo': {'id': 1}, 'bar': {'id': 2}, 'baz': {'id': 3}}
>>> value = [v['id'] for v in mymap.values()]
>>> value
[1, 2, 3]
Example 10: A real life example
All of this is good and theoretical although the reader may want a real life example at this point. Here is one:
variable "vpc_id" {
description = "ID for the AWS VPC where a security group is to be created."
}
variable "subnet_numbers" {
description = "List of 8-bit numbers of subnets of base_cidr_block that should be granted access."
default = [1, 2, 3]
}
data "aws_vpc" "example" {
id = var.vpc_id
}
resource "aws_security_group" "example" {
name = "friendly_subnets"
description = "Allows access from friendly subnets"
vpc_id = var.vpc_id
ingress {
from_port = 0
to_port = 0
protocol = -1
cidr_blocks = [
for num in var.subnet_numbers:
cidrsubnet(data.aws_vpc.example.cidr_block, 8, num)
]
}
}
So, for each subnet number, use the cidrsubnet()
function to generate a corresponding CIDR. The VPC’s CIDR prefix is 10.1.0.0/16, it would yield ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"]
.
Since this was not really possible in Terraform 0.11 and earlier without a lot of violating DRY, it can immediately be seen that the for
expressions are a big leap forward for the Terraform community.
Iteration VI: Dynamic nested blocks
Blocks and nested blocks
The second big improvement in Terraform 0.12 is the for_each
expression for dynamic nested blocks, which I’ll get to shortly. Firstly, however, I need to back track a little, and mention what blocks and nested blocks are.
Blocks
A block in Terraform is similar to blocks in other languages e.g. Bash, Perl, Ruby, Puppet. All of these allow blocks of code to be defined using { ... }
notation. In Terraform, a block is:
resource "aws_security_group" "vault" {
// resource attributes declared inside the block.
}
Nested blocks
A nested block meanwhile is a block defined inside a block:
resource "aws_security_group" "vault" {
// other config.
ingress {
// this is a nested block. Ingress configuration in here.
}
}
Dynamic block types
Which brings me to dynamic nested blocks. Terraform 0.12 has introduced the dynamic nested block, although no dynamic top-scope block. And it is in the context of the dynamic nested block that for_each
expressions can be used. (Although, as mentioned below, they will eventually be available to resources, data blocks and modules too).
A dynamic block looks like this:
resource "aws_security_group" "vault" {
// other config.
dynamic "ingress" {
// this is a dynamic nested block. A for_each goes in here.
}
}
For_each expressions
Now to the for_each
expression.
From a grammar point of view, Terraform’s for_each
is a little surprising. In languages that have both a for
and a foreach
loop, the for loop generally allows iteration over ranges of numbers or iteration according to arbitrary conditions, whereas a foreach loop is specifically for iterating over collections such as arrays and maps.
In Terraform, however, the for
and for_each
expressions are both foreach loops in this sense as both iterate over collections.
The difference is that for
expressions generate values that can be assigned to resource attributes whereas for_each
expressions generate a nested block of code instead.
As mentioned, the for_each
(at the time of writing) can only be used in a dynamic nested block. It looks like this:
resource "aws_security_group" "vault" {
// other config.
dynamic "ingress" {
for_each = some_array
// code to generate for each array element of the collection.
}
}
Example 11: A dynamic nested ingress block
Here is a full example:
variable "ingress_ports" {
type = list(number)
description = "list of ingress ports"
default = [8200, 8201]
}
resource "aws_security_group" "vault" {
name = "vault"
description = "Ingress for Vault"
vpc_id = aws_vpc.my_vpc.id
dynamic "ingress" {
for_each = var.ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
}
}
}
This generates an ingress
block for each port in the ingress_ports
list.
Iterators
As can be seen in the examples above, the temporary variable used as the loop’s iterator takes its name by default from the label of the dynamic block. In this example, I iterated over a list named ingress_ports
in the context of a dynamic ingress
block, and each element of the list was addressed as ingress.value
. Since there is no necessary relationship between the name of the list and the label of the block, the code could become unreadable.
To avoid this, it is possible to name the temporary variable something else by using the iterator
argument.
Example 12: Example with iterator
Rewriting the previous example with an iterator:
variable "ingress_ports" {
type = list(number)
description = "list of ingress ports"
default = [8200, 8201]
}
resource "aws_security_group" "vault" {
name = "vault"
description = "Ingress for Vault"
vpc_id = aws_vpc.my_vpc.id
dynamic "ingress" {
for_each = var.ingress_ports
iterator = "ingress_port"
content {
from_port = ingress_port.value
to_port = ingress_port.value
protocol = "tcp"
}
}
}
Example 13: Combining for and for_each
Having now covered off for
and for_each
it is useful to see how they can be combined. This example is copied from the Terraform 0.12 Preview:
variable "subnets" {
default = [
{
name = "a"
number = 1
},
{
name = "b"
number = 2
},
{
name = "c"
number = 3
},
]
}
locals {
base_cidr_block = "10.0.0.0/16"
}
resource "azurerm_virtual_network" "example" {
name = "example-network"
resource_group_name = azurerm_resource_group.test.name
address_space = [local.base_cidr_block]
location = "West US"
dynamic "subnet" {
for_each = [for s in subnets: {
name = s.name
prefix = cidrsubnet(local.base_cidr_block, 4, s.number)
}]
content {
name = subnet.value.name
address_prefix = subnet.value.prefix
}
}
}
As can be seen, a for
expression transforms a list of maps into a different list of maps, and then each element of that list of maps is made available in the content
block.
Best practices
I joked in Part II at the way attitudes changed in the Puppet community, from the early days, when it was thought that iteration should not ever be required in a declarative language - indeed, some argued against adding iteration in Puppet! - to the present day, where iteration is recommended in the style guide in preference to the earlier, declarative grammar.
All the same, at this time, Hashicorp’s advice is to avoid dynamic nested blocks and the for_each
where possible:
We still recommend that you avoid writing overly-abstract, dynamic configurations as a general rule. These dynamic features can be useful when creating reusable modules, but over-use of dynamic behavior will hurt readability and maintainability. Explicit is better than implicit, and direct is better than indirect.
And in the docs:
Overuse of
dynamic
blocks can make configuration hard to read and maintain, so we recommend using them only when you need to hide details in order to build a clean user interface for a re-usable module. Always write nested blocks out literally where possible.
Iteration VII: Resource for_each (coming soon)
Hashicorp tell us in their preview post that the groundwork has been laid for supporting for_each
inside resource and data blocks as a way of creating resources for each element in a list or map. Apparently, it will look something like this:
resource "aws_subnet" "example" {
for_each = var.subnet_numbers
vpc_id = aws_vpc.example.id
availability_zone = each.key
cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 8, each.value)
}
A new object each
with attributes each.key
and each.value
will allow access to the iterator in the for_each
expression. Also, when this happens, the count
and count.index
methods of iteration will no longer be recommended for most cases.
Iteration VIII: Module count and for_each (coming soon)
Likewise, a future release of Terraform will provide a count
and for_each
for modules. Although,
This feature is particularly complicated to implement within Terraform’s existing architecture, so some more work will certainly be required before we can support this. To avoid further breaking changes in later releases, 0.12 will reserve the module input variable names
count
andfor_each
in preparation for the completion of this feature.
Exciting stuff.
Summary
That completes the third part of my series. I have looked at for
expressions in Terraform 0.12 and noted that these are modeled on the list and dict comprehension from Python and shown some examples relating the two. I also showed how the for_each
expression can be used to generate dynamic nested blocks, and briefly mentioned that a similar, but not identical, for_each
grammar is coming soon for resources, data blocks and modules.
Stay tuned for Part IV of this series, where I amazingly continue with iteration in Terraform and discuss the splat operator both in Terraform 0.11 and the generalised splat operator of Terraform 0.12.
See also
- Martin Atkins, Jul 2018, HashiCorp Terraform 0.12 Preview: For and For-Each.
- Documentation in the Terraform source code on dynamic blocks.
- Map & list comprehensions feature request, 24 Aug 2016, Issue #8439, where the Python list & map comprehension grammar was first requested.
- For_each feature request in GitHub Issues, 24 Jan 2018, Issue #17179, where some of the motivations of the
for_each
feature is discussed.
1 Note that when I began this series, Terraform 0.12 was in beta2 stage, whereas at the time of writing this post, the current version is Terraform 0.12.1.
2 The numbering in this post continues the numbering in the previous post, so that these two posts (and the next one) can be read together as a complete treatment of iteration in Terraform 0.12.