Template System Architecture
OpenPrime does not render templates. It edits them in place.
That distinction is the whole design. The files in openprime-infra-templates are
valid, working Terraform and YAML on their own — you can terraform init and
terraform validate the repository without OpenPrime ever touching it. Injecto
takes that working tree and rewrites individual values to match an environment's
configuration.
The consequence worth internalising: a template is never half a file waiting to be completed. It is a complete file whose values are placeholders that already parse.
openprime-infra-templates environment config (JSON)
valid .tf / .yaml files + from the backend
│ │
└──────────────┬───────────────┘
â–¼
Injecto processor
substitutes value lines,
comments out unused blocks
│
â–¼
generated repository
pushed to the customer's Git
The decorator grammar​
Injecto recognises exactly three decorators. The specification lives in
injecto/decorators.py, which both the substituter and the catalog extractor
import — they cannot drift apart, because there is only one set of regexes.
# @param <dot.path> [| key=value]...
# @module <dot.path> [| key=value]...
# @section <dot.path> begin
# @section <dot.path> end
The path is always the first token after the decorator name. Attributes, if any,
follow a | separator.
@foreach and no @ifEarlier versions of this page documented loop and conditional decorators. They
were never implemented. Repetition is expressed with Terraform's own for_each
and count; conditionals are expressed with @section or with Terraform
expressions. If you need a value repeated, pass a list and let Terraform iterate.
@param — substitute one value​
@param marks the next value line in the file. Injecto replaces everything
after that line's key: or key= with the value found at the given path in the
environment configuration.
# @param services.eks.kubernetesVersion
kubernetes_version = "1.34"
With services.eks.kubernetesVersion set to 1.36, the generated file reads:
# @param services.eks.kubernetesVersion
kubernetes_version = "1.36"
The template's own value ("1.34") is a real, working default. It is what a
terraform validate in the templates repository checks, and it is what ships if
the parameter is never supplied — which is exactly why an unresolved @param is
treated as a defect rather than a no-op (see When a parameter goes
unresolved).
What counts as a value line. The line after the decorator must match
^(\s*(?:-\s+)?[\w.-]+\s*[:=]) — a key, then : or =, optionally preceded by a
YAML list dash. Comment lines between the decorator and the value are skipped.
# @param gitRepository.branches
branches: ["main"] # a YAML sequence — substituted as a JSON list
# @param terraformBackend.bucketName
bucket = "my-terraform-state-bucket" # HCL — same rule
Anything after the value is preserved, so a trailing comment survives substitution.
Rewriting a line that opens a block would orphan the rest of it and produce output
that does not parse. Injecto refuses the file and reports the site
(path:line @param <path>) rather than emitting something broken.
@section — include or comment out a block​
@section wraps a region between begin and end markers. When the path is
falsy in the configuration, Injecto comments the region out; the lines stay in
the file, inert.
# @section services.eks.karpenterEnabled begin
module "karpenter" {
source = "terraform-aws-modules/eks/aws//modules/karpenter"
# ...
}
# @section services.eks.karpenterEnabled end
Commenting out rather than deleting is deliberate: line numbers are preserved, the customer can see what was available but not selected, and re-enabling is a readable diff.
Decorator lines themselves are never un-commented by this process — an un-commented decorator would be code, not a marker.
@module — declare a service to the catalog​
@module has no effect on substitution. It exists so a template can tell the
wizard that a service exists and how to present it. See
Service Catalog.
# @module services.eks | displayName=Elastic Kubernetes Service (EKS) | category=Compute
Attributes​
Any @param or @module may carry a | key=value tail. Attributes are read by
the catalog extractor and ignored by the substituter — the path regex
[\w.-]+ stops at the space before the |.
That is not an accident, it is the property that lets template metadata be enriched without redeploying Injecto.
# @param services.eks.kubernetesVersion | displayName=Kubernetes Version | type=dropdown | options=[{"value":"1.34","label":"1.34"},{"value":"1.36","label":"1.36"}]
kubernetes_version = "1.34"
| Attribute | Meaning |
|---|---|
displayName | Label shown in the wizard |
description | Help text under the field |
type | Field widget: text, toggle, dropdown, number |
options | Strict JSON array of {value, label}; only for type=dropdown |
pattern | Regex the wizard validates the field against |
default | Value offered before the user edits |
available | false hides the service from the wizard |
category | Grouping for @module |
options is parsed as strict JSON and everything else as a string. Three failures
are reported rather than tolerated: a segment that is not key=value, a duplicate
key, and options that is not valid JSON.
| inside an attribute value splits the decoratorAttribute values are split on a bare |, so a regex alternation in pattern
arrives as a fragment and fails with "is not key=value". Alternation in patterns
is not supported yet. The gate fails rather than shipping a mangled pattern, which
is the behaviour you want — only the error wording used to be misleading.
Attributes in generated output​
By default the decorator lines — attribute tails included — are copied verbatim
into the customer's repository, which means they read OpenPrime's wizard metadata
in their own tfvars. Setting TRIM_DECORATOR_ATTRS=1 on Injecto strips the tail
and leaves # @param <path>, which still documents which wizard field drives the
value.
The decorator line itself is always kept: the CI gate locates each generated value by the line number it scanned from the template, so removing lines would shift everything after them.
When a parameter goes unresolved​
A @param whose path is absent from the configuration is not an error at the
Terraform level — the template's own default simply ships. That is the failure
mode to design against, because it is silent and it looks like success.
domain absent from the config -> UNRESOLVED_PARAM (CI gate fails)
the template default would have shipped
domain: "" -> substituted; empty is a real answer
domain: "example.com" -> substituted
This is why the backend sends domain: "" rather than omitting the key when a
customer has no domain: an empty value is a deliberate answer, a missing key is an
accident. Publishing OpenPrime's own domain into customer repositories (OP-244)
happened exactly this way — the parameter did not exist, so the template default
shipped.
The templates repository runs a generation gate in CI that fails on unresolved
parameters, inert decorators, missing output files, terraform fmt violations and
terraform validate errors.
Injecto service​
FastAPI service, port 8000. Every endpoint except /health requires the
X-Service-Token header.
| Method | Path | Purpose |
|---|---|---|
GET | /health | Liveness; the only unauthenticated route |
POST | /process | Process a template tree already on disk |
POST | /process-upload | Process an uploaded archive |
POST | /process-git-download | Clone a templates repo, process, return the result |
GET | /catalog | Extract the service catalog from the templates |
Failure semantics​
A run that reports success can still have dropped a file. Injecto raises
GenerationError for a file that threw during processing, for a file-count
mismatch and for multi-line value sites, and the API returns the detail rather
than a bare success — but the guarantee is only as good as the checks listed
above. Verify the generated tree, not the status code.
Template organisation​
openprime-infra-templates/
├── templates/
│ ├── terraform/
│ │ ├── aws/ # the AWS stack: VPC, EKS, RDS, ECR, …
│ │ │ ├── terraform.auto.tfvars # every @param lives here
│ │ │ ├── _variables.tf
│ │ │ ├── _config.tf # backend "s3" block
│ │ │ ├── helm_values.tf
│ │ │ └── …
│ │ └── kubernetes/ # the cluster stack: ArgoCD, support resources
│ ├── argocd/
│ │ ├── applications.yaml # which charts a cluster gets
│ │ ├── values/ # per-chart values, some as .tftpl
│ │ ├── charts/
│ │ └── example-apps/
│ └── .github/workflows/ # the pipeline the CUSTOMER receives
└── tests/
├── gate.py # the generation gate
└── fixtures/ # configurations the gate generates from
Files are plain .tf and .yaml. There is no .tpl extension and no separate
shared/ tree — anything under .terraform/ is a downloaded Terraform module,
not one of ours.
templates/.github/workflows/ is generated output: it becomes the customer's
own CI pipeline. The templates repository's own CI lives in .github/ at the
repository root.
terraform.auto.tfvars is the parameter surface​
Almost every @param in the AWS stack lives in one file. That is deliberate: it
gives the catalog extractor a single canonical file to scan, and it gives a
reviewer one place to see everything the wizard can drive.
From environment to generated repository​
wizard → PUT/POST /api/environments → PostgreSQL
│
POST /api/environments/:id/generate
│
prepareInjectoData(environment)
│
POST /process-git-download
│
generated tree → ZIP
│
POST /api/environments/:id/push → customer's Git
prepareInjectoData() in openprime-app-backend/src/services/environmentService.js
is the only transform between the stored environment and Injecto. It is the
place to look when a wizard field does not reach the generated output.
It does more than copy fields:
- scopes Terraform state keys per environment (
env/<id>/aws.tfstate), falling back to the legacy fixed keys for environments created before that existed - forces
useLockfile: true— the generated backend uses S3 native locking, with no DynamoDB table - sends only
url,branchandbranchesfrom the git configuration, so the customer's private deploy key never crosses the service boundary - sends
domain: ""rather than omitting it, per the unresolved-parameter rule above
Related​
- Service Catalog — how these same decorators build the wizard
- Working with Terraform templates — task-oriented guide
- Data model — what an environment stores