Skip to content

Library Helm Chart for NebariApp CR #148

Description

@pmeier

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/:

  1. Run helm lint charts/nebari-app.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Start date

    None yet

    Target date

    None yet

    Size

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions