Skip to main content

Terraform - Making Different Resource Types Based on Variable Input

·1734 words·9 mins

The biggest challenge I see people have with Terraform is around logic or conditions. Because it’s declarative, you can’t do something like “check for resource X, and create it if it doesn’t exist.”

However, you do have the ability to specify optional resources in a module, that may or may not be created depending on the variables you supply in your configuration.

In this post I’ll cover some methods for doing that, and as an example I’ll show you how we wrote our DNS/IPAM module to allow us to create one of three different types of DNS resources (A, CNAME, and HOST) depending on what we specify. You can skip to the end if you just want to see the final terraform module as an example.

Optionally Creating Resources #

The tools for optionally creating a resource block in Terraform are the count and for_each meta-arguments. While these arguments are normally used to create multiple instances of a single resource type, they also allow you the flexibility of creating a single instance or no instances at all if an empty list or map is passed to the argument.

I’m going to write a separate post comparing the count and for_each arguments, but for this example I’m going to stick with count because it’s simpler to read and understand.

The basic format of the count argument is that you want to create X number of resources or modules, where X is a number that you supply, normally by using the length() function on a list variable. The example Terraform uses in their documentation is as follows:

variable "subnet_ids" {
  type = list(string)
}

resource "aws_instance" "server" {
  # Create one instance for each subnet
  count = length(var.subnet_ids)

  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"
  subnet_id     = var.subnet_ids[count.index]

  tags = {
    Name = "Server ${count.index}"
  }
}

The element of this argument that we take advantage of is the fact that count can take 1 or 0 as it’s input. Meaning it can create a single instance or no instances at all. If you have a variable that’s an empty list and you use count, nothing will be created (and more importantly it doesn’t throw an error):

locals {
  subnet_ids = []
}

resource "aws_instance" "server" {
  # Create one instance for each subnet
  count = length(local.subnet_ids)

  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"
  subnet_id     = var.subnet_ids[count.index]

  tags = {
    Name = "Server ${count.index}"
  }
}

In the example above, we’re creating a local variable called subnet_ids that is an empty list. When we then go to create our server object, the length(local.subnet_ids) resolves to 0, so no servers are created.

Using locals to set up an optional element #

If you are optionally creating an element then it’s implied that you are writing the logic for the resources within a module that you will call from your configuration. This adds a little extra complexity as you have to write your module knowing you may or may not be receiving a variable for the input, which you do by specifying an input variable with a default value of null.

The null value does not evaluate the same as an empty map or list that you would use for your dynamic elements, and you cannot use count or for_each on a variable with a null value. Because of this, you have to perform some logic in a locals block of your module to prep your inputs.

You do this by using an if statement to create an empty list or map variable based on whether you input variable is null. If it’s not, then you set the the variable to match your current input:

locals {
    # Is var.input_variable null? If so, set servers_to_create as an empty list [], else make it a copy of var.input_variable
    server_to_create = var.input_variable == null ? [] : var.input_variable
}

Then once we have our local.server_to_create variable, we just construct our resource/module blocks using our meta-arguments demonstrated above. If the variable wasn’t supplied, the resource wont be created.

Tip: It’s a good practice to always add comments to anything written in the locals block.

Locals is usually where logic is being performed and that can be the most difficult part of terraform to interpret, so always adding an explanation of what you are doing is very helpful in any team setting.

Putting it together to dynamically create different resources based on input #

So now that we know how to dynamically create a single resource, we can put this together with some additional logic to create a module that will create one of three different types of resources depending on what’s specified.

We are currently using this technique in our environment to create our DNS/IPAM resources. This made sense to us because every resource we create will get at least one type of record and we didn’t want to maintain separate modules for each because they are so small/simple. This allows us to have one module we re-use for our different services to create whichever record type we need.

For some background, we use Infoblox as our IPAM product, in addition to your regular A and CNAME (and other DNS) records. Infoblox has something called a HOST record that it uses to combine a A records and DHCP lease information and some other stuff. (In case your wondering what a HOST record is.)

The resources we want the option to make each have different input variables:

  • A record
    • IP Address
    • Fully Qualified Domain Name
    • (Optional) Comment
  • CNAME record
    • Alias
    • Canonical Name
    • TTL
    • (Optional) Comment
  • HOST record
    • IP Address
    • Fully Qualified Domain Name
    • (Optional) Comment

Because I need to account for any information I might need, I created separate input variables for all of the individual properties and if it wasn’t something needed by all three elements, I made it an optional variable. So I take the following input:

  • IP Address (Optional, only needed for HOST/A records)
  • FQDN (Required, can be used as FQDN in HOST/A records and Canonical Name for a CNAME.)
  • Comment (Optional)
  • Alias (Optional, only CNAME)
  • TTL (Optional, only CNAME)
  • Record Type (Required, this is the input that will tell the module what to create. I use validation to make sure it’s either “A”, “CNAME”, or “HOST”)

Here’s where our first bit of logic is required, I know each record type takes different inputs. And I want to make it clear what is required for each. So I started by creating a map that consists of each record type, here’s what it looks like:

locals {
  record_templates = {
    A = {
      fqdn    = var.fqdn
      ip_addr = var.ip_address
      comment = var.comment
    }
    CNAME = {
      alias     = var.alias
      canonical = var.fqdn
      ttl       = var.ttl
      comment   = var.comment
    }
    HOST = {
      fqdn      = var.fqdn
      ipv4_addr = var.ip_address
      comment   = var.comment
    }
}

Now that I have that, here’s where I use some logic to create the variables I need to generate resources. Remember, the module will specify a record type, and using our methods above we are going to use for_each to create a single instance of that record type, or no instances of the records that don’t match.

So included in my locals block, I am creating the variables that I need to run my meta-arguments:

locals {
  a_record_to_create     = var.record_type == "A" ? { "A" = local.record_templates[var.record_type] } : {}
  host_record_to_create  = var.record_type == "HOST" ? { "HOST" = local.record_templates[var.record_type] } : {}
  cname_record_to_create = var.record_type == "CNAME" ? { "CNAME" = local.record_templates[var.record_type] } : {}
}

Notice I create a separate variable that represents each type of record I might create. If the supplied var.record_type matches that resource type, it generates a map consisting of the properties we specified for that type in our local.record_templates variable, otherwise it creates an empty map variable.

Now that we have these, it’s just a matter of creating the resource blocks and configuring them with for_each based on the local variable that represents that type.

Final Product #

To summarize, we use local variables to create a template for each resource type we might want to make, then make a map variable with a single key to create one instance of the resource type we want. We set the local variables for the other resource types to an empty map so the for_each argument doesn’t create anything.

Here’s what it looks like:

# The locals block where we do our logic
locals {
  record_templates = {
    A = {
      fqdn    = var.fqdn
      ip_addr = var.ip_address
      comment = var.comment
    }
    CNAME = {
      alias     = var.alias
      canonical = var.fqdn
      ttl       = var.ttl
      comment   = var.comment
    }
    HOST = {
      fqdn      = var.fqdn
      ipv4_addr = var.ip_address
      comment   = var.comment
    }

  }
  a_record_to_create     = var.record_type == "A" ? { "A" = local.record_templates[var.record_type] } : {}
  host_record_to_create  = var.record_type == "HOST" ? { "HOST" = local.record_templates[var.record_type] } : {}
  cname_record_to_create = var.record_type == "CNAME" ? { "CNAME" = local.record_templates[var.record_type] } : {}
}

# The three potential resource blocks, only one of these will be created

resource "infoblox_a_record" "a_record_static" {
  for_each = local.a_record_to_create

  fqdn    = local.a_record_to_create[each.key].fqdn
  ip_addr = local.a_record_to_create[each.key].ip_addr
  comment = local.a_record_to_create[each.key].comment

  dns_view = "Internal"
}

resource "infoblox_cname_record" "cname_record" {
  for_each = local.cname_record_to_create

  alias     = local.cname_record_to_create[each.key].alias
  canonical = local.cname_record_to_create[each.key].canonical
  ttl       = local.cname_record_to_create[each.key].ttl
  comment   = local.cname_record_to_create[each.key].comment

  dns_view = "Internal"
}

resource "infoblox_ip_allocation" "ip_allocation" {
  for_each = local.host_record_to_create

  fqdn      = local.host_record_to_create[each.key].fqdn
  ipv4_addr = local.host_record_to_create[each.key].ipv4_addr
  comment   = local.host_record_to_create[each.key].comment

  dns_view = "Internal"
}

# The input variables, with validation on the record_type

variable "record_type" {
  description = "(Required) The type of record that needs to be created. Acceptable values are A, HOST, or CNAME."
  type        = string

  validation {
    condition     = var.record_type == "A" || var.record_type == "HOST" || var.record_type == "CNAME"
    error_message = "Infoblox record_type is not equal to A, HOST, or CNAME."
  }
}

variable "fqdn" {
  description = "(Required) The fqdn assigned to the record."
  type        = string
}

variable "ip_address" {
  description = "(Required for Host or A records) The IP Address associated with the record."
  type        = string
  default     = null
}

variable "alias" {
  description = "(Required for CNAME records) The alias to assign the FQDN to."
  type        = string
  default     = null
}

variable "ttl" {
  description = "(Optional for CNAME records) The TTL of the record."
  type        = number
  default     = null
}

variable "comment" {
  description = "(Optional) Comment of the record in Infoblox."
  type        = string
  default     = null
}

Related

Terraform - Azure Managed Disks with For Each
·1204 words·6 mins
We have a use case with Azure where we want to support the ability to add, remove or resize additional data disks on a VM.
Azure Queue Storage Basics
·1859 words·9 mins
This post will cover the basics of setting up an Azure Queue and using APIs to interact with it.
Git Basics
·2169 words·11 mins
I will admit up front, there is a bit of imposter syndrome that comes with posting this, as I do not consider myself an expert in using git by any means.