Here’s what I came up with for a deep merge focused on this specific use-case:
variable "additional_tags" {
type = map(map(map(map(string))))
default = {}
}
locals {
default_tags = {
primary = {
aas = {
primary = {}
ha = {}
}
ci = {
primary = {}
ha = {}
}
db = {
primary = {}
ha = {}
}
worker = {
primary = {}
ha = {}
}
}
dr = {
aas = {
primary = {}
ha = {}
}
ci = {
primary = {}
ha = {}
}
db = {
primary = {}
ha = {}
}
worker = {
primary = {}
ha = {}
}
}
}
merged_tags = {
for env, apps in local.default_tags : env => {
for app, tiers in apps : app => {
for tier, tags in tiers : tier => merge(
tags,
lookup(
lookup(
lookup(
var.additional_tags,
env, {(app) = {}},
),
app, {(tier) = {}},
),
tier, {},
)
)
}
}
}
}
output "merged_tags" {
value = local.merged_tags
}
This depends on a change coming in 0.12.7 (due soon) to allow lookup
to work with nested data structures, so unfortunately it won’t work with the 0.12.6 release that is current at the time I write this, but I did verify it against a build from the latest master
branch.
I wrote a .tfvars
file containing the following:
additional_tags = {
primary = {
db = {
primary = {
name = "baz"
}
}
}
}
After applying it, I got the following result:
Outputs:
merged_tags = {
"dr" = {
"aas" = {
"ha" = {}
"primary" = {}
}
"ci" = {
"ha" = {}
"primary" = {}
}
"db" = {
"ha" = {}
"primary" = {}
}
"worker" = {
"ha" = {}
"primary" = {}
}
}
"primary" = {
"aas" = {
"ha" = {}
"primary" = {}
}
"ci" = {
"ha" = {}
"primary" = {}
}
"db" = {
"ha" = {}
"primary" = {
"name" = "baz"
}
}
"worker" = {
"ha" = {}
"primary" = {}
}
}
}
Generally when decomposing configuration into modules I try to keep the tag handling centralized in the root module and only pass individual modules the specific leaf set of tags they need, rather than passing around a big data structure of tags for all systems, but this somewhat-awkward nested for
expression allows merging the big data structure on the assumption that the structure is always consistently-shaped (it’s always a map(map(map(map(string))))
, not a mixture of different types at different levels) and that the default_tags
structure defines exhaustively all of the possible environment, application, and tier names that additional_tags
is permitted to override.
Regarding the possibility of having a “deep merge” function built in to Terraform: in both Terraform and in other tools we’ve seen functionality like that which seems at first to be pretty simple but then gets overcome by edge-cases. The pertinent Terraform examples are the automatic merging of map variables that was removed in Terraform 0.12 (because it caused more confusion than benefit in practice) and the override files mechanism which is still supported but has grown quite a long and complex list of rules about how different constructs merge together.
I expect we would not try to implement a naive “deep merge” function in Terraform in the near future because it’s likely to fall into the same complexity hole: different folks would have different needs and expectations for what to do in cases where the two data structures don’t have exactly the same shape, whether non-map collections ought to merge or override each other, etc.
We’ll see over time if some common patterns emerge that we could build a function around, but we’d want to scope it very carefully so that it’s easy to predict how it will behave without reading multiple paragraphs of documentation about various caveats and edges. Since the ability to pass around deeply-nested maps in Terraform is still relatively new, we haven’t seen enough real-world examples yet to define what exactly that might look like. In the mean time, nested for
expressions are a (admittedly rather awkward) way to implement certain types of nested merging, when the two structures have a fixed shape.