Skip to main content

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:

BehaviourDetail
TTLCATALOG_CACHE_TTL_MS, default 5 minutes
RevalidationInjecto's ETag is the templates commit; a 304 renews the TTL without replacing the document
Client cachingThe ETag is passed through, so a browser holding the current catalog gets a 304
Stale-on-errorIf 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:

  • pattern strings are compiled to RegExp in a try/catch — a pattern that does not compile drops that one validation rather than the field
  • an unknown type is coerced to text with a warning, because a missing type would crash the field renderer
  • a schemaVersion other than 1 is 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.

This was deliberately changed after review

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.

Both guards have been blind to a real defect, twice

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​

  1. Add the @param to templates/terraform/aws/terraform.auto.tfvars, above a real value line, with the attributes the wizard needs.
  2. Make sure a @section services.<key>.enabled gate exists for the module.
  3. Run the generation gate — it fails on an inert decorator, so a @param that substitutes nothing is caught immediately.
  4. Run npm run catalog:check in openprime-app and add an allowlist entry if the difference from the static config is intentional.
  5. Merge. No frontend release is needed — production clones the templates repository's main branch unpinned, so the knob is live on the next generate.
Templates main is live, unpinned

There 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 in pattern is not supported.