Using Feature Flags within Terraform for conditional infrastructure

Using Terraform's 'count' to support feature flags for conditional infrastructure

Table Of Contents

Today I Explained

Resources within Terraform can be conditionally created but not by a toggle like enabled or disabled, but instead using the count meta-argument. This argument takes in whole numbers (>= 0) to determine how many of a given resource to create, and using a ternary operator you can convert a boolean to a integer for conditional resources:

resource "my_resource" "resource" {
  count = (var.feature_flag_enabled) ? 1 : 0
  # feature_flag_enabled ∈ {true, false}
}

variable "feature_flag_enabled" {
  type    = bool
  default = true
}

This will have a count of 0 if the flag is false, and 1 if the flag is true. Terraform doesn’t support implicit conversions of Booleans to Integers, which prevents us from using count = var.feature_flag_enabled.

We aren’t limited to just passing the feature flag as a single variable to a Terraform module, but can instead pass all of the enabled feature flags as a set to the Terraform module. Terraform supports validation on variables, allowing us to ensure that each flag passed is within the set of feature flags using set operations:

variable "features" {
  type    = set(string)

  validation {
    # ∀ features ∈ { policy_guard, expire_all }
    condition = length(setsubtract(var.features, [
      "policy_guard",
      "expire_all"
    ])) == 0
    error_message = "All features must be within the predefined set of features"
  }
}

If each of the feature flags is considered “false” by default, then all the possible feature flags can create a map containing only the value 0 (false). For each of the enabled feature flags, these can be set to 1 (true), with the two maps merged. Merging of the maps will overwrite any default value with the enabled feature flags.

locals {
  # Same as the array in the validation condition
  featureset = ["policy_guard", "expire_all"]

  default = { for feature in local.featureset : feature => 0 }
  enabled = { for key in var.features : key => 1 }
  flags   = merge(local.default, local.enabled)
  rflags  = { for key, value in local.flags : key => range(value) }
}

These pre-computed count values can then be leveraged within the count meta-argument, acting as the determining factor of whether a resource should be enabled or disabled:

resource "my_resource" "resource" {
  count = local.flags["policy_guard"]
}

When working within a dynamic block you can make use of the value rflags, which works the same as flags but instead is a map of sets (∀ rflags ∈ { ∅, {1} }). This is to make it easier when working with the for_each metadata-argument of the dynamic block:

# rflags = {
#   "expire_all" = tolist([])
#   "policy_guard" = tolist([
#     0,
#   ])
# }

data "aws_iam_policy_document" "bucket_policy" {
  # ...

  dynamic "statement" {
    for_each = local.rflags["policy_guard"]

    content {
      sid    = "DenyDeleteOfBucketPolicy"
      effect = "Deny"

      principals {
        type        = "AWS"
        identifiers = ["*"]
      }

      actions = [
        "s3:DeleteBucketPolicy",
      ]

      resources = [
        aws_s3_bucket.primary.arn,
      ]
    }
  }
}

A note on set vs variable feature flags

Comparing the two approaches, it can be difficult to see the value in the set based approach when the variable based feature flag provides capabilities like:

  • Built-in validation for removal of a feature flag, or identifying unused feature flags (unused variables)
  • Built-in validation on the variable as a boolean
  • Built-in documentation using the variable description, along with documentation tooling such as terraform-docs
  • Naming conventions (_enabled) & pre-existing conventions around conditional flags

These are capabilities that either don’t exist with the set-based approach, or are involved to add equivalent support. In practice, the advantages I’ve found with the set based approach is when the Terraform module that consumes the feature flag isn’t consuming the enabled feature flags as variables.

This can happen when the interface for a Terraform module is an artifact (yaml/json) that contains within it a set of the enabled feature flags. These are not guaranteed to be provided as a mapping (map[int]string), and instead are often provided as a set of strings.

You can see an example of such a file below which contains the features specified as a set (.spec.features):

version: 1.2.4
type: CloudApplicationBundle
spec:
  networking: {}
  images: []
  features:
    - policy_guard
    - expire_all
  volumes: {}

For these cases, the usage of a set pattern allows for flexibility when working with different ways of passing arguments to Terraform modules.