OpenTofu: handcrafted include-file mechanism with YAML
I recently started playing with Terraform/OpenTofu almost on a daily basis.
The other day I was working with Amazon Managed Prometheus (or AMP), and wanted to define prometheus alert rules on YAML files.
I decided that I needed a way to put the alerts on a bunch of files, and then load them by the declarative code, on the correct AMP workspace.
I came up with this code pattern that I’m sharing here, for my future reference, and in case it is interesting to someone else.
The YAML file where I specify the AMP workspace, and where the alert rule files live:
---
alert_files:
my_alerts_production:
amp:
workspace: "production"
files: "alert_rules/production/*.yaml"
my_alerts_staging:
amp:
workspace: "staging"
files: "alert_rules/staging/*.yaml"
Note the files
entry contains a file pattern. I will later expand the pattern using the fileset() function.
Each rule file would be something like this:
---
name: "my_rule_namespace"
rule_data: |
# this is prometheus-specific config
groups:
- name: "example_alert_group"
rules:
- alert: Example_Alert_Cpu
# just arbitrary values, to produce an example alert
expr: avg(rate(ecs_cpu_seconds_total{container=~"something"}[2m])) > 1
for: 10s
annotations:
summary: "CPU usage is too high"
description: "The container average CPU usage is too high."
I’m interested in the data structure mutating into something similar to this:
---
alert_files:
my_alerts_production:
amp:
workspace: "production"
alerts_data:
- name: rule_namespace_1
rule_data: |
# actual alert definition here
[..]
- name: rule_namespace_2
rule_data: |
# actual alert definition here
[..]
my_alerts_staging:
amp:
workspace: "staging"
alerts_data:
- name: rule_namespace_1
rule_data: |
# actual alert definition here
[..]
- name: rule_namespace_2
rule_data: |
# actual alert definition here
[..]
This is the algorithm that does the trick:
locals {
alerts_config = {
for x, y in {
for k, v in local.config.alert_files :
k => {
amp : (v.amp),
files : fileset("", v.files)
}
} : x => {
amp : (y.amp),
alertmanager_data : [
for z in(y.files) :
yamldecode(file(z))
]
}
}
}
Because the declarative nature of the Terraform/OpenTofu language, I needed to implement 3 different for loops. Each loop reads the map and transforms it in some way, passing the result into the next loop. A bit convoluted if you ask me.
To explain the logic, I think it makes more sense to read it from inside out.
First loop:
for k, v in local.config.alert_files :
k => {
amp : (v.amp),
files : fileset("", v.files)
}
This loop iterates the input YAML map in key-value pairs, remapping each amp
entry, and expanding the file globs using the
fileset()
into a temporal files
entry.
Second loop:
for x, y in {
# previous fileset() loop
} : x => {
amp : (y.amp),
alertmanager_data : [
# yamldecode() loop
]
}
This intermediate loop is responsible for building the final data structure. It iterates the previous fileset()
loop
to remap it calling the next loop, the yamldecode()
one. Note how the amp
entry is being “rebuilt” in each remap (first loop
and this one), otherwise we would lose it!
Third loop:
alertmanager_data : [
for z in(y.files) :
yamldecode(file(z))
]
And finally, this is maybe the easiest loop of the 3, we iterate the temporal file
entry that was created in the first loop,
calling yamldecode()
for each of the file names generated by fileset()
.
The resulting data structure should allow you to easily create resources later in a for_each loop.