Working with Terraform Templates
This is the task-oriented guide. For the grammar itself, see Template System; for how templates drive the wizard, see Service Catalog.
The mental model
openprime-infra-templates is a working Terraform repository, not a set of
fragments. Clone it, terraform init -backend=false, terraform validate — it
passes. Every value in it is a real default that parses.
OpenPrime generates a customer repository by copying that tree and rewriting individual values. Nothing is assembled; things are edited.
Two consequences follow, and both catch people out:
- A template's default is not a placeholder — it ships. If a parameter never arrives, the customer gets whatever the template said. That is not a crash, it is a silent wrong answer.
- A change to
mainis live. Production clones the templates repository unpinned, so a merge reaches the next customer generate with no release and no staging step.
Layout
templates/
├── terraform/
│ ├── aws/ # the infrastructure stack
│ │ ├── terraform.auto.tfvars # ← almost every @param lives here
│ │ ├── _variables.tf
│ │ ├── _config.tf # backend "s3" { … use_lockfile = true }
│ │ ├── helm_values.tf
│ │ ├── eks.tf, vpc.tf, rds.tf, karpenter.tf, …
│ └── kubernetes/ # the in-cluster stack (ArgoCD, support)
├── argocd/
│ ├── applications.yaml # which charts the cluster gets
│ ├── values/ # per-chart values; some are .tftpl
│ ├── charts/
│ └── example-apps/
└── .github/workflows/ # the pipeline the CUSTOMER receives
Plain .tf and .yaml files. No .tpl extension of ours — anything under
.terraform/ is a downloaded module.
templates/.github/ is output, not our CItemplates/.github/workflows/terraform-deploy.yml becomes the customer's pipeline.
The templates repository's own CI is .github/workflows/templates-ci.yml at the
repository root. Editing the wrong one is a common first mistake.
Why parameters cluster in one file
terraform.auto.tfvars holds nearly every @param in the AWS stack. That gives
the catalog extractor a single canonical file to scan, and gives a reviewer one
place to see the entire surface the wizard can drive.
Adding a parameter
Say you want the wizard to control the VPC CIDR.
1. Put the decorator above a real value line.
# @param services.vpc.cidr | displayName=VPC CIDR | description=Network range for the VPC | type=text | pattern=^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$
vpc_cidr = "10.0.0.0/16"
The line below the decorator must be a value line — key = value or key: value.
A decorator above a comment, a blank line, or a block opener parameterises nothing.
2. Make sure the module has an enable gate.
# @section services.vpc.enabled begin
module "vpc" { … }
# @section services.vpc.enabled end
3. Send it from the backend. The path must exist in prepareInjectoData()'s
output. Service fields under services.<key>.* are carried automatically for
enabled services; a new top-level field needs an explicit line.
4. Run the gate.
git clone https://github.com/devopsgroupeu/Injecto /tmp/injecto
pip install -r /tmp/injecto/requirements.txt
python tests/gate.py \
--injecto /tmp/injecto \
--fixture tests/fixtures/standard.json \
--out-dir generated
5. Check the catalog side. In openprime-app, npm run catalog:check.
The generation gate
The gate exists because Injecto exits 0 on failures it only logs. It fails on:
| Code | Meaning |
|---|---|
UNRESOLVED_PARAM | A parameter under an enabled service did not resolve — the template default would ship to the customer |
NOT_SUBSTITUTED | It resolved, but the output still carries the template default |
NEW_INERT_PARAM | A decorator sits above a line substitution can never rewrite — it parameterises nothing |
DROPPED_FILE | A file threw during processing and vanished from the output |
FILE_COUNT_MISMATCH / MISSING_OUTPUT | The output tree does not match the input tree |
UNSAFE_DEFAULT | A secure-by-default value was weakened |
NETWORK_POLICY_INERT | Network policy YAML that the CNI would not enforce |
Then terraform fmt -check -recursive and terraform validate on the generated
tree — because the customer's own pipeline runs fmt -check as its first job,
so anything failing here is a red first pipeline for them.
tests/fixtures/standard.json enables no Helm charts. Every chart block is
therefore @section-commented out of the generated tree in every gate run, so the
Helm and ArgoCD value paths are not exercised by CI at all. Two production defects
have already lived behind a green gate for that reason. If you change anything
under templates/argocd/values/, test it by hand.
Testing a specific configuration
The fixture is a JSON file in prepareInjectoData() shape. Copy it, change what
you want to exercise, and point the gate at your copy:
python -c "
import json
d = json.load(open('tests/fixtures/standard.json'))
d['services']['eks']['helmCharts'] = {'externalDns': {'enabled': True}}
json.dump(d, open('/tmp/charts.json','w'), indent=2)
"
python tests/gate.py --injecto /tmp/injecto --fixture /tmp/charts.json --out-dir /tmp/out
Then read the generated tree. Reading the output is the test — the exit code is a summary of the checks that happen to be implemented.
local/terraforge.sh is not in the repositoryEarlier versions of this guide told you to test with ./terraforge.sh in
local/. That directory is untracked — git ls-files local/ returns nothing, so
a fresh clone does not have it. Use tests/gate.py.
Rendered values files (.tftpl)
Some ArgoCD values files are Terraform templates rather than static YAML, rendered
by templatefile() at apply time with values assembled in helm_values.tf:
locals {
ingress_domain_set = var.ingress_domain != ""
external_dns_domain_filters = local.ingress_domain_set
? jsonencode([var.ingress_domain])
: jsonencode(["none.invalid"])
}
Two substitution systems are in play. Injecto rewrites var.ingress_domain's value
in terraform.auto.tfvars; Terraform then interpolates it into the .tftpl. A
change that looks right in the .tftpl can still be inert because the value never
arrived.
domainFilters: [] reads like "restrict external-dns to nothing". It is the
opposite: an empty list drops --domain-filter from the container arguments
entirely, and external-dns with no filter considers every hosted zone in the
account. The fallback is a reserved .invalid domain for exactly this reason.
Before writing an empty-value fallback, render the chart and read the arguments.
Conventions
- Pin module and provider versions. An unpinned constraint has already resolved to an unexpected major in production.
- Give every
@paramadisplayNameanddescription— they are the wizard's label and help text, not decoration. - Secure defaults belong in the template, guarded by
UNSAFE_DEFAULT. - Never hardcode a domain, account ID or bucket name. If it belongs to a customer, it is a parameter.
- Verify on the generated tree, not on the template. The two differ by exactly the thing you are trying to test.
Related
- Template System — grammar and failure semantics
- Service Catalog — how a
@parambecomes a wizard field - Cloud credentials — the IAM policy the generated stack needs