RETROSPECTIVE

June 17th, 2019

Creating a Reusable Terraform Module

Terraform

HCL

Infrastructure as Code

AWS

When programming with Terraform, I often find myself writing the same code again and again. In order to maintain the DRY principal in Terraform configuration, modules are used to encapsulate logic and design a layer of abstraction for infrastructure code. This article explains how I created Terraform modules that are reused throughout my AWS Infrastructure as Code.

DRY Principal

DRY stands for "Do not Repeat Yourself." It's the design philosophy that similar code shouldn't exist in multiple locations. Instead, repeated code segments should be combined into a single component or function.

As I write Terraform code, I notice there are certain AWS resource configurations repeated across my repositories. For example, my AWS infrastructure has multiple VPCs which are all configured in a similar manner. Eventually I got unhappy with all this duplicate code. I started creating reusable modules that are used across all my Terraform repositories.

My current AWS architecture is split across multiple git repositories. I have a single repository with global infrastructure such as VPCs and multiple other repositories with application specific infrastructure such as EC2 instances. Additionally, I have one final repository containing reusable modules.

All my repositories are able to use Terraform modules defined in terraform-modules. In fact, anyone can use these modules because the repository is public.

Now that I've explained my Terraform modules from the repository standpoint, let's look at the code.

Terraform defines a module as a series of .tf files in a directory1. Therefore, anytime you write Terraform code you are creating one or more modules. A common approach is to have three Terraform files in a module. The first file contains resources and data blocks, the second file contains input variables, and the final file contains output variables2. For example, a VPC module would have the following folder structure:

vpc/ ├── main.tf ├── var.tf ├── output.tf

This file structure isn't mandatory. Instead, it's a common standard used for grouping related Terraform code blocks. In practice Terraform simply loads all .tf files in a directory at once for execution3.

With this structure in mind, let's explore one of the reusable modules I've built so far: security-group.

I currently have two reusable modules - security-group and vpc. The rest of this article explores the security-group module since it has fewer moving parts. You can check out the vpc module on GitHub.

In the security-group module, the var.tf file contains input variables. These variables are like function arguments for the module.

# var.tf variable "enabled" { description = "Whether or not the security group should be created" default = true } #----------------- # Naming Variables #----------------- variable "name" { description = "Name to use as a prefix for different resources" } variable "tag_name" { description = "Name to use for the Name property in the Tag objects" } #----------------------------- # aws_security_group Variables #----------------------------- variable "vpc_id" { description = "VPC identifier for the security group" } variable "description" { description = "Information about the security group" type = string default = "Security Group" } variable "sg_rules" { description = "A list of security group rules" type = list default = [] }

The variable enabled is used to enable or disable the creation of the security group. Usually the count variable is used for this purpose in Terraform resources, however it isn't exposed to modules. Using the enabled variable is a workaround for this limitation.

Variables under the “Naming Variables” header are used to name the AWS resources and their tags. Variables under the “aws_security_group Variables” header are used to configure the aws_security_group and aws_security_group_rule resources. Both are defined in main.tf. It's important to note that these resources use the new Terraform 0.12 and HCL 2 syntax.

# main.tf locals { count = var.enabled ? 1 : 0 } resource "aws_security_group" "security" { count = local.count name = var.name description = var.description vpc_id = var.vpc_id tags = { Name = var.tag_name } } resource "aws_security_group_rule" "security-rule" { count = length(var.sg_rules) security_group_id = local.count == 1 ? aws_security_group.security[0].id : null type = lookup(var.sg_rules[count.index], "type", "ingress") from_port = lookup(var.sg_rules[count.index], "from_port", 0) to_port = lookup(var.sg_rules[count.index], "to_port", 0) protocol = lookup(var.sg_rules[count.index], "protocol", "-1") cidr_blocks = [lookup(var.sg_rules[count.index], "cidr_blocks", "")] }

This code creates an AWS security group and its corresponding rules. Rules are created for each item in the var.sg_rules list. This module returns a single value - the ID of the security group.

# outputs.tf output "security_group_id" { # There will always be 0 or 1 security groups in the output value = aws_security_group.security.*.id }

This is the complete complete configuration of the security-group module. To use this module, a module code block must be created. module exposes a variable source which helps Terraform locate the module. Since my module lives in a GitHub repository, its accessible using a GitHub source type5.

module "security-group" { source = "github.com/ajarombek/terraform-modules//security-group?ref=v0.1.7" ... }

The module is located in the security-group subdirectory. I tag my repository whenever I alter the modules. In the example above, I'm pulling the module from the v0.1.7 tag. This is helpful because I don't want a potentially breaking change to impact existing codebases. You can also omit the tag reference, in which case the latest commit is pulled.

The rest of the module code block assigns values to the input variables defined in var.tf. The following code snippet is an example security-group implementation.

module "security-group" { source = "github.com/ajarombek/terraform-modules//security-group?ref=v0.1.7" # Mandatory arguments name = "example-sg" tag_name = "example-sg" vpc_id = data.existing-vpc.vpc_id # Optional arguments sg_rules = [ { # Inbound traffic for SSH type = "ingress" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = local.my_cidr }, { # Outbound traffic for HTTP type = "egress" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = local.public_cidr } ] description = "example sg module" }

Creating generic Terraform modules promotes code reuse and cuts down on bugs that commonly plague developers who copy and paste their code. I have a much better understanding of Terraform and HCL syntax because I spent time designing reusable modules. All the code from this article is available on GitHub.

[1] "Modules", https://www.terraform.io/docs/configuration/modules.html

[2] Yevgeniy Brikman, Terraform Up & Running (Beijing: O'Reilly, 2017), 88

[3] "Load Order and Semantics", https://www.terraform.io/docs/configuration-0-11/load.html

[4] "terraform modules value of count cannot be computed", https://bit.ly/2RiZZHV

[5] "Module Sources: GitHub", https://www.terraform.io/docs/modules/sources.html#github