Kubernetes / Helm deployment
ddd generate system --k8s emits a Helm chart (helm/) and the raw
manifests it renders to (k8s/) alongside the always-present
docker-compose.yml. Compose stays the inner-loop story
(docker compose up boots the whole stack against an in-container
postgres); the chart is the cluster story.
ddd generate system app.ddd -o out # compose only (default)
ddd generate system app.ddd -o out --k8s # + helm/ + k8s/
The switch is additive: it never replaces the compose file. This is an
emitter-only feature — nothing in the .ddd grammar or the IR changes.
Every infra knob the DSL doesn't model (replicas, resources, ingress host,
image registry, DB connection string) has a home in the chart's
values.yaml, overridden per environment with --set / -f values.prod.yaml.
What gets emitted
out/
docker-compose.yml # unchanged — the inner loop
helm/
Chart.yaml
values.yaml # the tuning seam (see below)
templates/
_helpers.tpl # name / label helpers
<name>-deployment.yaml # one per deployable
<name>-service.yaml # one per deployable (ClusterIP)
<name>-config.yaml # non-secret env (ConfigMap)
<name>-ingress.yaml # UI-serving deployables, gated on values
secret.yaml # DB connection + sensitive env (one Secret)
NOTES.txt
k8s/ # the same chart rendered with its defaults
<name>-deployment.yaml
<name>-service.yaml
<name>-config.yaml
secret.yaml
Resource names are DNS-1123 (webApp → web-app); the Helm values.yaml
keys and --set paths use the deployable's original identifier (webApp).
How a deployable maps onto a workload
Each deployable's k8s workload is derived from exactly the data the compose
path already reads — PlatformSurface.composeService + DeployableIR — so
the two stay structurally aligned:
| Source | k8s |
|---|---|
internalPort |
container port + Service.targetPort |
deployable port |
Service.port |
/ready on DB backends, / on frontends |
readinessProbe |
/health on DB backends, / on frontends |
livenessProbe |
non-secret env |
ConfigMap (via envFrom) |
DB-connection + sensitive env |
Secret ref (secretKeyRef) |
| frontend / UI-serving | optional Ingress (off by default; same-origin /api + / split when it targets a backend — see below) |
Probes are aligned across backends. Every DB-backed backend
(hono / .NET / Python / Java / Phoenix) emits both a cheap /health
(no DB) and a DB-aware /ready (pings the database), so the emitter uses a
uniform rule — livenessProbe: /health, readinessProbe: /ready — rather
than the deployable's healthPath (which reflects each backend's compose
healthcheck choice and would otherwise leak a non-DB-aware readiness probe
for Phoenix). /ready backing readiness means a transient DB outage gates
traffic instead of restarting the pod. Frontends expose neither endpoint, so
both probes hit the static root /.
Secrets stay out of the ConfigMap — and the backend owns the list. Which
env is sensitive is declared by each backend on its platform surface
(ComposeServiceShape.secretEnvKeys), not guessed from the variable name.
Java declares SPRING_DATASOURCE_PASSWORD; Phoenix declares
SECRET_KEY_BASE; Hono / .NET / Python / the frontends declare nothing (they
have no non-DB secrets). Declared entries plus the DB connection go into the
Secret; everything else (PHX_HOST, PORT, SPRING_DATASOURCE_USERNAME, …)
lands in the plaintext ConfigMap. The DB connection itself is identified by
its value embedding the compose db host — a structural match generated by
the same backend code, so it's deterministic, not a name heuristic.
values.yaml — the tuning seam
global:
image:
registry: "" # set to your registry; empty ⇒ local <name>:<tag>
tag: latest
pullPolicy: IfNotPresent
ingress:
className: ""
host: ""
api: # one block per deployable
enabled: true # render this workload; --set api.enabled=false drops it
replicas: 1
resources: { requests: {...}, limits: {...} }
env: {} # extra env overlaid onto the ConfigMap
database:
url: "postgres://…@db:5432/api" # placeholder — point at your managed DB
secrets: {} # sensitive env (only present when the backend has any),
# e.g. SECRET_KEY_BASE / SPRING_DATASOURCE_PASSWORD
webApp:
enabled: true
replicas: 1
resources: { ... }
env: {}
ingress: { enabled: false, host: "" }
Backends default to a heavier resource class than static frontends. The DB
database.url defaults to the dev-compose connection string as a recognisable
placeholder — production overrides it with the real managed-DB URL.
Every deployable carries a default-true enabled toggle; each
Deployment/Service/ConfigMap/Ingress template is wrapped in
{{- if .Values.<key>.enabled }}, so --set <key>.enabled=false installs a
subset of the system — one backend at a time, say. The default render is
byte-unchanged (all on); the k8s-e2e matrix uses it to install a single
backend per cell.
Ingress — same-origin routing
A UI-serving deployable gets one optional Ingress, off by default —
enable it per deployable:
--set webApp.ingress.enabled=true webApp.ingress.host=app.example.com
When that deployable targets: a distinct backend, the Ingress is
same-origin: one host, two path rules.
rules:
- host: app.example.com
http:
paths:
- path: /api # → the targeted backend's Service
pathType: Prefix
backend: { service: { name: <release>-api, port: { number: 8080 } } }
- path: / # → the SPA's own Service
pathType: Prefix
backend: { service: { name: <release>-web-app, port: { number: 3000 } } }
/api is listed first so the longer prefix wins, and no path rewrite
is needed — every backend already mounts its routes under /api, the
toolchain-wide base path (src/util/api-base.ts). That is why the split needs
zero per-deployable config: the built SPA fetches /api relative (its
src/api/config.ts defaults to /api), so on one host the browser stays
same-origin and the Ingress fans /api to the backend and everything else
to the bundle — no CORS, no second hostname.
A fullstack host (Phoenix LiveView, or a backend that mounts its own
ui:) keeps a single / catch-all — it already serves its own /api from
the same process, so there is nothing to split.
The relative base is load-bearing. If you bake an absolute
VITE_API_BASE_URLinto the bundle at build time, the browser calls that URL directly and bypasses theIngress. Leave it unset for same-origin.
Decisions (v1)
- D-K8S-FORMAT — Helm chart is the primary deliverable; the raw
k8s/manifests are the default-values render (the fallback view). - D-K8S-SCOPE — emitter-only. No grammar/IR change; per-deploy tuning
lives in
values.yaml. - D-K8S-DB — external / managed database. The chart emits a connection
Secret(placeholder) + avalues.yamlslot; there is no in-cluster postgresStatefulSet. The compose path keeps its in-container postgres for the inner loop. The chart does not create databases either: each backend'sdatabase.urlmust name a database that already exists on the managed instance — including one for a deployable added in a later generation (the compose path re-provisions those on everyupthrough itsdb-bootstrapone-shot; there is no in-cluster equivalent to run). The sameSecretalso holds any other sensitive env (passwords / app secret keys), kept out of the plaintextConfigMap. - D-K8S-INGRESS — one optional
Ingressper UI-serving deployable, host/className fromvalues.yaml, off by default. Backends areClusterIP. When the UI deployabletargets:a distinct backend, theIngressis same-origin: it splits one host into/api→ the backend service and/→ the SPA (the built bundle fetches/apirelative, so this needs no CORS or separate API host;/apiis listed first so the longer prefix wins, and no path rewrite is required since backends already mount under/api). A fullstack host (Phoenix LiveView, or a backend that mounts its ownui:) keeps the single/catch-all — it already serves its/api.
Broker channels (M-T4.4 slice 5b)
A wired channelSource adds its broker to the chart, auth-provisioned
exactly like the compose sidecars (design §7):
- One enabled-gated broker workload per wired broker storage —
helm/templates/<name>-broker.yaml(Deployment +ClusterIPService, plus a ConfigMap for RabbitMQ's mountedload_definitionsfile), gated on.Values.brokers.<storage>.enabled(defaulttrue). The images match compose:valkey/valkey:8-alpine(with--requirepass),rabbitmq:4-management-alpine(definitions provision theloomvhost + one user per deployable with name-scoped permissions, and suppress the defaultguestaccount),apache/kafka:4.1.0(single-node KRaft, SASL/PLAIN on the client listener, one JAASuser_<name>per wired deployable; the advertised listener resolves to the release-prefixed broker Service). LOOM_CHANNEL_<NAME>_URLrides the sharedSecret— it embeds the per-deployable §7 credentials (redis://:<pass>@…,amqp://<user>:<pass>@…/loom,kafka://<user>:<pass>@…). The default is computed in the Secret template against the in-cluster broker Service; override per deployable via.Values.<deployable>.channels.<ENV>to point at a managed broker (then set the matchingbrokers.<storage>.enabled=false).- The raw
k8s/view mirrors both with the defaults baked in (plain service names). Channel-less systems emit no broker artifacts — byte-identical.
Seams this does NOT cover (v1)
- Image build/push. Loom emits Dockerfiles, not a registry push. The
chart references
<registry>/<name>:<tag>; build and push the images, then--set global.image.registry=… global.image.tag=…. (FrontendVITE_*env is baked into the static bundle at build time, not at install. The bundle defaults to a relative/apibase for the same-origin Ingress above — only bake an absoluteVITE_API_BASE_URLif you deliberately want a cross-origin API URL, which bypasses the split.) - TLS termination. The
Ingressemits host-based routing only — notls:block or cert-manager annotations. Same-origin means one hostname, so the cluster operator terminates TLS once for that host (add thetls:section / issuer annotation to taste); the chart does not presume a cert story. - Secret values. Only references + placeholders; the DB URL and any auth secrets are supplied at install time.
- Deferred: infra-in-DSL clauses (
replicas/resources/ingressonDeployable), per-platform workload defaults, in-cluster postgres, HPA/autoscaling, NetworkPolicy, securityContext hardening, Kustomize overlays, CI pipeline generation. Seeproposals/kubernetes-helm.md§6.
Testing
Two tiers:
Static validation (shipped,
npm run test:k8s). The opt-in suitetest/e2e/k8s-validate.test.ts(gatedLOOM_K8S=1, CIk8s-build.yml) generates the chart for a set of fixtures and runshelm lint, thenhelm template→kubeconform -strict(validating the rendered manifests against the upstream Kubernetes API JSON schemas), thenkubeconformon the rawk8s/render. No cluster — fast and deterministic; catches schema-level drift (bad apiVersion, wrong field types, malformed probes/secretRefs) the substring unit tests can't. Requireshelm+kubeconformon PATH; skips cleanly when absent.Cluster smoke (shipped,
npm run test:k8s-e2e).scripts/k8s-e2e-smoke.shruns a realkind(Kubernetes-in-Docker) install of one backend: generate a system with--k8s,docker build+kind loadthat backend's image, stand up a throwaway in-cluster postgres (the chart targets an external DB),helm install --waitwith only that deployableenabled(every other workload--set …enabled=false, so the cell builds a single image),kubectl rollout status, an explicitcurl /health+/readythrough the backend's ClusterIP Service, and finally two real domain round-trips (from a throwawaynode:alpinepod hitting the backend's Service — notkubectl execinto the backend, since only the hono image is a node container):- a read —
GETthe fixture's list endpoint, asserting200+ JSON. That exercises what/readycan't — migrations applied at boot → the repository'sSELECThits a real, migrated table → wire-shape serialization → HTTP routing (a missing table would500). - a write —
POSTthe fixture's create body, expect201, then read it back and assert the new row is present and the count grew. This proves the mutation path:POST→ the domain validates its invariants →INSERT→ persisted. The body is a checked-in per-example fixture (scripts/k8s-e2e/<example>.smoke.json) because the invariants it must satisfy live in the domain, not the OpenAPI request schema, so a synthesized body would422; one fixture is backend-agnostic since the wire shape is identical across backends.
The script is parametrized (
SMOKE_DDD/SMOKE_BACKEND/SMOKE_FIXTURE, defaulting to theinheritance-systemhono case) and derives the values key, container/service ports, db name, image and build dir from the generated chart — so a CI cell needs only those three vars. Heavier (it builds container images), so it is not per-PR — thek8s-e2e.ymlworkflow fans it across every backend as a matrix (one cell per backend, in parallel): hono / dotnet / python / java over the dedicated, auth-freescripts/k8s-e2e/k8s-smoke.ddd(a trivialWidgetCRUD), plus phoenix overexamples/tasks-vanilla.ddd. Runs nightly, on thee2e-k8sPR label, or by manual dispatch. Run locally withkind create cluster && npm run test:k8s-e2e. (Making the Elixir deployable deployable at all — a Dockerfile, a release that runsEcto.Migratorat boot, andserver: true— is part of the generator.)- a read —
Implementation
Two sibling emitters under src/system/, wired into emitSystem next to
renderDockerCompose and gated on the emitKubernetes option:
src/system/kubernetes.ts— owns the sharedWorkloadModel(buildWorkloads) andrenderKubernetesManifests(raw YAML).src/system/helm.ts—renderHelmChart, consuming the sameWorkloadModelso the chart and raw manifests never drift.
Sensitive env is declared by each backend via
ComposeServiceShape.secretEnvKeys (src/platform/surface.ts) — the k8s
emitter reads that rather than guessing from variable names.