Software Factories on AWS

Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.

Template Syntax (synth-time scripting)

A minimal {{ path.to.value }} syntax in both pipeline configs (pipeline.json) and plugin specs (plugin-spec.yaml).

Highlights

Overview

This reference documents the grammar, the scopes, filters, the plugin contract, the CLI and editor tooling, and the error catalog. It is for plugin and pipeline authors who want a single spec to serve many environments.

Two uses drive the design: one plugin serving many environments, and one pipeline template serving many deployments via self-references.

How it works

  1. You write {{ ... }} tokens in a pipeline config or plugin spec.
  2. Metadata is merged pipeline → plugin reference → step into the scope the tokens resolve against.
  3. At synth, the server resolves every token once against that fixed scope. Nothing is evaluated later, and nothing executes.
  4. A declared contract is enforced. A plugin’s requiredMetadata is checked when the pipeline is created and again at synth.
  5. Unresolvable tokens fail with a named error rather than rendering empty — see the error catalog.

Configuration

  1. Declare your plugin’s contract so consumers get a clear failure instead of a puzzling one: requiredMetadata plus metadataTypes. See Plugin contract.
  2. Set the values the contract asks for, at whichever scope fits — pipeline-wide, per plugin reference, or per step.
  3. Preview before deploying — resolve a pipeline without a CDK deploy, and validate a local spec before upload. See CLI tools.
  4. Use ?resolve=true when you want the API to hand back the resolved form; see API.
  5. Read the error catalog when something fails — each error names the token and the scope it looked in.
  6. Migrating an existing plugin? Migrating an existing plugin is the ordered checklist.

Grammar

Template   := (Literal | Expr)*
Expr       := "{{" ws Path (ws Filter)? ws "}}"
Path       := Identifier ("." Identifier)*
Identifier := [a-zA-Z_][a-zA-Z0-9_]{0,63}
Filter     := "|" ws "default" ws ":" ws Quoted
Quoted     := "'...'"  |  "\"...\""

Scope reference

Different docs see different scopes.

In a pipeline config (pipeline.json)

Pipeline templates can only self-reference — one metadata key can interpolate another, or reference a vars key.

Scope root Available inside
metadata.* Any other metadata key in the same pipeline
vars.* Any vars key in the same pipeline

Templatable fields in a pipeline config: project, metadata.* string values, vars.* string values. Identity fields (id, orgId, stages, plugins[]) are not templatable.

Exception — the GitHub source token. synth.source.options.token is templatable against the pipeline scope (pipeline.*, the same scope plugin specs see), so a secret reference can be parameterized per org:

"synth": {
  "source": {
    "type": "github",
    "options": {
      "repo": "owner/repo",
      "branch": "main",
      "token": "secretsmanager:pipeline-builder/{{ pipeline.vars.orgId }}/github-token"
    }
  }
},
"vars": { "orgId": "<your-org-id>" }

It resolves at synth time to secretsmanager:pipeline-builder/<your-org-id>/github-token, which CodePipeline reads as the OAuth secret reference (only the reference — never the token value — lands in the template). Other source fields (repo, branch, etc.) stay literal.

In a plugin spec (plugin-spec.yaml)

Plugin templates see a richer scope assembled per-synth from the pipeline invoking the plugin.

Scope root Available inside
pipeline.projectName String — the pipeline’s project name
pipeline.orgId String — org UUID
pipeline.metadata.* Any key set on the pipeline’s metadata object
pipeline.vars.* Any key set on the pipeline’s vars object
plugin.name / plugin.version Plugin record fields
env.FOO Any key declared in the same plugin’s env: map. env values may reference each other (B: '{{ env.A }}-svc'); they resolve in dependency order, and a cycle is an error.

Templatable fields in a plugin spec: description, commands[], installCommands[], env.* values, buildArgs.* values. Identity/security fields (name, version, pluginType, computeType, timeout, secrets, failureBehavior) are not templatable.

Reserved paths


Plugin contract: declare your requirements

When a plugin spec references pipeline.metadata.X or pipeline.vars.Y, it must declare that dependency so pipelines using the plugin are rejected if they don’t supply the key.

name: kubectl-deploy
version: 2.0.0
pluginType: CodeBuildStep
computeType: SMALL

# Contract — pipelines using this plugin must set these metadata keys
requiredMetadata: [env, namespace, clusterName, region]
requiredVars: []

env:
  KUBECONFIG: /tmp/{{ pipeline.metadata.env }}-kubeconfig
installCommands:
  - "aws eks update-kubeconfig --name {{ pipeline.metadata.clusterName }} --region {{ pipeline.metadata.region }}"
commands:
  - "kubectl apply -f k8s/{{ pipeline.metadata.env }}/ -n {{ pipeline.metadata.namespace }}"

If a template uses | default: '...', the key is treated as optional and can be omitted from requiredMetadata / requiredVars.

How the contract is enforced on pipelines

The contract is stored with each plugin version, and every pipeline create and update (single and bulk) is checked against it:

Plugin references: publisher and version ranges

A step’s plugin names the plugin, and optionally its publisher and a version range:

plugin: { name: trivy }                                                     # unqualified
plugin: { name: trivy, filter: { version: '^1' } }                          # unqualified, 1.x
plugin: { publisher: acme, name: terraform-plan, filter: { version: '^1' } } # qualified: acme's listing
plugin: { publisher: pipeline-builder, name: trivy }                        # the Official listing, by name

publisher, name and filter are identity fields and aren’t templatable. See Plugin Installing.


Example: pipeline-level self-references

{
  "id": "bb234ff6-8b2e-41e3-9758-fb23b63916cd",
  "project": "{{ vars.service }}-{{ metadata.env }}",
  "orgId": "acmecorp",
  "metadata": {
    "env": "prod",
    "region": "us-east-1",
    "clusterName": "acme-eks-{{ metadata.env }}",
    "namespace": "{{ vars.service }}-{{ metadata.env }}"
  },
  "vars": {
    "service": "checkout",
    "branch": "main",
    "slackChannel": "#deploys-{{ metadata.env }}"
  },
  "stages": [
    { "name": "deploy", "plugins": ["kubectl-deploy", "slack-notify"] }
  ]
}

After pass-1 resolution, the pipeline looks like:

{
  "project": "checkout-prod",
  "metadata": {
    "env": "prod",
    "region": "us-east-1",
    "clusterName": "acme-eks-prod",
    "namespace": "checkout-prod"
  },
  "vars": {
    "service": "checkout",
    "branch": "main",
    "slackChannel": "#deploys-prod"
  }
}

Cycles are detected and rejected at upload time:

POST /api/pipelines  →  400 TEMPLATE_VALIDATION_FAILED

Pipeline has circular template references:
  • Template cycle detected: metadata.a -> metadata.b -> metadata.a

Example: plugin spec with pipeline.* interpolation

Before templates — hardcoded per environment:

# plugins/deploy/kubectl-deploy-prod/plugin-spec.yaml
name: kubectl-deploy-prod
commands:
  - "kubectl apply -f k8s/prod/ -n checkout-prod"
  - "kubectl scale deployment checkout --replicas=3 -n checkout-prod"

After templates — one plugin serves N environments:

# plugins/deploy/kubectl-deploy/plugin-spec.yaml
name: kubectl-deploy
version: 2.0.0
pluginType: CodeBuildStep
computeType: SMALL

requiredMetadata: [env, namespace, replicas]
requiredVars: []

env:
  NAMESPACE: "{{ pipeline.metadata.namespace }}"
installCommands:
  - "kubectl config use-context {{ pipeline.metadata.env }}"
commands:
  - "kubectl apply -f k8s/{{ pipeline.metadata.env }}/ -n {{ env.NAMESPACE }}"
  - "kubectl scale deployment {{ pipeline.projectName }} --replicas={{ pipeline.metadata.replicas | default: '1' }} -n {{ env.NAMESPACE }}"

Example: notification plugin

# plugins/notification/slack-notify/plugin-spec.yaml
name: slack-notify
version: 2.0.0
pluginType: CodeBuildStep
computeType: SMALL

requiredMetadata: [env]
requiredVars: [slackChannel]

secrets:
  - name: SLACK_WEBHOOK_URL
    required: true

commands:
  - |
    curl -X POST "$SLACK_WEBHOOK_URL" \
      -H 'Content-Type: application/json' \
      -d '{
        "channel": "{{ pipeline.vars.slackChannel }}",
        "text": "✅ {{ pipeline.projectName }} deployed to {{ pipeline.metadata.env }} from {{ pipeline.vars.branch | default: 'unknown' }}"
      }'

Example: build plugin with buildArgs

# plugins/artifact/docker-build-push/plugin-spec.yaml
name: docker-build-push
version: 2.0.0
pluginType: CodeBuildStep
computeType: MEDIUM

requiredMetadata: [region, ecrRepoName]
requiredVars: []

buildArgs:
  BUILD_ENV: "{{ pipeline.metadata.env | default: 'staging' }}"
  COMMIT_SHA: "$CODEBUILD_RESOLVED_SOURCE_VERSION"   # literal — runtime var

commands:
  - "aws ecr get-login-password --region {{ pipeline.metadata.region }} | docker login --password-stdin {{ pipeline.orgId }}.dkr.ecr.{{ pipeline.metadata.region }}.amazonaws.com"
  - "docker build --build-arg BUILD_ENV=$BUILD_ENV -t {{ pipeline.metadata.ecrRepoName }}:$COMMIT_SHA ."
  - "docker push {{ pipeline.metadata.ecrRepoName }}:$COMMIT_SHA"

Note the mix: {{ ... }} is resolved at synth time, while $CODEBUILD_* variables stay literal and are evaluated at runtime by the shell.


Filters

| default: '...' — fallback value

Use | default: '...' to supply a fallback when a scope path is undefined or empty:

commands:
  - "kubectl scale deployment {{ pipeline.projectName }} --replicas={{ pipeline.metadata.replicas | default: '1' }}"
  - "curl -s https://api.example.com/{{ pipeline.metadata.endpoint | default: 'v1/health' }}"

| number, | bool, | json — type coercion

Coercion filters turn the resolved text into a native value, but only when the template is the entire field (no surrounding literal text):

# Whole-field template — produces native types
metadata:
  replicas:  "{{ vars.count | number }}"        # → 3 (number)
  isProd:    "{{ vars.env | bool }}"             # → true
  features:  "{{ vars.featureJson | json }}"     # → parsed JSON

# Mixed with literal text — stays a string (the filter is ignored)
env:
  MSG: "count={{ vars.count | number }}"         # → "count=3" (string)

Coercion rules:

Filter Accepts Produces
\| number Any string Number() can parse number
\| bool true / false / 1 / 0 / yes / no / "" (case-insensitive) boolean
\| json Any valid JSON string string \| number \| bool \| null \| object \| array

Coercion filters can chain with default:

replicas: "{{ vars.replicas | default: '1' | number }}"   # → 1 (number) if vars.replicas missing

Unparseable values (e.g. "abc" | number) throw TEMPLATE_TYPE_MISMATCH at synth time.


CLI tools

Preview resolved output

# Print what the pipeline will look like after resolution (no CDK deploy)
pipeline-manager pipeline deploy --id <uuid> --show-resolved
pipeline-manager pipeline synth  --id <uuid> --show-resolved

Validate templates without uploading

# Validate a local plugin-spec.yaml or pipeline.json before upload
pipeline-manager template validate --file ./plugin-spec.yaml

# Validate a pipeline by ID against the platform
pipeline-manager template validate --pipeline <uuid>

# Validate a published plugin
pipeline-manager template validate --plugin kubectl-deploy:2.0.0

Frontend editor integration

The dashboard editor understands {{ ... }} tokens:

No rich editor, no auto-escape — what you type is saved verbatim. Template resolution is a server-side concern; the client only parses for diagnostics + preview.


API: ?resolve=true

Pipeline read endpoints return the source by default (with {{ ... }} intact) so editors can round-trip. Pass ?resolve=true to get the resolved form.

GET /api/pipelines/{id}            # source form (for editing)
GET /api/pipelines/{id}?resolve=true  # resolved form (for preview/inspection)

Error catalog

All template errors map to HTTP 400 with one of these codes:

Code Meaning
TEMPLATE_PARSE_ERROR Malformed {{ ... }} — bad syntax, missing }}, unknown filter
TEMPLATE_UNKNOWN_PATH Path references an unknown scope root (e.g. {{ foo.bar }})
TEMPLATE_CYCLE Self-referencing pipeline has a cycle across metadata/vars fields
TEMPLATE_TYPE_MISMATCH Path resolved to an object where a scalar was expected
TEMPLATE_SECRETS_RESERVED Reserved secrets.* path — use the plugin’s secrets: yaml field instead
TEMPLATE_CONTRACT_VIOLATION Pipeline create/update: a plugin step’s required metadata/vars key is missing, or a supplied value doesn’t match metadataTypes/varsTypes. steps[] lists each failing step’s missing[] and invalid[]
TEMPLATE_SIZE_EXCEEDED Field exceeded 4 KiB or path depth exceeded 5
TEMPLATE_VALIDATION_FAILED Batched umbrella — one or more of the above present in a single doc

Every error includes field, line, col (when applicable), and the exact path or cycle that triggered it.


What’s not supported (by design)


Migrating an existing plugin

Adopting templates on an existing plugin changes nothing for current callers when you use | default::

1. Add the contract block

# plugin-spec.yaml
requiredMetadata: []   # pipeline.metadata keys you require (empty if all optional)
requiredVars: []
metadataTypes:         # type hints enable coercion safety
  replicas: number
  isProd: bool
varsTypes:
  branch: string

2. Replace hardcoded env defaults with templates

Before:

env:
  KUBE_NAMESPACE: default
  ROLLOUT_TIMEOUT: "300s"

After:

env:
  KUBE_NAMESPACE: "{{ pipeline.metadata.namespace | default: 'default' }}"
  ROLLOUT_TIMEOUT: "{{ pipeline.metadata.rolloutTimeoutSeconds | default: '300' }}s"

3. Bump the plugin version

Minor bump (e.g. 1.0.0 → 1.1.0) so pipelines can pin the pre-template version if needed.

Reference conversions in this repo

Five production plugins now show the pattern:

Plugin Metadata keys used
notification/slack-notify env, vars.branch, vars.slackChannel, projectName
notification/teams-notify env, vars.branch, projectName
deploy/kubectl-deploy context, namespace, manifestPath, rolloutTimeoutSeconds
deploy/helm-deploy namespace, helmRelease, helmChart, helmTimeoutSeconds
deploy/ecs-deploy ecsCluster, ecsService, imageUri, ecsTaskFamily

All pipelines continue to work unchanged; when they start supplying metadata keys, the plugin auto-populates the env vars.


Troubleshooting

“Template references unknown scope root ‘foo’“
→ Only pipeline, plugin, env (for plugins) and metadata, vars (for pipelines) are accepted scope roots.

“‘secrets’ is a reserved scope”
→ Move secret references into the plugin’s top-level secrets: yaml field. Secrets Manager handles the injection as env vars.

“Plugin spec uses template paths not declared in contract”
→ Add the missing key to your plugin’s requiredMetadata: or requiredVars: list, or use | default: '...' to make it optional.

“Template cycle detected”
→ One of your metadata.* or vars.* fields references another that references back to the first. The error message includes the full cycle chain.

{{ ... }} still visible in my CodeBuild logs
→ The plugin was loaded without pipelineScope — this happens only in legacy direct-invocation paths. The platform-managed synth flow always passes scope.


Golden pipeline templates

A golden pipeline template is a reusable, governed pipeline config — a BuilderProps with {{ vars.* }} placeholders — plus a set of declared inputs. Teams instantiate it (filling the inputs) to spin up a real pipeline in a few fields instead of hand-building one. It’s the same {{ … }} engine documented above: each declared input becomes a vars.<name> value, baked in at instantiate time.

Create one from an existing pipeline — Save as template (a pipeline’s detail page) or Templates → New template — or Import a template JSON on the Templates page. Declare inputs (e.g. repoUrl, branch) and parameterize the repository so one template targets any repo: the authoring form swaps the source pipeline’s concrete repo URL for {{ vars.repoUrl }} (the “Parameterize repository” button auto-detects it).

Example — a Node build → test → deploy starter, parameterized on the target repo

project / organization come from the Use-template form; repoUrl / branch are declared inputs:

{
  "name": "node-service",
  "category": "backend",
  "description": "Golden path: Node build -> tests -> CDK deploy, pointed at any repo.",
  "inputs": [
    { "name": "repoUrl", "label": "Repository URL", "type": "string", "required": true },
    { "name": "branch",  "label": "Branch",         "type": "string", "default": "main" }
  ],
  "props": {
    "synth": {
      "plugin": { "name": "cdk-synth" },
      "source": { "repositoryUrl": "{{ vars.repoUrl }}", "branch": "{{ vars.branch }}" }
    },
    "stages": [
      { "stageName": "build",  "steps": [{ "plugin": { "name": "nodejs" } }] },
      { "stageName": "test",   "steps": [{ "plugin": { "name": "jest" } }] },
      { "stageName": "deploy", "steps": [{ "plugin": { "name": "cdk-deploy" } }] }
    ]
  }
}

Use it — Templates → Use template → enter Project, Target repository (Git URL) and Branch → Create. The instantiate call renders the vars into the props, then creates the pipeline (compliance + quota still apply):

POST /api/pipeline-templates/<id>/instantiate
{
  "project": "checkout",
  "organization": "acme",
  "inputs": { "repoUrl": "https://github.com/acme/checkout", "branch": "main" }
}
// -> props.synth.source.repositoryUrl == "https://github.com/acme/checkout"
//    a new pipeline builds github.com/acme/checkout

Visibility — templates use the same three-rung ladder every catalog entity uses (Permissions → the visibility ladder); templates differ only in that they default to private:

Rung Who can see it Who can edit it
private (default) Only you — a personal draft Only you
org Everyone in your organization Anyone with templates:write
public Your org and its teams; from the system org, every org templates:publish

New templates start private, so you can iterate before sharing. Moving one to public needs templates:publish — a caller without it is clamped to org rather than silently dropped back to a draft. The shared system catalog across all orgs is a superadmin action from the system org.


Related docs: Metadata Keys CDK Usage Plugin Catalog API Reference