Service Catalog
The wizard used to be a hand-written list of services. Adding a knob meant editing a frontend file, releasing the frontend, and hoping it still matched what the templates actually consumed.
It no longer works that way. The wizard is built from the templates, at
runtime. A @param added to openprime-infra-templates appears as a field in the
wizard without a frontend release.
The failure this design removes is a specific one: a field the wizard offers that generation ignores. When the wizard and the templates were two independent lists, they drifted, and the drift was invisible until a customer configured something and nothing happened.
The chain​
openprime-infra-templates
# @module services.eks | displayName=Elastic Kubernetes Service (EKS)
# @param services.eks.kubernetesVersion | type=dropdown | options=[…]
│
│ Injecto reads the decorators
â–¼
GET /catalog (Injecto, service token)
│
│ backend proxies, caches, adds the user's auth
â–¼
GET /api/catalog (backend, JWT)
│
│ frontend hydrates SERVICES_CONFIG in place
â–¼
the wizard renders the field
│
â–¼
the value lands in terraform.auto.tfvars
Every arrow is verified by something. The last one matters most: a knob that renders but never reaches the generated output would be the same defect in a new place.
What the catalog contains​
A JSON document with two buckets:
{
"schemaVersion": 1,
"commit": "924e502…",
"global": { "fields": { "name": {…}, "region": {…}, "domain": {…} } },
"services": { "eks": { "displayName": "…", "fields": { … } } },
"errors": []
}
Global fields are environment-level: name, globalPrefix, region,
domain. Their decorator path is a single segment.
Services come from @module services.<key> plus the services.<key>.<leaf>
parameters beneath it. The path shape decides the bucket — one segment is global,
three segments starting with services is a service field, and anything else is a
BAD_PATH error.
commit is the templates commit the catalog was extracted from. It is how a saved
wizard draft knows whether it was written against a different catalog.
Extraction​
Injecto's GET /catalog scans templates/terraform/<provider>/terraform.auto.tfvars
and returns the document. The same endpoint is available as a CLI:
python -m injecto.main --extract-catalog . --provider aws --strict --output catalog.json
--strict exits non-zero if any error was collected, which is what the templates
repository runs in CI.
Extraction never silently drops a service. A service whose only @param is
malformed still appears with its enable toggle — deleting a whole module from the
wizard because one field's attributes had a typo would be a much worse outcome
than reporting the error.
The backend proxy​
GET /api/catalog requires a JWT like any other API route. It exists so the
browser never talks to Injecto directly and never holds a service token.
It caches:
| Behaviour | Detail |
|---|---|
| TTL | CATALOG_CACHE_TTL_MS, default 5 minutes |
| Revalidation | Injecto's ETag is the templates commit; a 304 renews the TTL without replacing the document |
| Client caching | The ETag is passed through, so a browser holding the current catalog gets a 304 |
| Stale-on-error | If Injecto is unreachable and the cached document is younger than the stale window, it is served with Warning: 110 rather than failing the wizard |
The cache is a module-level object, not Redis. A second replica simply keeps its own copy — the document only changes when the templates repository does.
Frontend hydration​
SERVICES_CONFIG starts as an empty exported object and is filled in place, so
all of its consumers keep synchronous access with no signature changes.
Hydration also repairs what a raw document cannot express:
patternstrings are compiled toRegExpin a try/catch — a pattern that does not compile drops that one validation rather than the field- an unknown
typeis coerced totextwith a warning, because a missing type would crash the field renderer - a
schemaVersionother than1is rejected outright
The flag​
USE_RUNTIME_CATALOG is read at runtime from /env.js, not baked in at build
time. Turning the catalog on or off is a values change plus a restart — no rebuild,
and no rebuild to turn it back off either. That reversibility is what made the
cutover safe to attempt.
window._env_.USE_RUNTIME_CATALOG // "true" in production since 2026-09-01
Failure does not take the app down​
If the catalog cannot be fetched with the flag on, the wizard falls back to the static configuration. The user gets exactly the pre-catalog wizard; the failure is logged and exposed on the context.
The first implementation rendered an error screen instead of the children, which meant one unreachable internal pod became a total outage of the whole application. Falling back is strictly better: degraded, not down.
Guards​
Two checks run in CI (npm run catalog:check in openprime-app), because "we
think they match" is not a claim anyone should act on.
catalog-parity.js compares the live catalog against the static config —
service keys, field keys, types, defaults, available, and validation patterns —
with an allowlist for differences that are intentional.
node scripts/catalog-parity.js --catalog catalog.json
node scripts/catalog-parity.js --catalog https://api.openprime.io/api/catalog --token "$JWT"
catalog-lint checks the document's internal consistency.
A parity check only compares what it was written to compare. It compared fields
and could not see a service-level available flag, which would have put a broken
service back on offer. It compared with String(a) !== String(b), so the string
"false" and the boolean false matched — the exact case the check had just been
added for.
Both were found by measuring, not by reading the code. When you extend the catalog, extend the parity script in the same change, and prove the new check fails on the case it is meant to catch.
Adding a service knob​
- Add the
@paramtotemplates/terraform/aws/terraform.auto.tfvars, above a real value line, with the attributes the wizard needs. - Make sure a
@section services.<key>.enabledgate exists for the module. - Run the generation gate — it fails on an inert decorator, so a
@paramthat substitutes nothing is caught immediately. - Run
npm run catalog:checkinopenprime-appand add an allowlist entry if the difference from the static config is intentional. - Merge. No frontend release is needed — production clones the templates
repository's
mainbranch unpinned, so the knob is live on the next generate.
main is live, unpinnedThere is no staging step between merging a template change and every customer generating against it. The generation gate is the only thing standing between a merge and a customer's Terraform.
Deliberate limits​
- The catalog describes AWS. Other providers are extracted with
--provider, but only the AWS tree carries decorators today. - The gate's fixture enables no Helm charts, so chart-related template paths are
@section-commented out of every generated tree and are not exercised by CI. - Attribute values cannot contain
|, so regex alternation inpatternis not supported.
Related​
- Template System — the decorator grammar in full
- Services — what the catalog offers today
- API reference —
GET /api/catalog