Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ 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`. Only public (unauthenticated) registries are supported;
entries carry no credentials:

```yaml
nebi:
registries:
- name: acme-registry
url: registry.acme.com
namespace: acme-envs
default: true
```

Each entry follows nebi's own `registries.entries` schema (`name`, `url`,
`namespace`, `default`) and is rendered into a ConfigMap mounted into user
pods, so entries are locked in the UI rather than editable per-user.

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/<group>` in every user pod) need a
Expand Down
33 changes: 33 additions & 0 deletions config/jupyterhub/01-spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ConfigMap 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_CM = "__NEBI_CONFIG_CM__"
if _NEBI_CONFIG_CM and not _NEBI_CONFIG_CM.startswith("__"):
c.KubeSpawner.volumes.append(
{
"name": "nebi-config",
"configMap": {"name": _NEBI_CONFIG_CM},
}
)
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"

Expand Down Expand Up @@ -259,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 <script type="module" crossorigin>), which blanked the
# Nebi tile (nebari-dev/nebi#489). Allow the hub origin explicitly; the env
# var reaches nebi because jupyter-server-proxy children inherit the pod
# environment. Older nebi builds without server.allowed_origins ignore it.
_hub_external_host = get_chart_config("external-url")
if _hub_external_host:
env["NEBI_SERVER_ALLOWED_ORIGINS"] = f"https://{_hub_external_host}"

c.KubeSpawner.environment = env


Expand Down
11 changes: 11 additions & 0 deletions templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ Singleuser config ConfigMap name
{{- printf "%s-singleuser-config" (include "nebari-data-science-pack.name" .) -}}
{{- end }}

{{/*
Nebi config ConfigMap 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" -}}
{{- if or .Values.nebi.registries (not .Values.nebi.seedDefaultRegistry) -}}
{{- printf "%s-nebi-config" (include "nebari-data-science-pack.name" .) -}}
{{- end -}}
{{- end }}

{{/*
Selector labels
*/}}
Expand Down
3 changes: 2 additions & 1 deletion templates/hub-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +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 }}
{{- $nebiConfigCM := include "nebari-data-science-pack.nebi-config" . }}
data:
# ---------------------------------------------------------------------------
# 00-chart-derived.py — Helm-rendered defaults for every `custom.*` URL /
Expand Down Expand Up @@ -74,7 +75,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_CM__" $nebiConfigCM | indent 4 }}
02-jhub-apps.py: |
{{ .Files.Get "config/jupyterhub/02-jhub-apps.py" | indent 4 }}
03-nebi-envs.py: |
Expand Down
23 changes: 23 additions & 0 deletions templates/singleuser-nebi-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{{- /*
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 plain ConfigMap: entries are public registry
pointers only, nebi does not accept credentials here.
*/}}
{{- if include "nebari-data-science-pack.nebi-config" . }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "nebari-data-science-pack.nebi-config" . }}
labels:
{{- include "nebari-data-science-pack.labels" . | nindent 4 }}
data:
config.yaml: |
registries:
seed_default: {{ .Values.nebi.seedDefaultRegistry }}
{{- with .Values.nebi.registries }}
entries:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
108 changes: 108 additions & 0 deletions tests/unit/test_spawner_nebi_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Tests for the nebi-config ConfigMap mount wiring in `01-spawner.py`.

Helm substitutes __NEBI_CONFIG_CM__ with the ConfigMap 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_configmap(tmp_path: Path, c: FakeConfig, cm_name: str):
"""Exec 01-spawner.py with the Helm placeholder substituted."""
source = (CONFIG_DIR / "01-spawner.py").read_text()
rendered = source.replace("__NEBI_CONFIG_CM__", cm_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_configmap_name_empty(tmp_path):
"""Helm substitutes "" when the deployer customizes nothing."""
c = FakeConfig()
_load_spawner_with_configmap(tmp_path, c, "")
assert _nebi_volume(c) is None
assert _nebi_mount(c) is None


def test_mount_added_when_configmap_rendered(tmp_path):
c = FakeConfig()
_load_spawner_with_configmap(tmp_path, c, "my-pack-nebi-config")

volume = _nebi_volume(c)
assert volume is not None, "nebi-config volume missing"
assert volume["configMap"]["name"] == "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"


def _load_spawner_with_chart_config(c: FakeConfig, chart_values: dict):
"""Exec raw 01-spawner.py with a custom get_chart_config stub."""
path = CONFIG_DIR / "01-spawner.py"
spec = importlib.util.spec_from_file_location("_spawner_chartcfg", path)
module = importlib.util.module_from_spec(spec)
module.__dict__["c"] = c
module.__dict__["get_chart_config"] = (
lambda key, default="": chart_values.get(key, default)
)
spec.loader.exec_module(module)


def test_allowed_origins_env_set_from_hub_host():
"""The hub public origin must be allowlisted for nebi netguard
(nebari-dev/nebi#489), or browsers get 403 on the SPA asset bundle."""
c = FakeConfig()
_load_spawner_with_chart_config(c, {"external-url": "hub.example.com"})
assert (
c.KubeSpawner.environment["NEBI_SERVER_ALLOWED_ORIGINS"]
== "https://hub.example.com"
)


def test_allowed_origins_env_absent_without_hub_host():
"""No derivable hub host (plain kind deploy): do not set an empty origin."""
c = FakeConfig()
_load_spawner_with_chart_config(c, {})
assert "NEBI_SERVER_ALLOWED_ORIGINS" not in c.KubeSpawner.environment
17 changes: 17 additions & 0 deletions values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,23 @@ 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.
# Public (unauthenticated) registries only; entries carry no credentials.
# Rendered into a ConfigMap 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, default.
# 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
# url: registry.acme.com
# namespace: acme-envs
# default: true

# Helm Job that one-shots Keycloak realm config for role-gated shared
# mounts. Runs as a post-install / post-upgrade
Expand Down
Loading