5 Terraform built-in functions to improve your code

Terraform built-in functions are incredibly powerful if you use them wisely. Take a look at 5 functions that helped me improve my Terraform code.

5 Terraform built-in functions to improve your code

After a while, you'll certainly have tried everything possible to improve your Terraform code. You are probably pretty good at it, but there is one thing missing. I've been using Terraform for more than a year now, I've not improved a lot after a while. You tend to keep using what you know. One day, I decided to look at the documentation and found some exotic new functions.

I'll tell you all about it. There are functions that help me create beautiful tags for my resources, ones that help me create clean and variable modules and other that are just here to show up.

So let's get started, what can you do with Terraform that you probably didn't know you could. I'll show you all the functions that I found in the documentation that help me!

Merge

You probably know this function, but let me explain to you how to use it to create beautiful and useful tags.  You'll probably learn a thing or two with this.

merge takes an arbitrary number of maps or objects, and returns a single map or object that contains a merged set of elements from all arguments.

One example is better than a thousand words.

> merge({a="b", c="d"}, {e="f", c="z"})
{
  "a" = "b"
  "c" = "z"
  "e" = "f"
}

Did you notice it ?!
c is declared twice, but the last value is merged. Do you see where I'm going with my tags now?

Let's try it with some tags now.

Let's declare some default tags in our code with a local  for example.

locals {
  default_tags = {
    managed     = "terraform"
    environment = ""
    application = ""
    monitored = "true"
  }
}

Now we take a variable as input that we will call tags.

tags = {
	environment = "prod"
	application = "primates"
	monitored   = "false"
}

Now we use the merge functions in two ways:

  1. Local first, then tags.
  2. Tags then local.
> merge(local.default_tags, var.tags)
{
	managed     = "terraform"
	environment = "prod"
	application = "primates"
	monitored = "false"
}
Local First, then tags

Now we do the tags first and then the locals. Do you guess what the difference will be?

> merge(local.default_tags, var.tags)
{
	managed     = "terraform"
	environment = "prod"
	application = "primates"
	monitored = "true"
}
Tags then locals

One has monitored = "true" and the other has monitored = "false". It only depends on the order in which you put the inputs in the merge functions. Happy tagging :D


Lookup

You probably know the Terraform built-in function element that helps you retrieved a value in a list given its position. Now I'll show you how to use lookup that does almost the same thing but for an object.

lookup retrieves the value of a single element from a map, given its key. If the given key does not exist, the given default value is returned instead.

We will use our merged variable for this example.

tags = {
	managed     = "terraform"
	environment = "prod"
	application = "primates"
	monitored = "true"
}
Tags variable

We'd like to retrieve the monitored value to know if we have to deploy a certain type of resources in addition. Therefor we can use the Terraform built-in function I mentioned above call lookup

> lookup(var.tags, "monitored", "false")
true

## Now for a key that doesn't exist.
> lookup(var.tags, "shut_down_at_night", "false")
false

With the lookup function, you can easily find values that exist or not and apply certain parameters to your resources with it. The default value that it gives you allow you not to have any errors if the value doesn't exist. Pretty useful if you are creating a custom module that can be used by anybody.


Try

try is a particular function. Not often used, however it helps you when you have complex data structures. I recommend using it when you don't know if the data exist or how it is going to be formed.

try evaluates all of its argument expressions in turn and returns the result of the first one that does not produce any errors.

In the following example, we take an input of type any. Could be either a list or a string. How do you do normally?

Let's take a look at the example

variable "example" {
  type = any
}

locals {
  example = try(
    [tostring(var.example)],
    tolist(var.example),
  )
}

It is particularly useful in this example. If it is a string, then we create a list with a single element and so on.

Pretty useful, don't you think, to create user-friendly modules?

Coalesce

I'm confident that you once defined a default value using a conditional expression. Look no more. coalesce is a pretty useful function.

coalesce takes any number of arguments and returns the first one that isn't null or an empty string.

In our example, we create an Azure Function App using Terraform. We either use a custom_name given to us by the user or a default value that is generated in the locals using some rules.

resource "azurerm_function_app" "this" {
  name = coalesce(lower(var.function_app_custom_name), local.function_default_name)

  ....
  }

Pretty useful! I'll let you find how you can use it. However, the possibilities are endless.

Dynamic

It is technically not a function but a block. But I could not talk about those functions without talking about dynamic. I don't recall exactly when it was introduced to Terraform. However, it is a game changer!

You probably have some resources that have nested blocks, you know the ones with those objects that are colossal and complicated. Usually ones with your app settings or your network configuration. What if you could define default values and create clean code ?!

resource "aws_elastic_beanstalk_environment" "tfenvtest" {
  name = "tf-test-name" # can use expressions here

  setting {
    # but the "setting" block is always a literal block
  }
}

This is the bad way.
Now let's look at a better way to define a block.

resource "aws_elastic_beanstalk_environment" "tfenvtest" {
  name                = "tf-test-name"
  application         = "${aws_elastic_beanstalk_application.tftest.name}"
  solution_stack_name = "64bit Amazon Linux 2018.03 v2.11.4 running Go 1.12.6"

  dynamic "setting" {
    for_each = var.settings
    content {
      namespace = setting.value["namespace"]
      name = setting.value["name"]
      value = setting.value["value"]
    }
  }
}

You can even use nested blocks in the dynamic block.

Now with a more useful example

resource "azurerm_function_app" "this" {
  name = coalesce(lower(var.function_app_custom_name), local.function_default_name)

  app_service_plan_id        = var.app_service_plan_id
  location                   = var.location
  resource_group_name        = var.resource_group_name
  storage_account_name       = var.storage_account_name
  storage_account_access_key = var.storage_account_primary_access_key
  os_type                    = var.os_type

  app_settings = merge(
    local.default_application_settings,
    var.function_app_application_settings,
  )


  dynamic "site_config" {
    for_each = [merge(local.default_site_config, var.site_config)]
    content {
      always_on                   = lookup(site_config.value, "always_on", null)
      ftps_state                  = lookup(site_config.value, "ftps_state", null)
      http2_enabled               = lookup(site_config.value, "http2_enabled", null)
      ip_restriction              = lookup(site_config.value, "ip_restriction", null)
      linux_fx_version            = lookup(site_config.value, "linux_fx_version", null)
      min_tls_version             = lookup(site_config.value, "min_tls_version", null)
      pre_warmed_instance_count   = lookup(site_config.value, "pre_warmed_instance_count", null)
      scm_ip_restriction          = lookup(site_config.value, "scm_ip_restriction", null)
      scm_type                    = lookup(site_config.value, "scm_type", null)
      scm_use_main_ip_restriction = lookup(site_config.value, "scm_use_main_ip_restriction", null)
      use_32_bit_worker_process   = lookup(site_config.value, "use_32_bit_worker_process", null)
      websockets_enabled          = lookup(site_config.value, "websockets_enabled", null)

      dynamic "cors" {
        for_each = lookup(site_config.value, "cors", []) != [] ? ["fake"] : []
        content {
          allowed_origins     = lookup(site_config.value.cors, "allowed_origins", [])
          support_credentials = lookup(site_config.value.cors, "support_credentials", false)
        }
      }
    }
  }

As you can see, we used lookup in our dynamic block in order to easily and cleanly look into our site_config object. We set some default values also. Used the merge function for the for_each of our dynamic and a coalesce for the name.

There is one missing, the try function :D I used it in the outputs !

I hope that you've learned something. Those functions had a huge impact on the quality of the Terraform code that I produced. Those are particularly useful if you try to do clean code that can be easily reusable.

Let me know if you have any ideas on how to improve your code. If you have any insight on some good functions that I've missed, please let me know in the commentaries.