Skip to main content

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.

There is no @foreach and no @if

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

A value line that opens a multi-line block is refused, not rewritten

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"
AttributeMeaning
displayNameLabel shown in the wizard
descriptionHelp text under the field
typeField widget: text, toggle, dropdown, number
optionsStrict JSON array of {value, label}; only for type=dropdown
patternRegex the wizard validates the field against
defaultValue offered before the user edits
availablefalse hides the service from the wizard
categoryGrouping 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.

A | inside an attribute value splits the decorator

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

MethodPathPurpose
GET/healthLiveness; the only unauthenticated route
POST/processProcess a template tree already on disk
POST/process-uploadProcess an uploaded archive
POST/process-git-downloadClone a templates repo, process, return the result
GET/catalogExtract the service catalog from the templates

Failure semantics​

Exit code 0 means nothing on its own

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, branch and branches from 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