The 3 Rs: Reduce, Recycle, Repeat
A tote bag phrase applied to Terraform code
Why
One of (Software) Development's best practices is to make code reusable - meaning we want to make sure to reutilise pieces and bits of code so as not to write repetitive lines in our program.
What
A module in Terraform is a grouping of resources, an object that allows us to create a bunch of resources from a given set of input parameters used to process and further manage the output infrastructure.
How
There are two ways of using a module: referencing a published one or writing your own.
In the Terraform registry, a lot of modules are published and available for you to use in your code. These are free, community-maintained (in most cases), tested and documented, which makes them a very good resource to use and reuse code.
Or (AND) you could write your own, based either on a published one or from scratch by declaring native resources.
Let's see why you would want to use a module
The following example(s) assumes you have already used Terraform to manage Infrastructure resources and you know the basics. Also, this guide is based on a preexisting AWS EKS cluster.
Say you have 3 services (apps) hosted in Kubernetes (EKS) that upload files to a (AWS) S3 bucket and you want to enable these EKS services to interact with S3 using dynamic credentials (IRSA).
Psst, here is an IRSA implementation. Also, all these examples are here.
What do you need to create?
An S3 bucket with its base ACLs, then an IAM role with its policies to both allow the application in EKS to assume the role via AssumeRoleWithWebIdentity and the permissions required to upload files to the bucket. Those are in total 4 different Terraform resources (per application) and some additional code to get information/parameters.
Using plain resources
You would need to do something like:
locals {
apps = {
app-1 = { "permissions" : ["s3:PutObject"] },
app-2 = { "permissions" : ["s3:GetObject", "s3:PutObject"] },
app-3 = { "permissions" : ["s3:GetObjectVersion"] }
}
}
# S3 resources
resource "aws_s3_bucket" "the_bucket" {
for_each = local.apps
bucket_prefix = join("", [each.key, "-"])
}
resource "aws_s3_bucket_ownership_controls" "the_bucket_oc" {
for_each = {
app-1 = { "permissions" : ["s3:PutObject"] },
app-2 = { "permissions" : ["s3:GetObject", "s3:PutObject"] },
app-3 = { "permissions" : ["s3:GetObjectVersion"] }
}
bucket = aws_s3_bucket.the_bucket[each.key].id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "aws_s3_bucket_acl" "the_bucket_acl" {
depends_on = [aws_s3_bucket_ownership_controls.the_bucket_oc]
for_each = local.apps
bucket = aws_s3_bucket.the_bucket[each.key].id
acl = "private"
}
resource "aws_s3_bucket_public_access_block" "the_bucket_ab" {
for_each = local.apps
bucket = aws_s3_bucket.the_bucket[each.key].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# IAM resources
## Some prerequisites
data "aws_eks_cluster" "this" { # get EKS cluster attributes to use later on.
name = "test-eks-cluster"
}
data "aws_caller_identity" "current" {} # get current account ID
data "aws_iam_policy_document" "assume-policy" { # create an assume policy for STS
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [
replace(
data.aws_eks_cluster.this.identity[0].oidc[0].issuer,
"https://",
join("", ["arn:aws:iam::", data.aws_caller_identity.current.account_id, ":oidc-provider/"])
)
]
}
}
}
## Role definition
resource "aws_iam_role" "the_role" { # create the IAM role and attach both assume and inline identity based policies.
for_each = local.apps
name = each.key
path = "/"
assume_role_policy = data.aws_iam_policy_document.assume-policy.json
inline_policy {
name = "s3-put"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = [for permission in each.value["permissions"] : permission]
Effect = "Allow"
Resource = aws_s3_bucket.the_bucket[each.key].arn
}]
})
}
tags = {
# always add some tags!
}
}
resource "aws_iam_policy" "the_policy" {
for_each = local.apps
name_prefix = join("",[each.key,"-"])
path = "/"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [ for permission in each.value["permissions"]: permission ]
Effect = "Allow"
Resource = join("",[aws_s3_bucket.the_bucket[each.key].arn,"/*"])
},
]
})
}
resource "aws_iam_role_policy_attachment" "the_policy_attachment" {
for_each = local.apps
role = aws_iam_role.the_role[each.key].name
policy_arn = aws_iam_policy.the_policy[each.key].arn
}
output "the_bucket" {
value = values(aws_s3_bucket.the_bucket).*.arn
}
output "the_role" {
value = values(aws_iam_role.the_role).*.arn
}
Total: 131 lines of code.
Now let's do the same using upstream (registry) modules
There are published modules for S3 and IAM roles with IRSA support.
locals {
apps = {
app-1 = { "permissions" : ["s3:PutObject"] },
app-2 = { "permissions" : ["s3:GetObject", "s3:PutObject"] },
app-3 = { "permissions" : ["s3:GetObjectVersion"] }
}
}
data "aws_eks_cluster" "this" { # get EKS cluster attributes to use later on.
name = "test-eks-cluster"
}
data "aws_caller_identity" "current" {} # get current account ID
module "s3_bucket" {
for_each = local.apps
source = "terraform-aws-modules/s3-bucket/aws"
bucket_prefix = join("",[each.key,"-"])
acl = "private"
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
}
module "iam_assumable_role_with_oidc" {
for_each = local.apps
source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role-with-oidc"
create_role = true
role_name = each.key
tags = {
# add some tags!
}
provider_url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
role_policy_arns = [
aws_iam_policy.the_policy[each.key].arn,
]
number_of_role_policy_arns = 1
}
resource "aws_iam_policy" "the_policy" {
for_each = local.apps
name_prefix = join("",[each.key,"-"])
path = "/"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [ for permission in each.value["permissions"]: permission ]
Effect = "Allow"
Resource = join("",[module.s3_bucket[each.key].s3_bucket_arn,"/*"])
},
]
})
}
output "the_bucket" {
value = values(module.s3_bucket).*.s3_bucket_arn
}
output "the_role" {
value = values(module.iam_assumable_role_with_oidc).*.iam_role_arn
}
Total: 69 lines.
With less (half) code, you will end up with the same set of resources as before (21).
A module using modules
Now, let's go a bit further and write our own module using two published ones, so it makes more sense to our use case and avoids code repetition.
To achieve reusability you need to create code that:
Accepts a set of names/IDs for each bucket and role to be created.
Loops through that given set.
Calls the upstream modules the less amount of times.
Is flexible enough to be reused with as many inputs as needed.
For this topic, let's think about what attributes all apps share and what is specific for each of them.
Whatever is common will be set statically in the module and only use a name or identifier to be managed (e.g. a bucket ACL or a role assume policy).
But if we had a parameter that may vary per app, we would populate it dynamically in the module (e.g. the role permissions over the bucket objects).
Also as a general guideline, we will be referencing a locally written module.
The directory schema will look like the following:
.
├── my_awesome_module
│ ├── iam_role.tf
│ ├── outputs.tf
│ ├── s3_bucket.tf
│ └── variables.tf
└── with_local_module
└── the_apps_local_module.tf
Where:
the_apps_local_module.tf
is the main Terraform file, where we call our module with a set of input applications' parameters.my_awesome_module
is the local module directory.s3_bucket.tf
calls the upstream S3 bucket module.iam_role.tf
references the upstream IAM role module and defines a standalone role policy.variables.tf
where we define the input variables expected by our local module.outputs.tf
defining a couple of return values from the created resources, which I highly recommend but it's optional.
The module and its files
s3_bucket.tf
(required)
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
bucket_prefix = join("",[var.bucket_name,"-"])
acl = "private"
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
}
iam_role.tf
(required)
data "aws_eks_cluster" "this" { # get EKS cluster attributes to use later on.
name = "test-eks-cluster"
}
data "aws_caller_identity" "current" {} # get current account ID
module "iam_assumable_role_with_oidc" {
source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role-with-oidc"
create_role = true
role_name = var.role_name
tags = {
# add some tags!
}
provider_url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
role_policy_arns = [
aws_iam_policy.the_policy.arn,
]
number_of_role_policy_arns = 1
}
resource "aws_iam_policy" "the_policy" {
name_prefix = join("",[var.role_name,"-"])
path = "/"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [ for permission in var.permissions: permission ]
Effect = "Allow"
Resource = join("",[module.s3_bucket.s3_bucket_arn,"/*"])
},
]
})
}
variables.tf
(required)
variable "bucket_name" {
description = "S3 bucket name"
type = string
default = ""
}
variable "role_name" {
description = "IAM role name"
type = string
default = ""
}
variable "permissions" {
description = "List of permissions to attach to the IAM role"
type = list
default = []
}
outputs.tf
(optional)
output "the_bucket" {
value = module.s3_bucket.s3_bucket_arn
}
output "the_role" {
value = module.iam_assumable_role_with_oidc.iam_role_arn
}
The call
Now that we have our module, we can reference it by passing all the apps we need with their corresponding input (variable) values.
the_apps_local_module.tf
module "the_apps" {
for_each = {
app-1 = { "permissions" : ["s3:PutObject"] },
app-2 = { "permissions" : ["s3:GetObject", "s3:PutObject"] },
app-3 = { "permissions" : ["s3:GetObjectVersion"] }
}
source = "../my_awesome_module" # reference to our local module
bucket_name = each.key
role_name = each.key
permissions = each.value["permissions"]
}
output "the_apps" {
value = module.the_apps
}
Total: 85 lines for the same 21 resources as before.
Testing all up
git clone https://github.com/marianogg9/tf-moduling
cd tf-moduling
cd with_standalone_resources
terraform init
terraform plan
# Plan: 21 to add, 0 to change, 0 to destroy.
cd ../with_upstream_modules/
terraform init
terraform plan
# Plan: 21 to add, 0 to change, 0 to destroy.
cd ../with_local_module/
terraform init
terraform plan
# Plan: 21 to add, 0 to change, 0 to destroy.
Cleanup
If you went ahead and created any resources, please don't forget about this, your future self will thank you.
Delete any file uploaded to the S3 bucket(s).
Then delete TF-managed resources by running
terraform destroy
from the corresponding directory.
Conclusion
As always, everything depends on your use case. While it is easier to use a well-documented mainstream solution, it may not make sense to you, your team or even your apps.
If you have a specific Infrastructure need to allocate custom resources, you can write your module by either reusing upstream Terraform registry modules or plain native resources.
And it is also true that you do not have to use modules at all, some applications' Infrastructure don't need complicated IaC code bases, just define plain resources and make it as simple as possible for you.
Whatever makes sense to you: try it out, fail often, iterate and you will for sure create extensible, maintainable, reusable and overall better code.
References
The examples in this blog are here.
Thank you for stopping by! Do you know other ways to do this? Please let me know in the comments, I always like to learn how to do things differently.