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.