Skip to content

Helm chart type-level defaults with per-entry overrides

Status: accepted

Date: 2026-07-29

Relates to: ADR-0049 (caliper emission worker host — a services entry), ADR-0051 (two docs sites — two apps entries), infra/charts/emergent/.

The emergent chart renders every frontend from one set of apps templates and every backend from one set of services templates, iterating .Values.apps / .Values.services. The templates read every key directly off the entry, so every entry had to carry every key: five app blocks of ~170 lines and three service blocks of ~80 lines, of which roughly eight scalars per entry were genuine differences (hostname, cert name, data-image repository, ports, entrypoint, probe paths). The same boilerplate was then restated per entry in the environment override files.

The duplication had already produced real drift: a metrics annotation referencing a values path that no longer existed, one stray TLS cert name off the sibling pattern, a dbSchema comment copied into entries where it was meaningless, and a complete apps readiness: block configuring a probe the template never rendered. Helm itself cannot fix this in values: sibling keys are never merged into map entries — values-layer merging is only file-over-file on the same path.

  1. Type-level defaults, merged template-side. Top-level appDefaults: and serviceDefaults: sit beside apps: / services: in values.yaml. Every template that ranges over a map resolves the entry’s effective config first, via one helper per type (emergent.app.config, emergent.service.config): mustMergeOverwrite (deepCopy <defaults>) <entry>, rebinding the range variable. The chart stays fully self-contained under plain helm template; nothing about the mechanism depends on helmfile. Top-level placement (not a _defaults sentinel inside the map, not an instances: restructure) keeps existing apps.<name> override paths stable and the range loops free of sentinel-skipping.

  2. A per-entry key must be a genuine difference — uniform keys live only in defaults, even in the base chart. This is the discipline that makes environment-level defaults work: the merge runs after all values layers, so a per-entry key always beats appDefaults, including an appDefaults override set by an environment file. A base-chart entry restating a default would silently shadow every environment’s type-level override of that key.

  3. Defaults are schema-complete and are the config surface’s documentation. appDefaults / serviceDefaults hold exactly the keys the templates read, with the explanatory comments that were previously copy-pasted per entry. Membership is governed by the “actually used” test: a key (or rendered line) with no consumer is deleted, not defaulted. Applying that test removed debug, extraArgs, projectName, the apps readiness: block, metrics annotations and serviceMonitor config, most of the dev: block (only dev.main.gatewayIP is read), the apps service.type/clusterIP/loadBalancerIP/loadBalancerSourceRanges keys, and the rendered PROJECT_NAME + downward-API (emergent_*) env vars and probe Host headers that nothing consumed.

  4. One hostname key. An app’s external hostname was stored three times per app (haHost, gateway.hostName, listenerset.host). It is now a single top-level per-app hostName, read by the HTTPRoute, the gateway AuthorizationPolicy, and as the ListenerSet host default (listenerset.host survives only as an optional override via the existing default chain).

  5. Environment override files use the same shape. overrides-dev.yaml and overrides-local.yaml set appDefaults / serviceDefaults once (registry, static-web-server tag, gateway enablement, hostPath dev volumes) plus thin per-entry residue (hostnames, dev ports, service entrypoints and env).

Merge semantics — edges future readers must know

Section titled “Merge semantics — edges future readers must know”
  • Maps deep-merge; lists replace wholesale (a per-entry or env-layer list supersedes the default list — consistent with Helm’s own values-file layering).
  • Setting a per-entry key to null in a later values file deletes it at the values layer, so the entry falls back to the type default. This must be done per-entry (see overrides-local.yaml’s local.frontendPort: null): hoisting a null into appDefaults cannot delete a per-entry value, because per-entry keys win the template-side merge.
  • Falsy-but-set values (enabled: false, "", 0) do override defaults; only absent keys fall through.
  • Status quo (every entry self-contained): greppable, but the drift listed above is the empirical cost, and it recurs with every new app/service.
  • Helmfile-layer expansion: logic in the wrong layer; chart unusable without helmfile.
  • YAML anchors/merge keys: shallow-only, single-file — environments get nothing.
  • Convention-derived identity (tpl-rendered {{ .appId }}-data repositories, cert names, hostnames): the real data already breaks every candidate convention (docsEng key vs docs-eng name; hostnames not derivable from app keys), so it degenerates to explicit overrides plus indirection. Identity stays explicit literals.
  • values.yaml went from ~1,190 to ~390 lines; overrides-dev.yaml from 303 to 119; overrides-local.yaml from 104 to 46. Adding an app is now ~10 lines (hostname, cert, image repository) instead of ~170.
  • Verification for chart changes of this kind is golden-render diffing: helm template (bare, and with each environment values stack) before and after, with any diff limited to enumerated, intended deltas.