Intro
“Developer’s constant urge to use Terraform like a programming language :)”
My impression after enjoying my first terraform applys is that it's kind of rigid, though when someone says Immutable and declarative in the same sentence, you should definitely get that it's
not procedural by design. Therefore, you can’t expect a bash shell script programmability. That's the tricky part for me because I always start playing with the Cloud vendor’s CLI before switching to its Terraform provider... It’s like having a BBQ party on Sunday then turn vegan on Monday :D!! You just wish they could allow a tiny more.
Most of the time, the code inside your terraform configuration is pretty static, more like a reflection of the actual end state (hard coded). In a sense, declarative language is more readable but don’t fit the “if then else” logic proper to procedural programing (single use scripts).That being said, terraform still provides a number of functions and expressions you can use to process strings/number/lists/maps/simple loops etc.
-- “Because we’ll always have that reflex to make our code conditional.” --
What If, I wanted my aws vpc configuration (see config in my GitHubRepo) to accept different security group rules depending on the type of instance attached ? It’ll probably reduce duplication and improve reusability.
This article answers just that as it will demonstrate how dynamic interpolation of a nested variable can be accepted by the parser and used by a for_each loop to make a terraform provisioning more flexible. I’ll explain better through the post (don’t worry).
Terraform available loops
As said above, terraform has few routines and expressions that allow to perform loops, if-statements and other logic.
Conditional loops
1. Count:
Available in terraform 0.11, count can control the the number of resources to be created or whether the resource will be created at all using the ternary-operator [CONDITION ? TRUEVAL : FALSEVAL] => IF:THEN:ELSE
variable "instance" { default = true }
variable "
instance_NB
" { default = 3 }
# Create or Ignore
resource "aws_instance" "bastion" {
count = "${var.instance ? 1 : 0}" # IF var.instance=true THEN creation ELSE code ignored
}
# for 1 to N create instance
resource "aws_instance" "bastion" {
count = var.instance_NB # if var is a list/map you can use length(var)to get the nbr
}
Pros: Perfect for conditional logic using ternary operator (1:create, 0: skip)
weaknesses: Using count
inside of an inline block is not supported (i.e Tags) and referencing a count element using its index can be risky and confusing when deleting specific resources. Some users also think this is so 0.11 ;)!
2. For_each:
Available since v0.12, this one is more sophisticated than count, as it’s close to the loop as we know it. Below, we loop over all the map’s content to create as many resources as the length of the map (you can try this yourself).
variable "triggerMap"{ default = { 1 = "cluster_1" 2 = "cluster_2" } }
# for each map tuple create a resource based on its key and value resource "null_resource" "cluster" { # Changes to any instance of the cluster requires re-provisioning for_each= var.triggerMap triggers = { cluster_node_id = each.key cluster_name = each.value }
Pros: helps create multiple resources or inline blocks by looping over maps, set of strings, lists. Easy to reference an element within the looped collection (name instead of index). It even started to support Modules with version 0.13.
Weaknesses: Conditional logic is more complex than in count as it requires a nested for loop within the for_each clause. Referenced collection has to be computed during the plan phase and not upon resource creation (not on the fly).
My pick: The use of for_each is recommended by Hashicorp as it’s more intuitive than count and no longer effected by the order of the variable's values (index). Hence, I will be relying on it in my below use case
USE CASE
“Trying to reduce duplication and maintain readability is the eternal Terraform struggle”
Cool, now let’s describe the reason behind this post in the first place. In my previous vpc configuration (see my GitHubRepo) I hardcoded all the security group rules (Open Ports) which made it un-reusable. The following snippet will illustrate the section of interest ( ingress rules ):
resource "aws_security_group" "terra_sg" { name = var.sg_name vpc_id = aws_vpc.terra_vpc.id description = "SSH ,HTTP, and HTTPS" egress { cidr_blocks = ["0.0.0.0/0", ... } ingress = [ { cidr_blocks = ["0.0.0.0/0", ] description = "Inbound HTTP access " from_port = 80 protocol = "tcp" to_port = 80 prefix_list_ids = null ipv6_cidr_blocks = null
security_groups = null
self = false },
... ingress = [ Similar Ingress rule for port 22
... ingress = [ Similar Ingress rule for port 443
...
}
Notice that all the ingress rules are added as inline blocks in the main resource “terra_sg”. However, Terraform also provides a standalone Security Group Rule resource (a single ingress
or egress
rule) . This could really help to make the deployment more dynamic.
So let’s say, we have 3 cases or sets of sg ingress rules per type of attached instance:
- Case 1: SSH only => port 22
- Case 2: SSH+WEB => port 22, 80, 443
- Case 3: RDP+WEB (Windows)=> ports 3389, 80, 443
Desired configuration
The aim here is to replace the old vpc configuration that had a security group with hard coded inline rules (fixed list of ports) by a dynamic iteration of an “aws_security_group_rule” resource using a for_each loop. To do so, I first need to create a map for each set of sg rules.
# case 1
variable "sg_ssh" {
type = map
default = {SSH = 22}
}
# case 2
variable "sg_web" {
type = map
default = {
SSH = 22
HTTP = 80
HTTPS= 443}
}
# case 3
variable "sg_win" {
type = map
default = {
RDP = 3389
HTTP = 80
HTTPS= 443}
}
# Map of sg rule names
variable "sg_mapping" {
description = "mapping for sg rules "
default = {
"SSH" = "sg_ssh",
"WEB" = "sg_web",
"WIN" = "sg_win"
}
}
# sg rule selector
variable "sg_type"{
default = "WEB"
}
Is Nested variable call Possible in Terraform?
At this point in I only need to make the for_each expression accept a call to a nested variable that would include 3 variables:
1. sg_type to pick the rule type
2. sg_mapping to fetch the right map variable based on sg_type
3. A wrapper variable that the for_each can call =>
var.[var.sg_mapping[var.sg_type]
But l quickly realized that terraform doesn’t allow variable substitution within variables as shown below:
#
terraform Console
> var.sg_mapping[var.sg_type] sg_web > lookup(var.sg_mapping, var.sg_type) sg_web
#nested var call
> var.${var.sg_mapping[var.sg_type]} #OR var.${lookup(var.sg_mapping, var.sg_type)} Error: Invalid character > on
line 1: (source code not available ).This character isn't used within the language. Error: Invalid Attribute name
> online 1: (source code not available).An attribute name is required after a dot.
Solution
After few unsuccessful attempts and hours sniffing forums and other resources online. I sensed that I might have neglected a special type of variables that could help dealing with my substitution woes. The potential silver bullet in question is
terraform local which is a local block where expressions are defined in one or more local variables within a module. I was close but I still needed help from the community on Hashicorp forum.
The final result to represent my nested variable substitution: var.[var.sg_mapping[var.sg_type] is as follows
# Locals block
locals {
sg_mapping = { # variable substitution within a variable
SSH = var.sg_ssh
WEB = var.sg_web
WIN = var.sg_win
}
}
resource "aws_security_group "terra_sg" {...}
...
resource "aws_security_group_rule" "terra_sg_rule" {
for_each = local.sg_mapping[var.sg_type] # => var.[var.sg_mapping[var.sg_type]
type = "ingress"
from_port = each.value
to_port = each.value
protocol = "tcp"
security_group_id = aws_security_group.terra_sg.id
description = each.key
cidr_blocks = ["0.0.0.0/0",]
With Locals, I only needed to move sg_mapping map to a local block and replace its values by each literal sg map variables and voila ! the for_each can now call the local variable which in turn will substitute the sg_type variable.
Terraform plan
Each resource will be created and identified according to the selected map value (example “ WEB =>80,443,22”).
# aws_security_group_rule.terra_sg_rule["HTTP"]:
resource "aws_security_group_rule" "terra_sg_rule" {
cidr_blocks = ["0.0.0.0/0",]
description = "HTTP"
from_port = 80
...
}
# aws_security_group_rule.terra_sg_rule["HTTPS"]:
resource "aws_security_group_rule" "terra_sg_rule" {
cidr_blocks = ["0.0.0.0/0",]
description = "HTTPS"
from_port = 443
...
}
# aws_security_group_rule.terra_sg_rule["SSH"]:
resource "aws_security_group_rule" "terra_sg_rule" {
cidr_blocks = ["0.0.0.0/0",]
description = "SSH"
from_port = 22
...
}
...
OUTPUT
When for_each
is set, Terraform will identify each of the resource instances associated with the dynamic resource block by a map key from the value provided to for_each
. (example : => aws_security_group_rule.terra_sg_rule["SSH"])
Hence, referencing these resources as a list is wrong [aws_security_group_rule.terra_sg_rule.* ] => error
The correct way to output all of our resource instances in my case is to use a for loop to traverse and fetch the resulted map.
# Terraform control
> { for sg,p in aws_security_group_rule.terra_sg_rule : sg => p.to_port} { "HTTP" = "80""HTTPS" = "443" "SSH" = "22" }
CONCLUSION
- This was an improvement I wanted to complete from my previous blog post, but I hope you enjoyed discovering (like me) how to leverage dynamic features to increase the reusability of a terraform module
- Locals are a solid ally when dealing with variable substitutions within a variable (nested variables)
- Obviously, the over-use of dynamic behavior will hurt readability and maintainability. As terraform team likes to remind us often its famous mantra: “Explicit is better than implicit, and direct is better than indirect”
- TIP : There is also a way to keep all the maps in one main map and then create a local variable that would reference the sub map directly instead of calling an individual map each time .
# all cases in one map variable "main_sg" { default = { sg_ssh = {SSH = 22},
sg_web {
SSH = 22
HTTP = 80
HTTPS = 443 },
sg_win {
RDP = 3389
HTTP = 80
HTTPS = 443 }
}
}
# Locals block
locals { sg_mapping = { # variable substitution within a variable SSH = var.main_sg.sg_ssh WEB = var.main_sg.sg_web WIN = var.main_sg.sg_win } }
... resource "aws_security_group_rule" "terra_sg_rule" { for_each = local.sg_mapping[var.sg_type] …
- It might comfort you to know that balancing the readability and reusability is everybody’s struggle with Terraform ;)
Reference:
cloudaffaire.com
blog.gruntwork.io
GitHubRepo for this lab: brokedba/terraform-examples/terraform-provider-aws/create-vpc-dynamic