The 3 Rs: Reduce, Recycle, Repeat

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


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.

Did you find this article valuable?

Support Mariano González by becoming a sponsor. Any amount is appreciated!