Context
The org has ~14 active software packs (nebari-dev/repositories?q=pack), of which 8–10 build their charts around the NebariApp CRD: data-science, llm-serving, data-engineering, superset, rayserve, chat, mlflow, lgtm, pi-coding-agent, and probably a few more I'm miscounting. Each of them carries its own copy of templates/nebariapp.yaml that hand-renders the NebariApp object — wrapping CRD field names in {{- with ... }} blocks, building up spec.routing, spec.auth, spec.landingPage, etc.
Today, when this operator adds a field to the NebariApp CRD (recent examples: routing.tls.secretName via #114, auth.enforceAtGateway, auth.tokenExchange), each pack's template needs an independent hand-edit to surface that field. If a pack doesn't update, the field is silently dropped at templating time — set it in values.yaml, see nothing in the rendered manifest. We hit this in nebari-data-science-pack#57: a real deployment needed enforceAtGateway: true and the only path through was kubectl patch against the live resource, with the chart's values.yaml then drifting from the cluster.
The immediate per-pack fix is to switch the hand-rendered blocks to toYaml | nindent N passthrough (just landed in nebari-data-science-pack#94). That fixes the drop-silently problem at the field level, but it doesn't fix the structural problem: every pack still has its own 60-line templates/nebariapp.yaml, and every CRD addition still requires N copies to converge.
Proposal
Ship a Helm library chart from this repo (or from a sibling repo — see Where to host) that exports a nebari.app.spec template. Each pack's templates/nebariapp.yaml collapses to:
{{- if .Values.nebariapp.enabled }}
{{ include "nebari.app.spec" . }}
{{- end }}
The library template itself is pure passthrough plus the object shell:
{{- define "nebari.app.spec" -}}
apiVersion: reconcilers.nebari.dev/v1
kind: NebariApp
metadata:
name: {{ include "nebari.app.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "nebari.app.labels" . | nindent 4 }}
spec:
hostname: {{ required "nebariapp.hostname is required" .Values.nebariapp.hostname }}
{{- with .Values.nebariapp.service }}
service:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.nebariapp.routing }}
routing:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.nebariapp.auth }}
auth:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.nebariapp.landingPage }}
landingPage:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end -}}
Adding a new spec.X field to the CRD becomes:
- Update the CRD in this repo.
- Add a one-line
{{- with .Values.nebariapp.X }} X: {{- toYaml . | nindent 4 }} {{- end }} to the library template.
- Bump the library chart minor version.
- Each pack picks it up via
helm dependency update on its next release.
That last step is still N-shaped, but it's mechanical (a Chart.yaml version bump) rather than a template edit, and reviewable in seconds rather than minutes. The library template is also the natural place to enforce object-level invariants once we have them (label conventions, naming, required metadata).
What the library chart owns vs what stays per-pack
Library owns:
- The full
NebariApp object shell (apiVersion, kind, metadata structure with parameterized name).
- Every
spec.<section> block as passthrough.
- Standard label/annotation helpers (
nebari.dev/pack-name, version, helm.sh/chart, etc.) via named templates.
- Future composite helpers as they emerge — e.g., shared validation templates for value combinations the CRD can't fully express on its own.
Per-pack stays per-pack:
values.yaml defaults — every pack has its own JupyterHub-specific or Superset-specific or MLflow-specific OAuth callback path, scopes, healthCheck path.
values.schema.json — the curated supported subset differs per pack.
- Subchart wiring (jupyterhub, postgresql, etc.) and resources that aren't NebariApps.
Where to host
Three reasonable options, with trade-offs:
-
In nebari-operator itself (this repo). Pro: CRD and library chart move together — make prepare-release could publish both. Versions stay coupled, no chance of "CRD has new field, library doesn't expose it" drift. Con: this repo currently distributes via flat kubectl apply -f deploy/install.yaml, no Helm artifact pipeline at all. Adding one (OCI publish to ghcr.io/nebari-dev/charts, or a gh-pages-style index) is real but not large.
-
New nebari-helm-library repo. Pro: clean separation, library evolves at its own cadence, doesn't entangle with operator release ritual. Con: another repo to maintain; CRD-vs-library version coupling stops being mechanical and becomes process — easy to forget to bump the library when the CRD changes.
-
In nebari-software-pack-template. Tempting because it's the canonical scaffold-from point, but conceptually wrong — the template is meant to be copied and diverged from, not depended on. Mentioned for completeness; I'd reject this.
My lean: #1 (here, in nebari-operator). The version-coupling argument is strong, and the Helm-publish pipeline is a one-time cost that the org will benefit from regardless. If shipping Helm artifacts from this repo turns out to be heavier than expected, falling back to #2 is straightforward.
Distribution
Whichever repo hosts it, the library chart needs to be addressable from consuming packs' Chart.yaml. Two viable mechanisms:
- OCI registry (
oci://ghcr.io/nebari-dev/charts/nebari-app-lib). Helm 3.8+ supports this natively, no helm-repo-index machinery. Probably the right default given the org already uses ghcr for image distribution.
- Classic Helm repo via GitHub Pages. Works, well-trodden, but adds a Pages site to maintain.
OCI looks like the easier path.
Migration
No coordinated rewrite needed:
- First adoption:
nebari-software-pack-template. Once the template uses the library chart, every new pack scaffolded from it inherits the right pattern automatically.
- Existing packs: each one migrates opportunistically on its own release cycle. The passthrough shape that several packs are already converging on (per
nebari-data-science-pack#94) is one line away from the library include — delete the file body, add the include call, add the Chart.yaml dependency. Reviewable in five minutes.
- Long tail: packs that don't migrate aren't broken — they just don't get the automatic alignment benefit. No urgency, no flag day.
Open questions
- Whether to also include the controlling
{{- if .Values.nebariapp.enabled }} guard inside the library define, or leave it to the consumer. Both work; leaving it to the consumer keeps the library template strictly about the NebariApp object shape and lets consumers gate it on whatever condition they want.
- Whether
metadata.name should be derived from a library helper (nebari.app.fullname) or expected to be pre-computed by the consumer. The former enforces consistency; the latter respects packs that already have their own naming conventions.
- Whether to ship CRD validation (e.g., a
values.schema.json fragment for the supported nebariapp.* shape) alongside the library — would catch typos at helm lint time. Could be a phase 2.
- Versioning policy. The library version should track CRD compatibility (e.g., library 0.X is compatible with CRD reconcilers.nebari.dev/v1 fields up to operator version Y), but the exact contract is worth being explicit about.
Out of scope (for this proposal, can be follow-ups)
- A full operator-deploy Helm chart (we still ship via flat manifests; not changing that here).
- Migrating any specific pack — this proposal is about creating the library, not the per-pack adoption work.
- Replacing CRD validation. The library is layout, not semantics; the CRD remains the authority on what values are accepted.
Refs: nebari-data-science-pack#57, nebari-data-science-pack#94, #114 (routing.tls.secretName addition that motivated the passthrough pattern for routing).
Context
The org has ~14 active software packs (
nebari-dev/repositories?q=pack), of which 8–10 build their charts around theNebariAppCRD: data-science, llm-serving, data-engineering, superset, rayserve, chat, mlflow, lgtm, pi-coding-agent, and probably a few more I'm miscounting. Each of them carries its own copy oftemplates/nebariapp.yamlthat hand-renders theNebariAppobject — wrapping CRD field names in{{- with ... }}blocks, building upspec.routing,spec.auth,spec.landingPage, etc.Today, when this operator adds a field to the
NebariAppCRD (recent examples:routing.tls.secretNamevia #114,auth.enforceAtGateway,auth.tokenExchange), each pack's template needs an independent hand-edit to surface that field. If a pack doesn't update, the field is silently dropped at templating time — set it invalues.yaml, see nothing in the rendered manifest. We hit this innebari-data-science-pack#57: a real deployment neededenforceAtGateway: trueand the only path through waskubectl patchagainst the live resource, with the chart'svalues.yamlthen drifting from the cluster.The immediate per-pack fix is to switch the hand-rendered blocks to
toYaml | nindent Npassthrough (just landed innebari-data-science-pack#94). That fixes the drop-silently problem at the field level, but it doesn't fix the structural problem: every pack still has its own 60-linetemplates/nebariapp.yaml, and every CRD addition still requires N copies to converge.Proposal
Ship a Helm library chart from this repo (or from a sibling repo — see Where to host) that exports a
nebari.app.spectemplate. Each pack'stemplates/nebariapp.yamlcollapses to:The library template itself is pure passthrough plus the object shell:
Adding a new
spec.Xfield to the CRD becomes:{{- with .Values.nebariapp.X }} X: {{- toYaml . | nindent 4 }} {{- end }}to the library template.helm dependency updateon its next release.That last step is still N-shaped, but it's mechanical (a
Chart.yamlversion bump) rather than a template edit, and reviewable in seconds rather than minutes. The library template is also the natural place to enforce object-level invariants once we have them (label conventions, naming, required metadata).What the library chart owns vs what stays per-pack
Library owns:
NebariAppobject shell (apiVersion, kind, metadata structure with parameterized name).spec.<section>block as passthrough.nebari.dev/pack-name, version, helm.sh/chart, etc.) via named templates.Per-pack stays per-pack:
values.yamldefaults — every pack has its own JupyterHub-specific or Superset-specific or MLflow-specific OAuth callback path, scopes, healthCheck path.values.schema.json— the curated supported subset differs per pack.Where to host
Three reasonable options, with trade-offs:
In
nebari-operatoritself (this repo). Pro: CRD and library chart move together —make prepare-releasecould publish both. Versions stay coupled, no chance of "CRD has new field, library doesn't expose it" drift. Con: this repo currently distributes via flatkubectl apply -f deploy/install.yaml, no Helm artifact pipeline at all. Adding one (OCI publish toghcr.io/nebari-dev/charts, or agh-pages-style index) is real but not large.New
nebari-helm-libraryrepo. Pro: clean separation, library evolves at its own cadence, doesn't entangle with operator release ritual. Con: another repo to maintain; CRD-vs-library version coupling stops being mechanical and becomes process — easy to forget to bump the library when the CRD changes.In
nebari-software-pack-template. Tempting because it's the canonical scaffold-from point, but conceptually wrong — the template is meant to be copied and diverged from, not depended on. Mentioned for completeness; I'd reject this.My lean: #1 (here, in
nebari-operator). The version-coupling argument is strong, and the Helm-publish pipeline is a one-time cost that the org will benefit from regardless. If shipping Helm artifacts from this repo turns out to be heavier than expected, falling back to #2 is straightforward.Distribution
Whichever repo hosts it, the library chart needs to be addressable from consuming packs'
Chart.yaml. Two viable mechanisms:oci://ghcr.io/nebari-dev/charts/nebari-app-lib). Helm 3.8+ supports this natively, no helm-repo-index machinery. Probably the right default given the org already uses ghcr for image distribution.OCI looks like the easier path.
Migration
No coordinated rewrite needed:
nebari-software-pack-template. Once the template uses the library chart, every new pack scaffolded from it inherits the right pattern automatically.nebari-data-science-pack#94) is one line away from the library include — delete the file body, add theincludecall, add the Chart.yaml dependency. Reviewable in five minutes.Open questions
{{- if .Values.nebariapp.enabled }}guard inside the librarydefine, or leave it to the consumer. Both work; leaving it to the consumer keeps the library template strictly about the NebariApp object shape and lets consumers gate it on whatever condition they want.metadata.nameshould be derived from a library helper (nebari.app.fullname) or expected to be pre-computed by the consumer. The former enforces consistency; the latter respects packs that already have their own naming conventions.values.schema.jsonfragment for the supportednebariapp.*shape) alongside the library — would catch typos athelm linttime. Could be a phase 2.Out of scope (for this proposal, can be follow-ups)
Refs:
nebari-data-science-pack#57,nebari-data-science-pack#94,#114(routing.tls.secretNameaddition that motivated the passthrough pattern forrouting).