Skip to main content

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:

  1. 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.
  2. A change to main is 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 CI

templates/.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:

CodeMeaning
UNRESOLVED_PARAMA parameter under an enabled service did not resolve — the template default would ship to the customer
NOT_SUBSTITUTEDIt resolved, but the output still carries the template default
NEW_INERT_PARAMA decorator sits above a line substitution can never rewrite — it parameterises nothing
DROPPED_FILEA file threw during processing and vanished from the output
FILE_COUNT_MISMATCH / MISSING_OUTPUTThe output tree does not match the input tree
UNSAFE_DEFAULTA secure-by-default value was weakened
NETWORK_POLICY_INERTNetwork 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.

A green gate is narrower than it looks

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 repository

Earlier 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.

An "empty" fallback is not always inert

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

  1. Pin module and provider versions. An unpinned constraint has already resolved to an unexpected major in production.
  2. Give every @param a displayName and description — they are the wizard's label and help text, not decoration.
  3. Secure defaults belong in the template, guarded by UNSAFE_DEFAULT.
  4. Never hardcode a domain, account ID or bucket name. If it belongs to a customer, it is a parameter.
  5. Verify on the generated tree, not on the template. The two differ by exactly the thing you are trying to test.