Iterations in Terraform Part 1: meta-arguments
In this post I’m going to cover two extremely useful meta-arguments in Terraform - count
and for_each
, used to create resources iteratively. I’ll go through their benefits, drawbacks, and demonstrate a comprehensive example of how to use them.
Theory
Let’s start with a reviewing some theoretic review. Meta-arguments are the kind of arguments that can be used in all resources and modules regardless of what they do. There are five meta-arguments at the moment with count
and for_each
being the most often used.
count
The oldest way to create resources iteratively is to use meta-argument count
. It takes a number and creates that exact number of copies of the resources. On each iteration it exposes an object count
with a single argument index
inside of the resource block. By accessing count.index
you can get an index number of current iteration. You would use this index to somehow ensure that resource arguments which have to be unique - are unique. Some examples of that would be using count.index
interpolation in your strings or as a way to pick an item from some list.
There are multiple ways you can use count
in your code:
The most primitive one is to create identical resources with slightly different names:
resource "google_service_account" "service_accounts" { count = length(3) account_id = "${var.account_id}-${count.index}" display_name = var.display_name }
More useful scenario is to reference an item in the list of config objects. This way each of the resources you create can have fully customizable config. Please also note that using
length
function acts as a toggle in a way. By providing an empty list you disable resource creation.variable "service_accounts" { type = list(map(string)) default = [ {account_id = "one-sa", display_name = "Custom SA"}, {account_id = "two-sa", display_name = "Yet another SA"}, ] } resource "google_service_account" "service_accounts" { count = length(var.service_accounts) account_id = var.service_accounts[count.index].account_id display_name = var.service_accounts[count.index].display_name }
Sometimes you are in a situations when you need to create resource only if certain conditions are met(eg certain parameter is set to
true
). So it can act as a toggle if combined with conditional expression:resource "google_service_account" "service_account" { count = var.create_service_account == true ? 1 : 0 account_id = var.service_account_id display_name = var.service_account_display_name }
You can also combine toggle feature with creation from the list of configs:
resource "google_service_account" "service_accounts" { count = var.create_service_accounts == true ? length(var.service_accounts) : 0 account_id = var.service_accounts[count.index].account_id display_name = var.service_accounts[count.index].display_name }
As you can see it is very easy to use and quite powerful too. Unfortunately there is one particularly nasty side effect. Resources created using count are referenced in the state by their list index. If you delete a single item in the middle of the underlying list, all the items to the right of it will have their index recalculated, causing recreation of all those resources. Nonetheless it still can be used in scenarios when underlying list is not supposed to change frequently or items will be deleted only from the end of the list(scaling up/down).
If you want to reference an argument of a resource created with count
in other resources/outputs, you will have to use a list index or splat expression.
One item:
resource "google_service_account" "service_accounts" {
count = length(3)
account_id = "${var.account_id}-${count.index}"
display_name = var.display_name
}
output "service_account_email" {
value = google_service_account.service_accounts[0].email
}
All items:
# ...
output "service_account_emails" {
value = google_service_account.service_accounts[*].email
}
for_each
This meta-argument is relatively new. Originally for_each
was introduced in Terraform 0.12.0 as a way of creating “dynamic” resource blocks (blocks you can use within the same resource multiple times) but since Terraform 0.12.6 it’s usage was extended as a count
alternative for resource creation. On each iteration it exposes each
object with two arguments - key
and value
, which will have map element key and value correspondingly. Although map is the main resource type to use with for_each
you can also use it with sets (or lists converted to sets with toset
function).
When used with maps, for_each
is not a subject to the side effect that count
has. Due to the fact that every item in the map has a unique key and resources created with for_each
are referenced in the state by the key name of a corresponding map element, deleting an item from the map will not affect other map elements resulting in more predictable “plan”.
The main use case scenario is simple creation of multiple resources, but there is also a way you can implement a toggle feature.
Creating multiple resources with fully customizable configs. Please also note that using empty map works as toggle by itself just like the empty list did in
count = length(...
.variable "service_accounts" { type = map(map(string)) default = { "one-sa" = { display_name = "Custom SA" description = "A custom SA" }, "two-sa" = { display_name = "Yet another SA" description = "A custom SA" }, } } resource "google_service_account" "service_account" { for_each = var.service_accounts account_id = each.key display_name = each.value.display_name description = each.value.description }
Create single resources with a toggle. Looks ugly and does exactly the same thing as
count
toggle. I would not recommend using it but for the sake of completeness:resource "google_service_account" "service_accounts" { for_each = var.create_service_accounts == true ? map(var.account_id, var.display_name) : {} account_id = each.key display_name = each.value }
Create multiple resources with a toggle:
resource "google_service_account" "service_accounts" { for_each = var.create_service_accounts == true ? var.service_accounts : {} account_id = each.key display_name = each.value.display_name description = each.value.description }
When it comes to referencing resource values in the outputs,
If you want to reference an argument of a resource created with for_each
, you would need to address each resource by the key it was created with:
output "service_account_email" {
value = google_service_account.service_accounts["one-sa"].email
}
Splat expressions will not work with maps but you can use list and map expressions to achieve the same result:
output "service_account_email" {
value = [
for k, v in google_service_account.service_accounts: v.email
]
}
which would produce following output:
service_account_email = [
"one-sa@.....",
"two-sa@....."
]
or
output "service_account_email" {
value = {
for k, v in google_service_account.service_accounts:
k => v.email
}
}
which would produce following output:
service_account_email = {
one-sa = "one-sa@.....",
two-sa = "two-sa@....."
}
There is one thing you should be aware of when using for_each
. It will not be able to calculate the plan if the keys of the underlying map are composed from the outputs of any other resource. Consider the following snippet:
locals {
numbers = ["one", "two", "three"]
the_map = {
for number in local.numbers:
"${number}-${random_string.random.result}" => "some text"
}
}
resource "random_string" "random" {
length = 2
special = false
}
resource "local_file" "foo" {
for_each = local.the_map
content = each.value
filename = each.key
}
Even though it is clear to the human reader that the number of elements in the map does not depend on the result of random_string
resource, terraform will not be able to create the plan. So try to avoid such scenarios.
Error: Invalid for_each argument
on main.tf line 13, in resource "local_file" "foo":
13: for_each = local.the_map
The "for_each" value depends on resource attributes that cannot be determined
until apply, so Terraform cannot predict how many instances will be created.
To work around this, use the -target argument to first apply only the
resources that the for_each depends on.
Practice
Now as a practical example I will assemble some primitive certificate creation tool. It is not production-grade but might come handy for experiments in the home lab. The code will allow to either generate the CA or import the external one. I will be using TLS and Local providers with most of the aforementioned ways of using count
and for_each
.
Let’s start with the variable declaration. I’m going to use two composite variables, one for CA configuration and one for all leaf certificates.
cat variables.tf
variable "root_ca" {
description = "Root CA Specification"
type = object({
algorithm = string
rsa_bits = number
ecdsa_curve = string
external_cert = string
external_key = string
subject = map(string)
})
default = null
}
variable "certificates" {
description = "Leaf Certs Specification"
default = {}
}
You have probably noticed one significant difference between the root_ca
variable and the certificates
. First one has an object
type declared while second has none. While certificates
is a map of object
s, keeping its type unset allows me to have “optional parameters” in the config. Omitting such parameters will not be interpreted by terraform as an error and will make the tfvars
file significantly shorter in some cases. Terraform is smart enough to deduce variable type automatically during the plan/apply. If I were using strictly defined type I would have to set all key/value pairs declared in the object type. Even empty ones would have to be explicitly set to null
.
Next I’ll put some values to have a better perspective on the possible way of consuming them:
cat terraform.tfvars
root_ca = {
algorithm = "RSA"
rsa_bits = 4096
ecdsa_curve = null
external_cert = null
external_key = null
subject = {
CN = "Root CA"
O = "Example Org"
}
}
certificates = {
"example.com" = {
algorithm = "RSA"
rsa_bits = 2048
subject = {
CN = "Example Leaf Cert"
O = "Example Org"
}
}
"website.com" = {
algorithm = "ECDSA"
subject = {
CN = "Some Website"
O = "Example Org"
}
}
"anotherone.com" = {
subject = {
CN = "Leaf Cert"
O = "Example Org"
}
}
}
As you can see, the keys of certificates
variable are domain names which make them unique to a certain degree. The “website.com” value has only algorithm
set and “anotherone.com” omits even that. In both cases missing values will be calculated automatically according to the sane defaults I will put in my main.tf
. At the same time omitting root_ca.ecdsa_curve
would be an error.
Finally the heart of this tool, the resources.
cat main.tf
# the locals block is prefect for reusable expressions, it has a toggle which
# defines if CA cert and key must be generated or imported from file
locals {
use_external_cert = lookup(var.root_ca, "external_key", null) != null && lookup(var.root_ca, "external_cert", null) != null
}
# next two data sources import the external cert and key file only it toggle
# is true
data "local_file" "ca-key" {
count = local.use_external_cert == true ? 1 : 0
filename = var.root_ca.external_key
}
data "local_file" "ca-cert" {
count = local.use_external_cert == true ? 1 : 0
filename = var.root_ca.external_cert
}
# Root CA Config #
# same logic goes into the self-signed CA config, but the toggle is reversed
resource "tls_private_key" "root" {
count = local.use_external_cert == true ? 0 : 1
algorithm = var.root_ca.algorithm
rsa_bits = var.root_ca.algorithm == "RSA" ? var.root_ca.rsa_bits : null
ecdsa_curve = var.root_ca.algorithm == "ECDSA" ? var.root_ca.ecdsa_curve : null
}
# notice that in subject block we cant reference CN and O as we did with with
# algorithm so usilg `lookup` function is desirable
resource "tls_self_signed_cert" "root" {
count = local.use_external_cert == true ? 0 : 1
key_algorithm = tls_private_key.root[0].algorithm
private_key_pem = tls_private_key.root[0].private_key_pem
subject {
common_name = lookup(var.root_ca.subject, "CN")
organization = lookup(var.root_ca.subject, "O")
}
validity_period_hours = 87600 # 10 years
allowed_uses = [
"crl_signing",
"cert_signing"
]
is_ca_certificate = true
}
# Leaf Certs Config #
# leaf config is not as strict as root ca, because there was no explicit type
# declaration. Again the `lookup` function saves the day providing defaults for
# all omitted values
resource "tls_private_key" "leaf" {
for_each = var.certificates
algorithm = lookup(each.value, "algorithm", "RSA")
rsa_bits = lookup(each.value, "algorithm", "RSA") == "RSA" ? lookup(each.value, "rsa_bits", 2048) : null
ecdsa_curve = lookup(each.value, "algorithm", "RSA") == "ECDSA" ? lookup(each.value, "ecdsa_curve", "P224") : null
}
resource "tls_cert_request" "leaf" {
for_each = var.certificates
key_algorithm = tls_private_key.leaf[each.key].algorithm
private_key_pem = tls_private_key.leaf[each.key].private_key_pem
subject {
common_name = lookup(each.value.subject, "CN")
organization = lookup(each.value.subject, "O")
}
dns_names = [each.key]
}
# another resource with local toggle use, this time to either get
# the generated CA or the external one
resource "tls_locally_signed_cert" "leaf" {
for_each = var.certificates
cert_request_pem = tls_cert_request.leaf[each.key].cert_request_pem
ca_key_algorithm = var.root_ca.algorithm
ca_private_key_pem = local.use_external_cert == true ? data.local_file.ca-key[0].content : tls_private_key.root[0].private_key_pem
ca_cert_pem = local.use_external_cert == true ? data.local_file.ca-cert[0].content : tls_self_signed_cert.root[0].cert_pem
validity_period_hours = 8760 # 1 year
allowed_uses = [
"key_encipherment",
"digital_signature",
"server_auth",
]
}
# File Outputs #
# and finally file outputs
# nothing new here, same tricks
resource "local_file" "ca-cert" {
count = local.use_external_cert == true ? 0 : 1
content = tls_self_signed_cert.root[0].cert_pem
filename = "ca.pem"
}
resource "local_file" "ca-key" {
count = local.use_external_cert == true ? 0 : 1
content = tls_private_key.root[0].private_key_pem
filename = "ca-key.pem"
}
resource "local_file" "leaf-certs" {
for_each = var.certificates
content = tls_locally_signed_cert.leaf[each.key].cert_pem
filename = "${each.key}-cert.pem"
}
resource "local_file" "leaf-keys" {
for_each = var.certificates
content = tls_private_key.leaf[each.key].private_key_pem
filename = "${each.key}-key.pem"
}
Now run it and you will get the CA certificate, all leaf certificates and corresponding private keys:
terraform init
terraform plan -out main.tfplan
terraform apply main.tfplan
ls -l | grep pem
Output:
-rwxr-xr-x 1 alex alex 1554 Mar 9 23:52 anotherone.com-cert.pem
-rwxr-xr-x 1 alex alex 1679 Mar 9 23:52 anotherone.com-key.pem
-rwxr-xr-x 1 alex alex 3243 Mar 9 23:52 ca-key.pem
-rwxr-xr-x 1 alex alex 1834 Mar 9 23:52 ca.pem
-rwxr-xr-x 1 alex alex 1558 Mar 9 23:52 example.com-cert.pem
-rwxr-xr-x 1 alex alex 1675 Mar 9 23:52 example.com-key.pem
-rwxr-xr-x 1 alex alex 1265 Mar 9 23:52 website.com-cert.pem
-rwxr-xr-x 1 alex alex 207 Mar 9 23:52 website.com-key.pem
To test the script with imported CA you can delete the state and all leaf certs and then replace the root_ca
if your terraform.tfvars
with the following content:
root_ca = {
algorithm = "RSA"
rsa_bits = 4096
ecdsa_curve = null
external_cert = "ca.pem"
external_key = "ca-key.pem"
subject = {
CN = "Root CA"
O = "Example Org"
}
}
and then repeat the Terraform routine
rm -f terraform.tfstate*
ls | grep .com | xargs rm -f
terraform plan -out main.tfplan
terraform apply main.tfplan
ls -l | grep pem
Output:
-rwxr-xr-x 1 alex alex 1554 Mar 10 21:27 anotherone.com-cert.pem
-rwxr-xr-x 1 alex alex 1679 Mar 10 21:27 anotherone.com-key.pem
-rwxr-xr-x 1 alex alex 3243 Mar 10 21:25 ca-key.pem
-rwxr-xr-x 1 alex alex 1834 Mar 10 21:25 ca.pem
-rwxr-xr-x 1 alex alex 1562 Mar 10 21:27 example.com-cert.pem
-rwxr-xr-x 1 alex alex 1675 Mar 10 21:27 example.com-key.pem
-rwxr-xr-x 1 alex alex 1265 Mar 10 21:27 website.com-cert.pem
-rwxr-xr-x 1 alex alex 207 Mar 10 21:27 website.com-key.pem
Conclusion
I hope this article shed some light on the potential and the ways of using the iteration mechanisms in Terraform. In part two I am going to cover the list and map expressions for building robust data structures that can be consumed by the count
and for_each
meta-arguments. Combining those you will be able to write the code to support “self-serve” infrastructure deployment processes and create complex customizable resource hierarchies.
Stay tuned.