Terraform Tricks: simulating conditional logic

September 27, 2016
terraform

I have been using Terraform in production since version 0.4.0, and most of that experience has been pretty awesome. Especially considering Terraform is still pre-1.0 software. However, Terraform does have some shortcomings here and there. This series of ‘Terraform Tricks’ blogs will be about working around some of the shortcomings of Terraform, as well as exploring some of the less-known (or undocumented) awesomeness.

Background: evolving platforms

At my current project I’m building a container platform on AWS, and I use Terraform to deploy it. However, since we’re using all kinds of new technology that is still evolving heavily, we have completely re-worked our stack multiple times in the past year. It’s one of the hazards of working with cutting-edge platforms.

Currently, we’re in the process of transitioning from a setup where we have a single platform that contains everything, to a setup where each environment, or stage, consists of at least 2 dedicated, isolated platforms. The inner workings of our platform remain largely unchanged, but on a Terraform level, the changes are massive.

Both in an attempt to reduce duplication, and simply to challenge ourselves, we try to adapt our Terraform modules for things like RDS databases, S3 buckets, ElasticSearch clusters, etcetera so that they work with both the old and the new setups. And sometimes that is quite the challenge.

Use case: backwards compatibility in a module

Consider a Terraform module for creating RDS database instances. On the current platform, its implementation would look something like this:

module "rds-foo-dev" {
  ...
  
  platform_name = "${var.platform_name}"
  stage = "dev"
  db_name = "foo"
  db_subnet_group_name = "${aws_db_subnet_group.rds.name}"
  rds_security_group_id = "${aws_security_group.dev-rds.id}"
}

The resulting RDS instance would get a name using the following scheme:

<platform_name>-<db_name>-<stage>

For instance: infra-prod-foo-dev.

The module implements this in the following way:

resource "aws_rds_instance" "db" {
  identifier = "${var.platform_name}-${var.db_name}-${var.stage}"
  ...
}

Now on our new setup, we no longer use platform_name, but both db_name and stage persist. The naming scheme also changes, to:

<stage>-<db_name>

Challenge: simulating conditional logic in Terraform

If Terraform had supported conditional logic, like if-statements, life would have been simple. I could write something like this:

if ( ${var.platform_name} != "" ) {
  identifier_string = "${var.platform_name}-${var.db_name}-${var.stage}"
} else {
  identifier_string = "${var.stage}-${var.db_name}"
}

resource "aws_db_instance" "db" {
  identifier = "${identifier_string}"
  ...
}

Or something like that. But unfortunately, Terraform doesn’t support this kind of conditional logic. So, is there really no way to make this work?

Tricks to the rescue!

If we look at the RDS identifier string, we see it consists of 2 or 3 parts:

  • Platform Name (optional, platform_name) -> A
  • Stage Name (stage) -> B
  • Database Name (db_name) -> C

For the existing setup, we need both A, B, and C and the formula is: id = A-C-B

For the new setup, we need just B and C, and A will be unset. The formula is: id = B-C

The key here is that we know that on the new setup, A will be unset. So we could combine the two formula’s as follows:

field 1: A or B
field 2: C
field 3: B (if A is set) or empty (if A is unset)

So:

id = (A or B) - C - (( A => B ) or ( !A => !B))

Great! Now how do we implement that in Terraform? Well, a little like this:

resource "aws_db_instance" "db" {
  identifier = "${join("-", distinct(list(coalesce(var.platform_name, var.stage), var.db_name, var.stage)))}"
  ...
}

Hold the phone. What exactly just happened? What’s all this join, distinct, coalesce stuff, and why is var.stage in there twice? Let me break it down:

  1. coalesce takes a list of strings and picks the first string that is not empty. In this case, if platform_name is set, it will pick that, and otherwise it will pick stage. This implements the logic for field 1
  2. list takes a set of strings, and turns them into a list
  3. distinct is the magic trick for field 3. If platform_name (A) is unset, field 1 will default to stage (B), in which case field 3 should be empty. To simulate this conditional logic, we let field 3 default to stage and then apply the distinct function. This function will iterate over a list, and remove every subsequent occurrence of an item (in this case stage). So if field 1 defaults to stage, the distinct function will remove stage from field 3, making it empty
  4. join wraps everything, and adds the dashes - in between the fields. join("-", A, B, C) would result in A-B-C.