From e470d2a4fec1536b3e4e0fe2f24e53ee55513f05 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:08:06 -0500 Subject: [PATCH 1/7] feat: render admin-provisioned nebi registries into a singleuser Secret --- templates/_helpers.tpl | 7 +++++++ templates/singleuser-nebi-config.yaml | 24 ++++++++++++++++++++++++ values.yaml | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 templates/singleuser-nebi-config.yaml diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index abc9bb8..81f6e5a 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -49,6 +49,13 @@ Singleuser config ConfigMap name {{- printf "%s-singleuser-config" (include "nebari-data-science-pack.name" .) -}} {{- end }} +{{/* +Nebi config Secret name (admin-provisioned registries) +*/}} +{{- define "nebari-data-science-pack.nebi-config-secret" -}} +{{- printf "%s-nebi-config" (include "nebari-data-science-pack.name" .) -}} +{{- end }} + {{/* Selector labels */}} diff --git a/templates/singleuser-nebi-config.yaml b/templates/singleuser-nebi-config.yaml new file mode 100644 index 0000000..224fbfc --- /dev/null +++ b/templates/singleuser-nebi-config.yaml @@ -0,0 +1,24 @@ +{{- /* + Nebi config for singleuser pods: admin-provisioned OCI registries and the + default-registry seed flag. Rendered only when the deployer customizes + either value; mounted by 01-spawner.py at /etc/nebi/config.yaml, a path + nebi searches at boot. A Secret (not ConfigMap) because registry + credentials may be inline. +*/}} +{{- if or .Values.nebi.registries (not .Values.nebi.seedDefaultRegistry) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "nebari-data-science-pack.nebi-config-secret" . }} + labels: + {{- include "nebari-data-science-pack.labels" . | nindent 4 }} +type: Opaque +stringData: + config.yaml: | + registries: + seed_default: {{ .Values.nebi.seedDefaultRegistry }} + {{- with .Values.nebi.registries }} + entries: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/values.yaml b/values.yaml index 8b6929d..277c1f6 100644 --- a/values.yaml +++ b/values.yaml @@ -247,6 +247,24 @@ nebi: # Port the Nebi server listens on (used in hub -> nebi NetworkPolicy # egress rule). port: 8460 + # Seed nebi's built-in default registry (quay.io/nebari_environments) in + # each user's per-pod nebi instance. Set false to remove it everywhere. + seedDefaultRegistry: true + # Admin-provisioned OCI registries for every user's nebi instance. + # Rendered into a Secret mounted at /etc/nebi/config.yaml in user pods + # (which nebi searches at boot). Each entry uses nebi's own + # `registries.entries` schema: name, url, namespace, username, password, + # api_token, default. Credential values support nebi's ${ENV_VAR} + # expansion, but note that anything mounted into a user pod, including + # these credentials, is readable by that pod's user. + registries: [] + # registries: + # - name: acme-registry + # url: registry.acme.com + # namespace: acme-envs + # username: shared-user + # password: "s3cret" + # default: true # Helm Job that one-shots Keycloak realm config for role-gated shared # mounts. Runs as a post-install / post-upgrade From a9a583ecfe466ae79ae65ea81f8e7cacb4ac871c Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:12:00 -0500 Subject: [PATCH 2/7] feat: mount nebi config Secret at /etc/nebi/config.yaml in user pods --- config/jupyterhub/01-spawner.py | 23 +++++++++++++++++++++++ templates/hub-config.yaml | 6 +++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 6a1e5bf..b062e53 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -70,6 +70,29 @@ }, ] +# --------------------------------------------------------------------------- +# Admin-provisioned nebi config (OCI registries, default-registry seed flag). +# Helm renders the deployer's `nebi.registries` / `nebi.seedDefaultRegistry` +# values into a Secret and substitutes its name below; the placeholder stays +# literal (and is skipped) when the deployer customizes neither value. +# nebi searches /etc/nebi/config.yaml at boot, so mounting is all it takes. +# --------------------------------------------------------------------------- +_NEBI_CONFIG_SECRET = "__NEBI_CONFIG_SECRET__" +if _NEBI_CONFIG_SECRET and not _NEBI_CONFIG_SECRET.startswith("__"): + c.KubeSpawner.volumes.append( + { + "name": "nebi-config", + "secret": {"secretName": _NEBI_CONFIG_SECRET}, + } + ) + c.KubeSpawner.volume_mounts.append( + { + "name": "nebi-config", + "mountPath": "/etc/nebi/config.yaml", + "subPath": "config.yaml", + } + ) + c.KubeSpawner.notebook_dir = "/home/jovyan" c.KubeSpawner.working_dir = "/home/jovyan" diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml index 6ec8cbd..ba8b2ab 100644 --- a/templates/hub-config.yaml +++ b/templates/hub-config.yaml @@ -16,6 +16,10 @@ metadata: {{- if $hubHost }} {{- $gatewayAuthPy = $gatewayAuthPy | replace "__CHART_OAUTH_CALLBACK_URL__" (printf "https://%s/hub/oauth_callback" $hubHost) | replace "__CHART_OAUTH_EXTERNAL_URL__" (printf "https://%s/" $hubHost) -}} {{- end }} +{{- $nebiConfigSecret := "" -}} +{{- if or .Values.nebi.registries (not .Values.nebi.seedDefaultRegistry) -}} +{{- $nebiConfigSecret = include "nebari-data-science-pack.nebi-config-secret" . -}} +{{- end }} data: # --------------------------------------------------------------------------- # 00-chart-derived.py — Helm-rendered defaults for every `custom.*` URL / @@ -74,7 +78,7 @@ data: 00-gateway-auth.py: | {{ $gatewayAuthPy | indent 4 }} 01-spawner.py: | -{{ .Files.Get "config/jupyterhub/01-spawner.py" | replace "__SINGLEUSER_CONFIG_CM__" (include "nebari-data-science-pack.singleuser-config" .) | indent 4 }} +{{ .Files.Get "config/jupyterhub/01-spawner.py" | replace "__SINGLEUSER_CONFIG_CM__" (include "nebari-data-science-pack.singleuser-config" .) | replace "__NEBI_CONFIG_SECRET__" $nebiConfigSecret | indent 4 }} 02-jhub-apps.py: | {{ .Files.Get "config/jupyterhub/02-jhub-apps.py" | indent 4 }} 03-nebi-envs.py: | From b42db5ab5e9c4bb19f480078af8c17cf2e2eaaaa Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:14:03 -0500 Subject: [PATCH 3/7] test: cover nebi-config Secret mount wiring in spawner --- tests/unit/test_spawner_nebi_config.py | 78 ++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/unit/test_spawner_nebi_config.py diff --git a/tests/unit/test_spawner_nebi_config.py b/tests/unit/test_spawner_nebi_config.py new file mode 100644 index 0000000..3443f91 --- /dev/null +++ b/tests/unit/test_spawner_nebi_config.py @@ -0,0 +1,78 @@ +"""Tests for the nebi-config Secret mount wiring in `01-spawner.py`. + +Helm substitutes __NEBI_CONFIG_SECRET__ with the Secret name when the +deployer sets `nebi.registries` or disables `nebi.seedDefaultRegistry`, +and with "" otherwise. The spawner must add the /etc/nebi/config.yaml +mount only when a real name was substituted. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +_z2jh = types.ModuleType("z2jh") +_z2jh.get_config = lambda key, default=None: default +sys.modules.setdefault("z2jh", _z2jh) + +from conftest import CONFIG_DIR, FakeConfig, load_config_module # noqa: E402 + + +def _load_spawner_with_secret(tmp_path: Path, c: FakeConfig, secret_name: str): + """Exec 01-spawner.py with the Helm placeholder substituted.""" + source = (CONFIG_DIR / "01-spawner.py").read_text() + rendered = source.replace("__NEBI_CONFIG_SECRET__", secret_name) + path = tmp_path / "01-spawner-rendered.py" + path.write_text(rendered) + + spec = importlib.util.spec_from_file_location("_spawner_rendered", path) + module = importlib.util.module_from_spec(spec) + module.__dict__["c"] = c + module.__dict__["get_chart_config"] = lambda key, default="": default + spec.loader.exec_module(module) + + +def _nebi_volume(c: FakeConfig): + return next( + (v for v in c.KubeSpawner.volumes if v.get("name") == "nebi-config"), None + ) + + +def _nebi_mount(c: FakeConfig): + return next( + (m for m in c.KubeSpawner.volume_mounts if m.get("name") == "nebi-config"), + None, + ) + + +def test_no_mount_when_placeholder_unrendered(): + """Raw source (placeholder intact) must not add the mount. This is + what unit tests and a template-render failure would see.""" + c = FakeConfig() + load_config_module("01-spawner.py", inject_c=c) + assert _nebi_volume(c) is None + assert _nebi_mount(c) is None + + +def test_no_mount_when_secret_name_empty(tmp_path): + """Helm substitutes "" when the deployer customizes nothing.""" + c = FakeConfig() + _load_spawner_with_secret(tmp_path, c, "") + assert _nebi_volume(c) is None + assert _nebi_mount(c) is None + + +def test_mount_added_when_secret_rendered(tmp_path): + c = FakeConfig() + _load_spawner_with_secret(tmp_path, c, "my-pack-nebi-config") + + volume = _nebi_volume(c) + assert volume is not None, "nebi-config volume missing" + assert volume["secret"]["secretName"] == "my-pack-nebi-config" + + mount = _nebi_mount(c) + assert mount is not None, "nebi-config volume_mount missing" + assert mount["mountPath"] == "/etc/nebi/config.yaml" + assert mount["subPath"] == "config.yaml" From 941d2c8d6a75adf543f6f39fa6562f29d4a05aef Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:20:08 -0500 Subject: [PATCH 4/7] refactor: single source of truth for nebi-config secret enablement --- templates/_helpers.tpl | 6 +++++- templates/hub-config.yaml | 5 +---- templates/singleuser-nebi-config.yaml | 2 +- values.yaml | 3 +++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index 81f6e5a..8335c3b 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -50,10 +50,14 @@ Singleuser config ConfigMap name {{- end }} {{/* -Nebi config Secret name (admin-provisioned registries) +Nebi config Secret name (admin-provisioned registries). Returns "" when the +deployer customizes neither nebi.registries nor nebi.seedDefaultRegistry, +so consumers can use emptiness as the single enable/disable signal. */}} {{- define "nebari-data-science-pack.nebi-config-secret" -}} +{{- if or .Values.nebi.registries (not .Values.nebi.seedDefaultRegistry) -}} {{- printf "%s-nebi-config" (include "nebari-data-science-pack.name" .) -}} +{{- end -}} {{- end }} {{/* diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml index ba8b2ab..c0c22fd 100644 --- a/templates/hub-config.yaml +++ b/templates/hub-config.yaml @@ -16,10 +16,7 @@ metadata: {{- if $hubHost }} {{- $gatewayAuthPy = $gatewayAuthPy | replace "__CHART_OAUTH_CALLBACK_URL__" (printf "https://%s/hub/oauth_callback" $hubHost) | replace "__CHART_OAUTH_EXTERNAL_URL__" (printf "https://%s/" $hubHost) -}} {{- end }} -{{- $nebiConfigSecret := "" -}} -{{- if or .Values.nebi.registries (not .Values.nebi.seedDefaultRegistry) -}} -{{- $nebiConfigSecret = include "nebari-data-science-pack.nebi-config-secret" . -}} -{{- end }} +{{- $nebiConfigSecret := include "nebari-data-science-pack.nebi-config-secret" . }} data: # --------------------------------------------------------------------------- # 00-chart-derived.py — Helm-rendered defaults for every `custom.*` URL / diff --git a/templates/singleuser-nebi-config.yaml b/templates/singleuser-nebi-config.yaml index 224fbfc..b7cd6a7 100644 --- a/templates/singleuser-nebi-config.yaml +++ b/templates/singleuser-nebi-config.yaml @@ -5,7 +5,7 @@ nebi searches at boot. A Secret (not ConfigMap) because registry credentials may be inline. */}} -{{- if or .Values.nebi.registries (not .Values.nebi.seedDefaultRegistry) }} +{{- if include "nebari-data-science-pack.nebi-config-secret" . }} apiVersion: v1 kind: Secret metadata: diff --git a/values.yaml b/values.yaml index 277c1f6..e87a2e8 100644 --- a/values.yaml +++ b/values.yaml @@ -257,6 +257,9 @@ nebi: # api_token, default. Credential values support nebi's ${ENV_VAR} # expansion, but note that anything mounted into a user pod, including # these credentials, is readable by that pod's user. + # Changes to this value (and to seedDefaultRegistry above) take effect + # only for user servers started after the hub pod restarts, since the + # mount wiring lives in the hub ConfigMap (01-spawner.py). registries: [] # registries: # - name: acme-registry From 8574f88500f3029bec72bb9cdc00e3ddc5334ec9 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:26:37 -0500 Subject: [PATCH 5/7] docs: document nebi registry values in README --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 185ddfb..c45c3bd 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,35 @@ make down See `values.yaml` for all configuration options. The chart wraps the [JupyterHub Helm chart](https://z2jh.jupyter.org/) - all `jupyterhub.*` values are passed through. +### Nebi Registries + +Admins can provision OCI registries for every user's nebi instance via +`nebi.registries`: + +```yaml +nebi: + registries: + - name: acme-registry + url: registry.acme.com + namespace: acme-envs + username: shared-user + password: "s3cret" + default: true +``` + +Each entry follows nebi's own `registries.entries` schema (`name`, `url`, +`namespace`, `username`, `password`, `api_token`, `default`) and is rendered +into a Secret mounted into user pods, so entries are locked in the UI rather +than editable per-user. Credentials mounted this way are readable by the pod +they land in, so treat `nebi.registries` credentials as visible to the user +they're provisioned for. + +Set `nebi.seedDefaultRegistry: false` to remove the built-in +`quay.io/nebari_environments` registry that nebi seeds by default. + +Both settings only take effect for user servers started after the hub pod +restarts, since the mount wiring lives in the hub ConfigMap. + ## Shared Storage Per-group shared directories (`/shared/` in every user pod) need a From 18ded410804c6891ab50d14169493e74b3603a7d Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:52:36 -0500 Subject: [PATCH 6/7] feat: allowlist the hub origin for nebi netguard in user pods nebi local mode rejects non-loopback Origin headers, which browsers send on the SPA's crossorigin asset requests, blanking the Nebi tile (nebari-dev/nebi#489). Inject NEBI_SERVER_ALLOWED_ORIGINS with the hub's public origin; requires a nebi build with server.allowed_origins (nebari-dev/nebi#490), older builds ignore the variable. --- config/jupyterhub/01-spawner.py | 10 +++++++++ tests/unit/test_spawner_nebi_config.py | 30 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index b062e53..8751c2c 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -282,6 +282,16 @@ def _setup_trust_bundle(spawner): env["NEBI_STORAGE_WORKSPACES_DIR"] = "/var/lib/nebi/workspaces" +# nebi's local-mode netguard only accepts loopback Origin headers by default. +# Browsers send the hub's public origin on CORS-mode asset requests (the SPA +# bundle is emitted as