A library Helm chart (nebari-app) that provides a reusable named template for creating NebariApp custom resource instances. The template acts as a pure function — callers pass metadata and spec dicts, and the template renders a NebariApp resource. No global context, no field enumeration. The chart lives in this repository alongside the operator.
Goals
- Provide a single, canonical Helm template for
NebariApp CR instances.
- Allow a single consumer chart to define multiple NebariApp instances.
- Enforce required fields (
metadata.name, metadata.namespace, spec.hostname, spec.service.name, spec.service.port) via required.
- Support arbitrary metadata fields (labels, annotations, ownerReferences, etc.) via
toYaml.
- Keep the template minimal — ~10 lines.
Non-Goals
- CRD installation. The
CustomResourceDefinition itself is installed separately. This library chart templates only CR instances.
- Related resources. The chart does not template
Service, HTTPRoute, Certificate, or any other resources.
- Operator deployment. The library chart does not install the Nebari operator.
- Opinionated defaults. The template supplies no defaults. The CRD and API server handle defaulting. Fields not provided by the caller are omitted from the rendered YAML.
- Values schema validation. No
values.schema.json is shipped. The template's required guards and the API server handle validation.
- Enforcing values structure on the parent chart. The library chart does not prescribe where the consumer puts values in its
values.yaml. It receives explicit metadata and spec parameters.
- Merging into the operator chart. The library chart is a separate
type: library chart, not folded into the operator's type: application chart (dist/chart/). A consumer that depended on the operator chart to get the template would also deploy the operator, its CRDs, RBAC, and manager — a nebariOperator.enabled: false kill switch is non-idiomatic and couples template versioning to operator releases. Two separate charts, packaged by the same release pipeline.
Background / Motivation
Today, every Helm chart that wants to onboard an application into the Nebari platform must write a raw NebariApp YAML manifest. This duplicates the resource structure across charts and makes it easy to drift from the operator's expected schema.
A library chart solves this: the template lives in the operator repo, evolves with the API types, and is consumed via a standard Helm dependency. Consumer charts pass metadata and spec dicts and the template produces valid NebariApp YAML.
Design
Repository Layout
The library chart is distributed alongside the operator chart via the existing make helm-chart / make helm-package pipeline.
charts/
nebari-app/
Chart.yaml # type: library, name: nebari-app
templates/
_nebari-app.tpl # Named template producing a NebariApp CR
README.md # Consumer-facing documentation
Chart.yaml
apiVersion: v2
name: nebari-app
description: Library chart providing a NebariApp custom resource template.
type: library
version: 0.1.0
appVersion: "0.1.0"
Template
{{- define "nebari-app.nebariApp" -}}
{{- $_ := required "metadata.name is required" .metadata.name -}}
{{- $_ := required "metadata.namespace is required" .metadata.namespace -}}
{{- $_ := required "spec.hostname is required" .spec.hostname -}}
{{- $_ := required "spec.service.name is required" .spec.service.name -}}
{{- $_ := required "spec.service.port is required" .spec.service.port -}}
apiVersion: reconcilers.nebari.dev/v1
kind: NebariApp
metadata:
{{- toYaml .metadata | nindent 2 }}
spec:
{{- toYaml .spec | nindent 2 }}
{{- end -}}
The guards assign to a throwaway ($_) because required renders the value when present, not just validates it — bare {{- required ... -}} lines would concatenate their output onto the apiVersion: line and produce unparseable YAML. $_ captures and discards the value while keeping the validation.
The template validates the five required fields, then dumps metadata and spec wholesale via toYaml. The API server handles schema validation and defaulting for everything else. No field enumeration, no top context, no opinionated labels or namespace decisions.
Because the spec is passed through via toYaml (no field enumeration), the template is structurally immune to drift: the NebariAppSpec Go struct can change shape entirely and the template still renders valid YAML. The only struct-aware knowledge is the five required guards. If the required-field set ever grows large, those guards could be auto-generated from the CRD OpenAPI schema produced by controller-gen — but for five fields hand-maintained next to the types is simpler.
Consumer Usage
Single NebariApp with static values:
{{ include "nebari-app.nebariApp" (dict
"metadata" (dict
"name" .Release.Name
"namespace" .Release.Namespace
"labels" (dict "app.kubernetes.io/name" .Chart.Name)
)
"spec" .Values.nebariApp
) }}
Single NebariApp with dynamic service name (using mergeOverwrite):
The consumer uses mergeOverwrite to layer computed values under static values. Note: merge $dest $src gives precedence to $dest (the first argument), while mergeOverwrite $dest $src gives precedence to $src (the later argument). Here the consumer's values must win on overlap — in the common case the consumer sets nothing under nebariApp.service and the computed service.name/service.port fill in; if a consumer explicitly overrides service, their value takes precedence. So mergeOverwrite is the correct function.
{{- if .Values.frontend.enabled }}
{{- $component := "frontend" }}
{{- include "nebari-app.nebariApp" (dict
"metadata" (dict
"name" (include "ravnar.component-name" (dict "top" . "component" $component))
"namespace" .Release.Namespace
"labels" (include "ravnar.labels" (dict "top" . "component" $component) | fromYaml)
)
"spec" (mergeOverwrite (dict
"service" (dict
"name" (include "ravnar.component-name" (dict "top" . "component" $component))
"port" .Values.frontend.service.port
)
) .Values.frontend.nebariApp)
) }}
{{- end }}
mergeOverwrite puts the computed service dict first as the base, then layers the consumer's .Values.frontend.nebariApp on top — consumer values win on overlapping keys, computed values fill the gaps. This keeps the consumer values file clean (no service.name duplication) while still handling dynamically derived fields, and it lets a consumer override service if they ever need to. (If computed-wins were desired instead, that's merge — not used here.)
Multiple NebariApps from one chart:
When a chart defines several NebariApps, repeating the metadata block at every call site gets noisy. The consumer defines a thin wrapper template (e.g. ravnar.nebariApp) that hardcodes the metadata construction from top and component, and forwards spec to the library template:
{{/* ravnar.nebariApp — consumer wrapper that builds metadata and forwards spec. */}}
{{- define "ravnar.nebariApp" -}}
{{- $top := .top -}}
{{- $component := .component -}}
{{- include "nebari-app.nebariApp" (dict
"metadata" (dict
"name" (include "ravnar.component-name" (dict "top" $top "component" $component))
"namespace" $top.Release.Namespace
"labels" (include "ravnar.labels" (dict "top" $top "component" $component) | fromYaml)
)
"spec" .spec
) -}}
{{- end -}}
Each call site then only passes top, component, and the computed spec:
{{- if .Values.frontend.enabled }}
{{- $component := "frontend" }}
{{- include "ravnar.nebariApp" (dict
"top" .
"component" $component
"spec" (mergeOverwrite (dict
"service" (dict
"name" (include "ravnar.component-name" (dict "top" . "component" $component))
"port" .Values.frontend.service.port
)
) .Values.frontend.nebariApp)
) }}
{{- end }}
---
{{- if .Values.api.enabled }}
{{- $component := "api" }}
{{- include "ravnar.nebariApp" (dict
"top" .
"component" $component
"spec" (mergeOverwrite (dict
"service" (dict
"name" (include "ravnar.component-name" (dict "top" . "component" $component))
"port" .Values.api.service.port
)
) .Values.api.nebariApp)
) }}
{{- end }}
The wrapper is consumer-owned and consumer-named (ravnar.* here is illustrative) — the library chart stays a pure function with no knowledge of the consumer's naming or label conventions.
Example Consumer Values
frontend:
enabled: true
service:
port: 80
nebariApp:
hostname: chat.nebari.local
auth:
enabled: true
provider: keycloak
provisionClient: true
enforceAtGateway: false
redirectURI: /*
scopes:
- openid
- profile
- email
spaClient:
enabled: true
routing:
routes:
- pathPrefix: /
pathType: PathPrefix
landingPage:
enabled: true
displayName: Chat
description: Chat with AI models
category: AI
priority: 1
healthCheck:
enabled: true
path: /health
Field Reference
The canonical reference for all spec fields is docs/api-reference.md (auto-generated from the Go types via crd-ref-docs). The Configuration Reference provides examples and usage guidance.
Tradeoffs & Risks
| Tradeoff |
Mitigation |
| No field-by-field validation in the template. The template only validates the five required fields. All other validation happens API-server-side at apply time. |
Acceptable — helm template output is piped through kubectl --dry-run=client in CI, catching schema errors before deployment. |
New required CRD fields won't be guarded. If a field gains +kubebuilder:validation:Required, the template won't fail-fast on it until the guard is added. |
kubectl --dry-run=client in CI catches it at apply time. The required set is small and changes rarely; if it grows, the guards can be auto-generated from the CRD OpenAPI schema. |
Consumer must construct service separately from nebariApp. service.name is often dynamic (derived from component name via a helper), while everything else in the spec is static values. |
The mergeOverwrite pattern cleanly separates computed fields from static values. The consumer values file doesn't duplicate service.name. |
Namespace opt-in not templated. The operator's CoreReconciler requires the target namespace to carry nebari.dev/managed=true. A consumer can render a valid NebariApp that the operator then rejects. |
Templating the namespace label is out of scope, but the chart README documents the requirement so consumers know to opt in their namespace. |
Testing Strategy
Library charts are not installable, so helm template charts/nebari-app errors with library charts are not installable. Testing requires a fixture chart that declares nebari-app as a dependency and includes the template.
The fixture chart covers all three usage patterns from the Design section — single static, single with computed service (mergeOverwrite), and multiple NebariApps — each gated by an enabled boolean so a single helm template run with --set switches render the right case. One values file holds all the data.
test/fixture/
Chart.yaml # depends on nebari-app (file://../../charts/nebari-app)
values.yaml # data for all three cases
templates/
static.yaml # case 1: single NebariApp, static values (gated by .Values.cases.static.enabled)
computed.yaml # case 2: single NebariApp, mergeOverwrite service (gated by .Values.cases.computed.enabled)
multi.yaml # case 3: multiple NebariApps via range (gated by .Values.cases.multi.enabled)
Lint
helm lint charts/nebari-app
Works directly on the library chart. Ensures Chart.yaml is valid and the template parses.
Template rendering
One values file, three cases, each enabled via --set:
helm dependency build test/fixture
helm template test test/fixture --set cases.static.enabled=true
helm template test test/fixture --set cases.computed.enabled=true
helm template test test/fixture --set cases.multi.enabled=true
Verify output is valid NebariApp YAML (pipe through kubectl --dry-run=client -f - if a cluster with the CRD is available).
Required field validation
Helm aborts at the first required failure, so each missing-field test enables exactly one case and omits exactly one required field from it. This isolates the error to the field under test. The same fixture chart and values file are reused — only the --set and the omitted field change:
# Each expects a render failure naming the field
helm template test test/fixture --set cases.static.enabled=true --set spec.hostname='' # expect: spec.hostname is required
helm template test test/fixture --set cases.static.enabled=true --set metadata.name='' # expect: metadata.name is required
helm template test test/fixture --set cases.static.enabled=true --set metadata.namespace='' # expect: metadata.namespace is required
helm template test test/fixture --set cases.static.enabled=true --set spec.service.name='' # expect: spec.service.name is required
helm template test test/fixture --set cases.static.enabled=true --set spec.service.port='' # expect: spec.service.port is required
CI integration
A Makefile target or GitHub Actions workflow that runs on every PR touching api/ or charts/:
- Run
helm lint charts/nebari-app.
helm dependency build test/fixture then helm template test/fixture once per case via --set.
Test values
The single test/fixture/values.yaml holds data for all three cases (static, computed, multi), each section self-contained and gated by its enabled bool. No per-case values files.
Versioning
The library chart's version and appVersion match the operator chart exactly — same release, same bump. This couples the library to the operator release cadence, which is acceptable because the template only changes when the NebariAppSpec Go struct changes, and those changes ship in the operator release anyway. The release pipeline auto-populates both versions from the operator version when packaging (same mechanism make helm-chart-version uses for the operator chart).
A library Helm chart (
nebari-app) that provides a reusable named template for creatingNebariAppcustom resource instances. The template acts as a pure function — callers passmetadataandspecdicts, and the template renders aNebariAppresource. No global context, no field enumeration. The chart lives in this repository alongside the operator.Goals
NebariAppCR instances.metadata.name,metadata.namespace,spec.hostname,spec.service.name,spec.service.port) viarequired.toYaml.Non-Goals
CustomResourceDefinitionitself is installed separately. This library chart templates only CR instances.Service,HTTPRoute,Certificate, or any other resources.values.schema.jsonis shipped. The template'srequiredguards and the API server handle validation.values.yaml. It receives explicitmetadataandspecparameters.type: librarychart, not folded into the operator'stype: applicationchart (dist/chart/). A consumer that depended on the operator chart to get the template would also deploy the operator, its CRDs, RBAC, and manager — anebariOperator.enabled: falsekill switch is non-idiomatic and couples template versioning to operator releases. Two separate charts, packaged by the same release pipeline.Background / Motivation
Today, every Helm chart that wants to onboard an application into the Nebari platform must write a raw
NebariAppYAML manifest. This duplicates the resource structure across charts and makes it easy to drift from the operator's expected schema.A library chart solves this: the template lives in the operator repo, evolves with the API types, and is consumed via a standard Helm dependency. Consumer charts pass
metadataandspecdicts and the template produces validNebariAppYAML.Design
Repository Layout
The library chart is distributed alongside the operator chart via the existing
make helm-chart/make helm-packagepipeline.Chart.yaml
Template
The guards assign to a throwaway (
$_) becauserequiredrenders the value when present, not just validates it — bare{{- required ... -}}lines would concatenate their output onto theapiVersion:line and produce unparseable YAML.$_captures and discards the value while keeping the validation.The template validates the five required fields, then dumps
metadataandspecwholesale viatoYaml. The API server handles schema validation and defaulting for everything else. No field enumeration, notopcontext, no opinionated labels or namespace decisions.Because the spec is passed through via
toYaml(no field enumeration), the template is structurally immune to drift: theNebariAppSpecGo struct can change shape entirely and the template still renders valid YAML. The only struct-aware knowledge is the fiverequiredguards. If the required-field set ever grows large, those guards could be auto-generated from the CRD OpenAPI schema produced bycontroller-gen— but for five fields hand-maintained next to the types is simpler.Consumer Usage
Single NebariApp with static values:
Single NebariApp with dynamic service name (using
mergeOverwrite):The consumer uses
mergeOverwriteto layer computed values under static values. Note:merge $dest $srcgives precedence to$dest(the first argument), whilemergeOverwrite $dest $srcgives precedence to$src(the later argument). Here the consumer's values must win on overlap — in the common case the consumer sets nothing undernebariApp.serviceand the computedservice.name/service.portfill in; if a consumer explicitly overridesservice, their value takes precedence. SomergeOverwriteis the correct function.mergeOverwriteputs the computedservicedict first as the base, then layers the consumer's.Values.frontend.nebariAppon top — consumer values win on overlapping keys, computed values fill the gaps. This keeps the consumer values file clean (noservice.nameduplication) while still handling dynamically derived fields, and it lets a consumer overrideserviceif they ever need to. (If computed-wins were desired instead, that'smerge— not used here.)Multiple NebariApps from one chart:
When a chart defines several NebariApps, repeating the
metadatablock at every call site gets noisy. The consumer defines a thin wrapper template (e.g.ravnar.nebariApp) that hardcodes the metadata construction fromtopandcomponent, and forwardsspecto the library template:Each call site then only passes
top,component, and the computedspec:The wrapper is consumer-owned and consumer-named (
ravnar.*here is illustrative) — the library chart stays a pure function with no knowledge of the consumer's naming or label conventions.Example Consumer Values
Field Reference
The canonical reference for all spec fields is docs/api-reference.md (auto-generated from the Go types via
crd-ref-docs). The Configuration Reference provides examples and usage guidance.Tradeoffs & Risks
helm templateoutput is piped throughkubectl --dry-run=clientin CI, catching schema errors before deployment.+kubebuilder:validation:Required, the template won't fail-fast on it until the guard is added.kubectl --dry-run=clientin CI catches it at apply time. The required set is small and changes rarely; if it grows, the guards can be auto-generated from the CRD OpenAPI schema.serviceseparately fromnebariApp.service.nameis often dynamic (derived from component name via a helper), while everything else in the spec is static values.mergeOverwritepattern cleanly separates computed fields from static values. The consumer values file doesn't duplicateservice.name.nebari.dev/managed=true. A consumer can render a validNebariAppthat the operator then rejects.Testing Strategy
Library charts are not installable, so
helm template charts/nebari-apperrors withlibrary charts are not installable. Testing requires a fixture chart that declaresnebari-appas a dependency andincludes the template.The fixture chart covers all three usage patterns from the Design section — single static, single with computed service (
mergeOverwrite), and multiple NebariApps — each gated by anenabledboolean so a singlehelm templaterun with--setswitches render the right case. One values file holds all the data.Lint
Works directly on the library chart. Ensures Chart.yaml is valid and the template parses.
Template rendering
One values file, three cases, each enabled via
--set:Verify output is valid
NebariAppYAML (pipe throughkubectl --dry-run=client -f -if a cluster with the CRD is available).Required field validation
Helm aborts at the first
requiredfailure, so each missing-field test enables exactly one case and omits exactly one required field from it. This isolates the error to the field under test. The same fixture chart and values file are reused — only the--setand the omitted field change:CI integration
A
Makefiletarget or GitHub Actions workflow that runs on every PR touchingapi/orcharts/:helm lint charts/nebari-app.helm dependency build test/fixturethenhelm template test/fixtureonce per case via--set.Test values
The single
test/fixture/values.yamlholds data for all three cases (static, computed, multi), each section self-contained and gated by itsenabledbool. No per-case values files.Versioning
The library chart's
versionandappVersionmatch the operator chart exactly — same release, same bump. This couples the library to the operator release cadence, which is acceptable because the template only changes when theNebariAppSpecGo struct changes, and those changes ship in the operator release anyway. The release pipeline auto-populates both versions from the operator version when packaging (same mechanismmake helm-chart-versionuses for the operator chart).