How To: Compose One Variable’s Value From Several Files (Per-Customer/Per-Ring Fragments)
You have one value — e.g. an Azure Application Gateway’s appgateway_config, a deeply nested
map(object({ hosts, path_rules, waf_policy, ... })) Terraform variable — that different
customers, teams, or rings need to contribute their own piece of, from their own file, without
editing one shared blob every time someone onboards. kind: environment’s spec.variables
(store: constant) can’t do this: two Environment documents declaring the same variable key
have the second one’s value win entirely (whole-value override, not a combine) — see
docs/design/composite-variable-merge.md for the full
investigation. This guide covers the pattern that already solves it today, with no new strata
code.
The short answer
Author the shared value under spec.properties, not spec.variables. Split it across as
many Environment documents as you have contributors (one per customer, one per ring, whatever
your real ownership boundary is) — each document sets only its own fragment, nested at whatever
path it owns. Every properties dict a reachable Environment declares is recursively deep
merged (Workspace → each reachable Environment, in order → the Deployment’s own), not
overridden — confirmed directly against real code
(merge_workspace_environment_deployment_properties(),
strata/controllers/value_controller.py) and
experimentally verified by actually running three
fragment documents through it. The merged result is delivered to Terraform exactly like any
other property — written straight into properties.auto.tfvars.json, which Terraform
auto-loads with no -var-file flag needed.
Worked example
Three separate files, three separate owners, one combined appgateway_config.
customers/unisonplanning/environment.yaml — owned by the unisonplanning team, declares
their own domain and their first ring:
apiVersion: strata.huybrechts.xyz/v2
kind: environment
meta:
name: unisonplanning-agw
spec:
properties:
appgateway_config:
unisonplanning:
domain: "unisonplanning.com"
hosts:
c0224:
domain_name: "c0224-agw-dev.westeurope.cloudapp.azure.com"
backend_pools: ["apim", "dispatcher-dev", "datahub-admin-dev"]
customers/unisonplanning/ring-c0225.yaml — a later onboarding, adding a second ring to
the same customer, from its own file — this is the real scaling case (“add a new ring to a
customer” shouldn’t mean editing someone else’s file):
apiVersion: strata.huybrechts.xyz/v2
kind: environment
meta:
name: unisonplanning-agw-ring2
spec:
properties:
appgateway_config:
unisonplanning:
hosts:
c0225:
domain_name: "c0225-agw-stage.westeurope.cloudapp.azure.com"
backend_pools: ["apim", "dispatcher-stage"]
customers/acmecorp/environment.yaml — a different customer entirely, owned by a different
team, contributing their own top-level key:
apiVersion: strata.huybrechts.xyz/v2
kind: environment
meta:
name: acmecorp-agw
spec:
properties:
appgateway_config:
acmecorp:
domain: "acmecorp.com"
hosts:
c0300:
domain_name: "c0300-agw-dev.westeurope.cloudapp.azure.com"
backend_pools: ["apim"]
The deployment lists all three, in any order that doesn’t matter here (none of these fragments collide on the same leaf key):
apiVersion: strata.huybrechts.xyz/v2
kind: deployment
meta:
name: hub-agw
spec:
workspace: hub-agw-workspace
environments:
- unisonplanning-agw
- unisonplanning-agw-ring2
- acmecorp-agw
The result, exactly what properties.auto.tfvars.json would contain — and exactly what
terraform plan sees as var.appgateway_config:
{
"appgateway_config": {
"unisonplanning": {
"domain": "unisonplanning.com",
"hosts": {
"c0224": { "domain_name": "c0224-agw-dev.westeurope.cloudapp.azure.com", "backend_pools": ["apim", "dispatcher-dev", "datahub-admin-dev"] },
"c0225": { "domain_name": "c0225-agw-stage.westeurope.cloudapp.azure.com", "backend_pools": ["apim", "dispatcher-stage"] }
}
},
"acmecorp": {
"domain": "acmecorp.com",
"hosts": {
"c0300": { "domain_name": "c0300-agw-dev.westeurope.cloudapp.azure.com", "backend_pools": ["apim"] }
}
}
}
}
Nothing was dropped — both rings under unisonplanning, both customers side by side — because
every fragment nests at a different path. Onboarding a fourth customer, or a third ring, means
adding one more small file and one more line in spec.environments — never touching the other
owners’ files.
Rules that actually matter
Merge order is
environments:list order, later wins per leaf key. A Tenant’s ownenvironmentsare already folded in ahead of a Deployment’s own list (reachable_environments()), so the full precedence is: Workspace’s ownproperties→ Tenant’senvironments, in order → Deployment’s ownenvironments, in order → the Deployment’s ownproperties. If two fragments genuinely need to set the exact same leaf key, the one named later always wins — silently, with no collision warning. Keep fragments non-overlapping by construction (each customer/ring owns its own subtree) rather than relying on ordering to resolve a real conflict.Dicts merge recursively; lists do not.
deep_merge()(strata/utils/dict_merge.py) merges nesteddictvalues at any depth, but a list-valued leaf (likebackend_poolsabove) is replaced wholesale, not concatenated or deduped. Never have two different fragments both set the same list-valued field expecting them to combine — one will silently and completely discard the other’s list. Keep list-valued fields inside the deepest leaf a single fragment owns (as in the example: each ring owns its ownbackend_pools), never at a level two different fragments both touch.Strata stays opaque to what’s inside the value (ADR-0025). Nothing here validates
domain_name/backend_pools/waf_policyshape — a typo inside a fragment is invisible to strata; the real Terraform module’s ownvariabletype constraint is the only thing that will ever catch it, atterraform plantime.This is Terraform-specific as confirmed so far —
properties/customare delivered viaproperties.auto.tfvars.json/custom.auto.tfvars.json. Whether the same convention reaches Helm/Compose targets the same wayspec.variablesdoes has not yet been checked; don’t assume it does for a non-Terraform provisioner without verifying first.You give up
VariableStoreModel’s typed wrapper (type/description, HCL-emission hints) by moving a value out ofspec.variables. If a real variable actually relies on those (a declaredtype: map/type: listcross-check, or documentationdescription), keep it underspec.variablesinstead — this pattern is for values that need composing from several files, not a wholesale replacement forspec.variables.