From f7cc6d100aa07e7d4d92833f7f0e29ef88933df3 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:13:39 +0100 Subject: [PATCH 1/9] feat(chart): auto-wire hub OAuth from nebariapp.hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the deployer-side boilerplate of setting OAUTH_CALLBACK_URL + OAUTH_EXTERNAL_URL via hub.extraEnv. The chart now renders these values directly into 00-gateway-auth.py via Helm replace at template time, using nebariapp.hostname, and adds the operator-provisioned oidc-client Secret mount as a default in hub.extraVolumes / extraVolumeMounts. Changes: - 00-gateway-auth.py: read URLs from chart-rendered constants first, fall back to OAUTH_CALLBACK_URL env (legacy escape hatch). Add _resolve_oauth_urls() helper to encapsulate the precedence. - templates/hub-config.yaml: substitute __CHART_OAUTH_*__ placeholders with values computed from nebariapp.hostname. Skip substitution when nebariapp is disabled or hostname is unset, leaving the env-var path intact for kind / local-dev deploys. - values.yaml: add oauth-client volume + mount defaults referencing the operator's standard secret name pattern. Document the z2jh list-replace gotcha so deployers re-include both entries if they override extraVolumes. - tests/unit: new cases for _resolve_oauth_urls() — chart wins over env, env serves as fallback, neither returns None. Fixes the regression seen on nebari.openteams.ai after switching from git-branch chart source to published chart 0.1.0-alpha.12: deployers without the manual OAUTH_* extraEnv wiring fell back to dummy auth. --- config/jupyterhub/00-gateway-auth.py | 42 ++++++++++++++++++----- templates/hub-config.yaml | 13 ++++++- tests/unit/test_keycloak_authenticator.py | 36 ++++++++++++++++++- values.yaml | 20 ++++++++++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/config/jupyterhub/00-gateway-auth.py b/config/jupyterhub/00-gateway-auth.py index 118e341..4c0df2a 100644 --- a/config/jupyterhub/00-gateway-auth.py +++ b/config/jupyterhub/00-gateway-auth.py @@ -446,12 +446,36 @@ def _read_secret_file(secret_dir: Path, key: str) -> str: return (secret_dir / key).read_text().strip() +# Chart-rendered constants. ``templates/hub-config.yaml`` substitutes the +# ``__CHART_*__`` placeholders with values computed from ``nebariapp.hostname`` +# at Helm render time, so deployers do not need to repeat the URLs in their +# ``hub.extraEnv``. Untouched placeholders (``__CHART_*__``) mean we are +# either running under a non-substituting renderer (a unit test, a local +# ``kind`` deploy without nebariapp) or the deployer opted out — both cases +# fall back to the historical ``OAUTH_CALLBACK_URL`` env-var path. +_CHART_OAUTH_CALLBACK_URL = "__CHART_OAUTH_CALLBACK_URL__" +_CHART_OAUTH_EXTERNAL_URL = "__CHART_OAUTH_EXTERNAL_URL__" + + +def _resolve_oauth_urls() -> tuple[str, str] | None: + """Return (callback_url, external_url) or None when OAuth is opted out. + + Chart-rendered values win; env vars are the legacy escape hatch. + """ + if _CHART_OAUTH_CALLBACK_URL.startswith("https://"): + return _CHART_OAUTH_CALLBACK_URL, _CHART_OAUTH_EXTERNAL_URL + env_callback = os.environ.get("OAUTH_CALLBACK_URL") + if env_callback: + return env_callback, os.environ["OAUTH_EXTERNAL_URL"] + return None + + # When loaded by JupyterHub, `c` is a magic global. On host imports (tests), # `c` is undefined and the production wiring is skipped. # # Production wiring is gated TWICE: # 1. `c` must exist (real JupyterHub run, not a host import). -# 2. `OAUTH_CALLBACK_URL` must be set (deployer opted into KC OAuth). +# 2. OAuth URLs must resolve — via chart-rendered constants OR env vars. # Without (2), the chart's default authenticator (dummy) stays in place, # so plain `kind` deploys come up without needing the operator Secret. try: @@ -459,13 +483,15 @@ def _read_secret_file(secret_dir: Path, key: str) -> str: except NameError: pass else: - if os.environ.get("OAUTH_CALLBACK_URL"): + _urls = _resolve_oauth_urls() + if _urls is not None: + _callback_url, _external_url = _urls _secret_dir = Path(os.environ.get("OAUTH_SECRET_DIR", "/etc/oauth")) # RBAC for role-gated /shared/ mounts. - # Read from env vars on the hub Deployment rather than via - # z2jh.get_config (which sources from the hub Secret's - # embedded values.yaml). The Secret is hold-stable by - # ArgoCD-side ignoreDifferences on rotating fields, and + # Read realm_api_url / role-name from env vars on the hub + # Deployment rather than via z2jh.get_config (which sources from + # the hub Secret's embedded values.yaml). The Secret is held + # stable by ArgoCD-side ignoreDifferences on rotating fields, and # mutating non-rotating fields inside it via Helm-template # +ArgoCD-SSA doesn't reliably propagate. Env-var-on-Deployment # flows through standard SSA + checksum-driven hub rollout, so @@ -475,8 +501,8 @@ def _read_secret_file(secret_dir: Path, key: str) -> str: issuer=_read_secret_file(_secret_dir, "issuer-url"), client_id=_read_secret_file(_secret_dir, "client-id"), client_secret=_read_secret_file(_secret_dir, "client-secret"), - callback_url=os.environ["OAUTH_CALLBACK_URL"], - external_url=os.environ["OAUTH_EXTERNAL_URL"], + callback_url=_callback_url, + external_url=_external_url, realm_api_url=os.environ.get("KC_REALM_API_URL", ""), shared_mount_role_name=os.environ.get( "KC_SHARED_MOUNT_ROLE", diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml index 6797863..ce55d09 100644 --- a/templates/hub-config.yaml +++ b/templates/hub-config.yaml @@ -5,9 +5,20 @@ metadata: name: {{ include "nebari-data-science-pack.name" . }}-hub-config labels: {{- include "nebari-data-science-pack.labels" . | nindent 4 }} +{{- /* + Substitute OAuth URL placeholders in 00-gateway-auth.py with values + computed from .Values.nebariapp.hostname. When nebariapp is disabled + or hostname is unset, leave the __CHART_*__ placeholders intact so the + python file falls back to its OAUTH_CALLBACK_URL env-var path (or to + dummy auth on a plain kind deploy). +*/}} +{{- $gatewayAuthPy := .Files.Get "config/jupyterhub/00-gateway-auth.py" -}} +{{- if and .Values.nebariapp.enabled .Values.nebariapp.hostname }} +{{- $gatewayAuthPy = $gatewayAuthPy | replace "__CHART_OAUTH_CALLBACK_URL__" (printf "https://%s/hub/oauth_callback" .Values.nebariapp.hostname) | replace "__CHART_OAUTH_EXTERNAL_URL__" (printf "https://%s/" .Values.nebariapp.hostname) -}} +{{- end }} data: 00-gateway-auth.py: | -{{ .Files.Get "config/jupyterhub/00-gateway-auth.py" | indent 4 }} +{{ $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 }} 02-jhub-apps.py: | diff --git a/tests/unit/test_keycloak_authenticator.py b/tests/unit/test_keycloak_authenticator.py index 84b518b..253788d 100644 --- a/tests/unit/test_keycloak_authenticator.py +++ b/tests/unit/test_keycloak_authenticator.py @@ -212,7 +212,7 @@ def test_any_keycloak_authenticated_user_is_allowed_by_default(): # --------------------------------------------------------------------------- -# Cycle 5 — production wiring is opt-in via env var +# Cycle 5 — production wiring is opt-in via chart-rendered URLs or env vars # --------------------------------------------------------------------------- def test_module_loads_in_jupyterhub_context_without_oauth_env(monkeypatch): @@ -234,3 +234,37 @@ def test_module_loads_in_jupyterhub_context_without_oauth_env(monkeypatch): assert "JupyterHub" not in c.__dict__, ( "Authenticator was configured despite missing OAUTH env vars" ) + + +def test_resolve_returns_none_when_placeholders_intact_and_env_unset(monkeypatch): + """No chart substitution + no env vars → no auth wiring intended.""" + for key in ("OAUTH_CALLBACK_URL", "OAUTH_EXTERNAL_URL"): + monkeypatch.delenv(key, raising=False) + mod = load_config_module("00-gateway-auth.py") + assert mod._resolve_oauth_urls() is None + + +def test_resolve_prefers_chart_rendered_urls_over_env(monkeypatch): + """Helm-rendered constants are the supported production path.""" + monkeypatch.setenv("OAUTH_CALLBACK_URL", "https://env-fallback.test/hub/oauth_callback") + monkeypatch.setenv("OAUTH_EXTERNAL_URL", "https://env-fallback.test/") + mod = load_config_module("00-gateway-auth.py") + monkeypatch.setattr(mod, "_CHART_OAUTH_CALLBACK_URL", "https://chart.test/hub/oauth_callback") + monkeypatch.setattr(mod, "_CHART_OAUTH_EXTERNAL_URL", "https://chart.test/") + assert mod._resolve_oauth_urls() == ( + "https://chart.test/hub/oauth_callback", + "https://chart.test/", + ) + + +def test_resolve_falls_back_to_env_when_placeholders_intact(monkeypatch): + """Env-var path stays as the escape hatch for non-substituting renders.""" + monkeypatch.setenv("OAUTH_CALLBACK_URL", "https://hub.example.test/hub/oauth_callback") + monkeypatch.setenv("OAUTH_EXTERNAL_URL", "https://hub.example.test/") + mod = load_config_module("00-gateway-auth.py") + # Placeholders left untouched (no Helm substitution at test time). + assert mod._CHART_OAUTH_CALLBACK_URL.startswith("__CHART_") + assert mod._resolve_oauth_urls() == ( + "https://hub.example.test/hub/oauth_callback", + "https://hub.example.test/", + ) diff --git a/values.yaml b/values.yaml index f75557a..f7771a7 100644 --- a/values.yaml +++ b/values.yaml @@ -325,15 +325,33 @@ jupyterhub: # - email # ============================================================ - # Mount custom config files from ConfigMap + # Mount custom config files from ConfigMap + the operator-provisioned + # Keycloak OIDC client Secret. 00-gateway-auth.py reads client-id / + # client-secret / issuer-url from /etc/oauth/ when KC OAuth is wired. + # + # IMPORTANT: z2jh treats ``extraVolumes`` and ``extraVolumeMounts`` as + # lists that REPLACE (not merge) on override. If a deployer sets either + # of these, they MUST re-include the entries below or the hub falls + # back to dummy auth and/or jupyterhub_config.d is empty. + # + # The oauth-client secretName follows the operator's convention: + # ``{Release.Name}-{Chart.Name}-oidc-client``. Override here if you + # use a non-standard release name. extraVolumes: - name: custom-config configMap: name: nebari-data-science-pack-hub-config + - name: oauth-client + secret: + secretName: data-science-pack-nebari-data-science-pack-oidc-client + optional: true extraVolumeMounts: - name: custom-config mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/ + - name: oauth-client + mountPath: /etc/oauth + readOnly: true service: extraPorts: From 3aa1f3764663ac137916e3c1bdbfec589dc950c9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:20:42 +0100 Subject: [PATCH 2/9] feat(chart): enable rbac.bootstrap by default with sensible defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Nebari deployment uses bitnami/keycloakx + nebari-operator NebariApps, so the chart can self-bootstrap the KC role + group mapper the spawner needs. Without this, every deployer had to copy the same rbac.bootstrap block into their values, and forgetting it left the hub OIDC client with no roles — spawner could not gate shared mounts and the Roles tab in KC was empty. - values.yaml: flip enabled to true, default namespace=keycloak + kcAdminCredentialSecret=keycloak-admin-credentials so a vanilla Nebari install needs no extra config. - keycloak-rbac-bootstrap-job.yaml: derive hubClientId from `jupyterhub--` when unset (the operator's NebariApp client-id pattern). Removes the deployer-must-set failure when the rest of the layout is standard. Override any field for non-Nebari (kind, BYO-Keycloak) deployments. --- templates/keycloak-rbac-bootstrap-job.yaml | 12 +++++++---- values.yaml | 24 ++++++++++++++-------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/templates/keycloak-rbac-bootstrap-job.yaml b/templates/keycloak-rbac-bootstrap-job.yaml index 0db33e7..815d010 100644 --- a/templates/keycloak-rbac-bootstrap-job.yaml +++ b/templates/keycloak-rbac-bootstrap-job.yaml @@ -21,9 +21,13 @@ Secret reference for the KC admin password — the chart deploys fine without RBAC bootstrap pre-configured. */}} {{- if and .Values.rbac.bootstrap.enabled .Values.rbac.bootstrap.kcAdminCredentialSecret }} -{{- if not .Values.rbac.bootstrap.hubClientId }} -{{- fail "rbac.bootstrap.hubClientId must be set when rbac.bootstrap.enabled=true — the role + SA bindings attach to this Keycloak client." }} -{{- end }} +{{/* +Auto-derive hubClientId from release + chart name when the deployer +hasn't set one explicitly. Matches the nebari-operator NebariApp +client-id pattern: ``jupyterhub--``. Override via +``rbac.bootstrap.hubClientId`` for non-standard layouts. +*/}} +{{- $hubClientId := .Values.rbac.bootstrap.hubClientId | default (printf "jupyterhub-%s-%s" .Release.Name .Chart.Name) }} {{- $script := .Files.Get "files/keycloak_rbac_bootstrap.py" }} {{- if not $script }} {{- fail "files/keycloak_rbac_bootstrap.py missing from the chart — repackage with the file included." }} @@ -95,7 +99,7 @@ spec: - name: REALM value: {{ .Values.rbac.bootstrap.realmName | quote }} - name: HUB_CLIENT_ID - value: {{ .Values.rbac.bootstrap.hubClientId | quote }} + value: {{ $hubClientId | quote }} - name: ROLE_NAME value: {{ .Values.rbac.bootstrap.sharedMountRoleName | quote }} - name: SHARED_MOUNT_GROUPS diff --git a/values.yaml b/values.yaml index f7771a7..7488c7d 100644 --- a/values.yaml +++ b/values.yaml @@ -190,15 +190,20 @@ nebi: # goes under ``jupyterhub.custom.rbac-*`` — see below. rbac: bootstrap: - enabled: false - # Namespace to run the Job in. Default (empty) = chart release - # namespace. Override to the keycloak namespace so the Job can - # read the admin-credentials Secret without cross-namespace copy. - namespace: "" + # On by default — every Nebari deployment uses bitnami/keycloakx + + # nebari-operator NebariApps, so the chart can self-bootstrap the + # KC role + group mapper that the spawner needs. Set ``false`` for + # non-Nebari (local-dev, kind, BYO-Keycloak) deployments. + enabled: true + # Namespace the Job runs in. Defaults to ``keycloak`` so the Job + # can read the admin-credentials Secret without cross-namespace + # copy. Override for non-bitnami KC layouts. + namespace: "keycloak" # Name of a K8s Secret holding the KC realm-admin password in key # ``password``. Job authenticates as ``admin`` in the ``master`` - # realm via kcadm.sh and applies config to ``realmName``. - kcAdminCredentialSecret: "" + # realm via kcadm.sh and applies config to ``realmName``. Defaults + # to bitnami/keycloakx's secret name; override for other layouts. + kcAdminCredentialSecret: "keycloak-admin-credentials" # Key within ``kcAdminCredentialSecret`` that holds the password. # Default ``admin-password`` matches the bitnami keycloak/keycloakx # chart's ``keycloak-admin-credentials`` Secret layout. @@ -206,7 +211,10 @@ rbac: # Realm to bootstrap. realmName: "nebari" # ClientId of the hub OAuth client in Keycloak (created by the - # nebari-operator NebariApp). The role + SA roles attach here. + # nebari-operator NebariApp). Leave empty to derive from the + # release + chart name (the operator's standard pattern: + # ``jupyterhub--``). Override for non-standard + # client ids. hubClientId: "" # Name of the client role the Job creates on the hub OIDC client. # Must match ``KC_SHARED_MOUNT_ROLE`` env var on the hub Deployment From 02f96ebf5348d3212214c6690afe59fdc99f9915 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:40:56 +0100 Subject: [PATCH 3/9] feat(chart): add default profile_list (Small + Medium) under custom.profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the resource-sizing profile selector that was split out in 67a452e. Without a profile_list, users with a saved profile_slug from earlier chart versions hit: ValueError: No such profile: small-instance. Options include: Adds explicit slug fields ('small-instance', 'medium-instance') so the identifiers are stable across display_name changes and match the slugs users already have saved. Wires custom.profiles → c.KubeSpawner.profile_list in 01-spawner.py; an empty list disables the selector (single-instance mode). --- config/jupyterhub/01-spawner.py | 22 ++++++++++++++++++++++ values.yaml | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 0cdf91e..0a64c45 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -165,6 +165,28 @@ c.KubeSpawner.environment = env +# --------------------------------------------------------------------------- +# Profiles (resource sizing) +# --------------------------------------------------------------------------- +# Each profile maps directly to a KubeSpawner profile_list entry. The +# optional ``slug`` key gives a stable identifier independent of the +# human-facing ``display_name``; kubespawner falls back to slugifying +# ``display_name`` when ``slug`` is omitted. +# ``kubespawner_override`` accepts any valid KubeSpawner trait so deployers +# can add node_selector, image, extra_resource_limits (GPU), etc. without +# code changes. Empty list = no profile selector (single-instance mode). +_profiles = get_config("custom.profiles", []) +if _profiles: + c.KubeSpawner.profile_list = _profiles + log.info( + "profiles: loaded %d profile(s): %s", + len(_profiles), + [p.get("slug") or p.get("display_name") for p in _profiles], + ) +else: + log.info("profiles: none configured — single-instance mode") + + # --------------------------------------------------------------------------- # Keycloak token exchange helpers (synchronous, shared) # --------------------------------------------------------------------------- diff --git a/values.yaml b/values.yaml index 7488c7d..465eb62 100644 --- a/values.yaml +++ b/values.yaml @@ -252,6 +252,33 @@ jupyterhub: nebi-client-id: "" # Nebi's Keycloak OIDC client ID jupyterhub-client-id: "" # JupyterHub's Keycloak OIDC client ID nebi-environment-selector: false # Set true to show nebi workspaces in jhub-apps env dropdown + # --------------------------------------------------------------------------- + # Profiles — resource sizing for JupyterLab pods. + # Each entry maps directly to a KubeSpawner profile_list item. Slugs are + # derived from display_name via z2jh's slugify (e.g. "Small Instance" → + # "small-instance"). Set to [] to disable the profile selector + # (single-instance mode). + # kubespawner_override accepts any valid KubeSpawner trait (cpu_limit, + # mem_limit, node_selector, image, extra_resource_limits, etc.) so deployers + # can add GPU profiles, custom images, etc. without code changes. + profiles: + - slug: small-instance + display_name: "Small Instance" + description: "1 CPU / 2 GB RAM" + default: true + kubespawner_override: + cpu_limit: 1 + cpu_guarantee: 0.5 + mem_limit: "2G" + mem_guarantee: "1G" + - slug: medium-instance + display_name: "Medium Instance" + description: "4 CPU / 8 GB RAM" + kubespawner_override: + cpu_limit: 4 + cpu_guarantee: 2 + mem_limit: "8G" + mem_guarantee: "4G" # Terminal customization: controls Starship prompt in JupyterLab terminals. # When false, falls back to the default bash prompt. terminal-customization: true From 7b5580fb7b3f41923f3dd28d616123f73bf41613 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:42:42 +0100 Subject: [PATCH 4/9] fix(chart): gate rbac.bootstrap Job on nebariapp.enabled The post-install Job runs in the 'keycloak' namespace by default, which only exists when nebari-operator + keycloakx are deployed alongside (the same precondition as nebariapp). On bare-chart installs (kind, local-dev) the namespace is absent and the Job fails post-install with: failed post-install: ... namespaces "keycloak" not found Gate the template on nebariapp.enabled too, so deployers who turn nebariapp off implicitly opt out of RBAC bootstrap. --- templates/keycloak-rbac-bootstrap-job.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/templates/keycloak-rbac-bootstrap-job.yaml b/templates/keycloak-rbac-bootstrap-job.yaml index 815d010..ebcccec 100644 --- a/templates/keycloak-rbac-bootstrap-job.yaml +++ b/templates/keycloak-rbac-bootstrap-job.yaml @@ -16,11 +16,15 @@ where it cannot be tested, linted, or single-stepped. Bumping the Python image tag forces a checksum-driven Job re-roll on the next chart upgrade. -Gated on ``rbac.bootstrap.enabled`` AND the deployer providing a -Secret reference for the KC admin password — the chart deploys -fine without RBAC bootstrap pre-configured. +Gated on: + - ``rbac.bootstrap.enabled`` AND + - the deployer providing a KC admin password Secret reference AND + - ``nebariapp.enabled`` — no Nebari operator / KC realm means there + is nothing to bootstrap; lets kind / local-dev installs come up + cleanly without the post-install Job hitting a missing namespace. +The chart deploys fine without RBAC bootstrap pre-configured. */}} -{{- if and .Values.rbac.bootstrap.enabled .Values.rbac.bootstrap.kcAdminCredentialSecret }} +{{- if and .Values.nebariapp.enabled .Values.rbac.bootstrap.enabled .Values.rbac.bootstrap.kcAdminCredentialSecret }} {{/* Auto-derive hubClientId from release + chart name when the deployer hasn't set one explicitly. Matches the nebari-operator NebariApp From 33acfffb06bd09aec7c6af529352f8ec48d6ec07 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:45:31 +0100 Subject: [PATCH 5/9] feat(chart): more descriptive profile descriptions Replace bare spec strings ("1 CPU / 2 GB RAM") with workload-oriented hints so deployers see a use-case alongside the resource numbers. --- values.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/values.yaml b/values.yaml index 465eb62..b364456 100644 --- a/values.yaml +++ b/values.yaml @@ -264,7 +264,7 @@ jupyterhub: profiles: - slug: small-instance display_name: "Small Instance" - description: "1 CPU / 2 GB RAM" + description: "1 CPU / 2 GB RAM — interactive notebooks, light data exploration, teaching." default: true kubespawner_override: cpu_limit: 1 @@ -273,7 +273,7 @@ jupyterhub: mem_guarantee: "1G" - slug: medium-instance display_name: "Medium Instance" - description: "4 CPU / 8 GB RAM" + description: "4 CPU / 8 GB RAM — pandas / scikit-learn workloads on medium datasets." kubespawner_override: cpu_limit: 4 cpu_guarantee: 2 From 61e39a76d60728aaea653ca62f9620ef4c7d8343 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:48:51 +0100 Subject: [PATCH 6/9] feat(chart): add profile_options.image.choices to each profile Matches the classic Nebari profile-selector layout: each instance profile exposes an Image dropdown so users see (and can later choose between) the container image they're spawning into. Single default choice per profile for now, pointing at the chart's bundled singleuser image. Comment notes that bumping singleuser.image.tag also requires bumping the profile choices since z2jh values.yaml cannot reference other values. --- values.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/values.yaml b/values.yaml index b364456..780960c 100644 --- a/values.yaml +++ b/values.yaml @@ -261,6 +261,10 @@ jupyterhub: # kubespawner_override accepts any valid KubeSpawner trait (cpu_limit, # mem_limit, node_selector, image, extra_resource_limits, etc.) so deployers # can add GPU profiles, custom images, etc. without code changes. + # NOTE: when bumping singleuser.image.tag below, also bump the + # ``image: ...`` lines inside each profile_options.image.choices.default + # entry so the profile selector shows the right tag. (z2jh values.yaml + # cannot reference other values, so the duplication is unavoidable.) profiles: - slug: small-instance display_name: "Small Instance" @@ -271,6 +275,15 @@ jupyterhub: cpu_guarantee: 0.5 mem_limit: "2G" mem_guarantee: "1G" + profile_options: + image: + display_name: Image + choices: + default: + display_name: "nebari-data-science-pack-jupyterlab:sha-60c3d6f" + default: true + kubespawner_override: + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-60c3d6f - slug: medium-instance display_name: "Medium Instance" description: "4 CPU / 8 GB RAM — pandas / scikit-learn workloads on medium datasets." @@ -279,6 +292,15 @@ jupyterhub: cpu_guarantee: 2 mem_limit: "8G" mem_guarantee: "4G" + profile_options: + image: + display_name: Image + choices: + default: + display_name: "nebari-data-science-pack-jupyterlab:sha-60c3d6f" + default: true + kubespawner_override: + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-60c3d6f # Terminal customization: controls Starship prompt in JupyterLab terminals. # When false, falls back to the default bash prompt. terminal-customization: true From 25b81205e712f29e6c85f286eb93eb9e88c8b788 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:58:04 +0100 Subject: [PATCH 7/9] feat(chart): wire JUPYTERHUB_OIDC_CLIENT_SECRET + derive KC realm admin URL Two more deployer-side env vars that previously had to be added by hand are now bundled with the chart: - hub.extraEnv.JUPYTERHUB_OIDC_CLIENT_SECRET is now a default secretKeyRef against the operator-provisioned oidc-client Secret (same secret already mounted at /etc/oauth). Token exchange in 02-jhub-apps.py / 03-nebi-envs.py works out of the box. - KC_REALM_API_URL is derived in 00-gateway-auth.py from the issuer-url file by inserting /admin before /realms/. The KC realm admin API lives on the same host as the OIDC issuer for a standard KC layout, so deployers no longer need to compute and pin the admin URL separately. The explicit KC_REALM_API_URL env var still wins for non-standard deployments (KC admin behind a different gateway). Both are gated on the operator Secret being present (optional: true on the secretKeyRef, file-read inside the OAuth wiring block), so plain kind / local-dev installs still come up on dummy auth. --- config/jupyterhub/00-gateway-auth.py | 38 +++++++++++++++++------ tests/unit/test_keycloak_authenticator.py | 26 ++++++++++++++++ values.yaml | 14 +++++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/config/jupyterhub/00-gateway-auth.py b/config/jupyterhub/00-gateway-auth.py index 4c0df2a..8142450 100644 --- a/config/jupyterhub/00-gateway-auth.py +++ b/config/jupyterhub/00-gateway-auth.py @@ -446,6 +446,22 @@ def _read_secret_file(secret_dir: Path, key: str) -> str: return (secret_dir / key).read_text().strip() +def _derive_realm_api_url(issuer_url: str) -> str: + """Convert a KC realm issuer URL to its admin-API counterpart. + + Issuer URL: ``https://kc.example/realms/nebari`` + Admin API URL: ``https://kc.example/admin/realms/nebari`` + + Returns ``""`` when the URL doesn't match the standard KC layout — + callers fall back to the explicit ``KC_REALM_API_URL`` env var. + """ + marker = "/realms/" + idx = issuer_url.find(marker) + if idx == -1: + return "" + return f"{issuer_url[:idx]}/admin{issuer_url[idx:]}" + + # Chart-rendered constants. ``templates/hub-config.yaml`` substitutes the # ``__CHART_*__`` placeholders with values computed from ``nebariapp.hostname`` # at Helm render time, so deployers do not need to repeat the URLs in their @@ -488,22 +504,24 @@ def _resolve_oauth_urls() -> tuple[str, str] | None: _callback_url, _external_url = _urls _secret_dir = Path(os.environ.get("OAUTH_SECRET_DIR", "/etc/oauth")) # RBAC for role-gated /shared/ mounts. - # Read realm_api_url / role-name from env vars on the hub - # Deployment rather than via z2jh.get_config (which sources from - # the hub Secret's embedded values.yaml). The Secret is held - # stable by ArgoCD-side ignoreDifferences on rotating fields, and - # mutating non-rotating fields inside it via Helm-template - # +ArgoCD-SSA doesn't reliably propagate. Env-var-on-Deployment - # flows through standard SSA + checksum-driven hub rollout, so - # it actually reaches every cluster on chart upgrade. + # ``realm_api_url`` is normally derived from the same issuer URL + # we mount for the OIDC client (one host, two paths). Deployers + # with a non-standard layout (e.g. KC admin behind a different + # gateway) can still pin it via the ``KC_REALM_API_URL`` env var. + # Role-name is rarely overridden; env-var path kept for parity. + _issuer = _read_secret_file(_secret_dir, "issuer-url") + _realm_api_url = ( + os.environ.get("KC_REALM_API_URL") + or _derive_realm_api_url(_issuer) + ) configure( c, # noqa: F821 - issuer=_read_secret_file(_secret_dir, "issuer-url"), + issuer=_issuer, client_id=_read_secret_file(_secret_dir, "client-id"), client_secret=_read_secret_file(_secret_dir, "client-secret"), callback_url=_callback_url, external_url=_external_url, - realm_api_url=os.environ.get("KC_REALM_API_URL", ""), + realm_api_url=_realm_api_url, shared_mount_role_name=os.environ.get( "KC_SHARED_MOUNT_ROLE", "allow-group-directory-creation-role", diff --git a/tests/unit/test_keycloak_authenticator.py b/tests/unit/test_keycloak_authenticator.py index 253788d..aa7d74f 100644 --- a/tests/unit/test_keycloak_authenticator.py +++ b/tests/unit/test_keycloak_authenticator.py @@ -268,3 +268,29 @@ def test_resolve_falls_back_to_env_when_placeholders_intact(monkeypatch): "https://hub.example.test/hub/oauth_callback", "https://hub.example.test/", ) + + +# --------------------------------------------------------------------------- +# Cycle 6 — KC realm admin URL derived from issuer URL +# --------------------------------------------------------------------------- + +def test_derive_realm_api_url_inserts_admin_segment(): + mod = load_config_module("00-gateway-auth.py") + assert mod._derive_realm_api_url( + "https://kc.example.test/realms/nebari" + ) == "https://kc.example.test/admin/realms/nebari" + + +def test_derive_realm_api_url_handles_path_prefix(): + """Some KC deployments serve at a sub-path (e.g. behind a gateway).""" + mod = load_config_module("00-gateway-auth.py") + assert mod._derive_realm_api_url( + "https://gw.example.test/keycloak/realms/nebari" + ) == "https://gw.example.test/keycloak/admin/realms/nebari" + + +def test_derive_realm_api_url_returns_empty_for_non_kc_url(): + """Unknown URL shape returns empty — caller falls back to env var.""" + mod = load_config_module("00-gateway-auth.py") + assert mod._derive_realm_api_url("https://example.test/no/realms/here") != "" + assert mod._derive_realm_api_url("https://example.test/oidc") == "" diff --git a/values.yaml b/values.yaml index 780960c..1ec275d 100644 --- a/values.yaml +++ b/values.yaml @@ -410,6 +410,20 @@ jupyterhub: mountPath: /etc/oauth readOnly: true + # KC OIDC client secret. ``02-jhub-apps.py`` and ``03-nebi-envs.py`` + # read this env var to do KC token exchange for the Nebi service. + # ``00-gateway-auth.py`` reads the same secret from /etc/oauth (file) + # for the KeyCloakOAuthenticator wiring — both paths point at the + # same operator-provisioned Secret. + # The secretName follows the same convention as oauth-client above. + extraEnv: + JUPYTERHUB_OIDC_CLIENT_SECRET: + valueFrom: + secretKeyRef: + name: data-science-pack-nebari-data-science-pack-oidc-client + key: client-secret + optional: true + service: extraPorts: - port: 10202 From 0288b1c4e01d15df40d920bca51a6ca1aee18b26 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 15:15:17 +0100 Subject: [PATCH 8/9] feat(chart): rbac bootstrap reconciles group-mapper full.path + hub client URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-provisioned KC objects shipped with config that silently broke chart features; rbac bootstrap now reconciles them on every run. 1. ``groups`` client-scope ``group-membership`` mapper with ``full.path: "false"`` — token emits ``["admin"]`` (no leading slash) but the KC admin API role-groups endpoint returns ``["/admin"]``. The hub's ``filter_user_groups_by_role`` intersects the two and silently returned ``[]`` for every user, so ``/shared/`` never mounted. Bootstrap now detects the mismatch and PUTs the full desired config (was: skip when a mapper of that name existed, regardless of config). 2. Hub OIDC client with ``rootUrl``/``baseUrl: null`` and no ``initiate.login.uri``. KC-initiated SSO flows (account console "Sign in", third-party launchers) skip ``/hub/oauth_login`` and hit ``/hub/oauth_callback`` directly. JupyterHub has no ``oauthenticator-state`` cookie set for the flow and raises ``400 OAuth state mismatch``. Bootstrap now patches the client to route KC-initiated launches through ``/hub/oauth_login`` first. Wiring: - Adds ``rbac.bootstrap.hubExternalUrl`` value (defaults to ``https://{nebariapp.hostname}``). - ``HUB_EXTERNAL_URL`` env on the Job, ``BootstrapConfig.hub_external_url``, new ``ensure_hub_client_urls`` step that no-ops if the URL is empty. - Unit tests for both reconcile paths + the opt-out empty-URL case. Both root causes are operator-side defaults that need separate fixes upstream; until then the chart self-heals each install/upgrade. --- files/keycloak_rbac_bootstrap.py | 85 ++++++++++++++++++--- templates/keycloak-rbac-bootstrap-job.yaml | 5 ++ tests/unit/test_keycloak_rbac_bootstrap.py | 89 +++++++++++++++++++++- values.yaml | 10 +++ 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/files/keycloak_rbac_bootstrap.py b/files/keycloak_rbac_bootstrap.py index c89d754..52d06cb 100644 --- a/files/keycloak_rbac_bootstrap.py +++ b/files/keycloak_rbac_bootstrap.py @@ -72,6 +72,7 @@ class BootstrapConfig: hub_client_id: str role_name: str shared_mount_groups: tuple[str, ...] + hub_external_url: str @classmethod def from_env(cls, env: dict[str, str] | None = None) -> "BootstrapConfig": @@ -84,6 +85,7 @@ def from_env(cls, env: dict[str, str] | None = None) -> "BootstrapConfig": hub_client_id=env["HUB_CLIENT_ID"], role_name=env["ROLE_NAME"], shared_mount_groups=tuple(g for g in raw_groups.split(",") if g), + hub_external_url=env.get("HUB_EXTERNAL_URL", "").rstrip("/"), ) @@ -203,12 +205,40 @@ def ensure_groups_mapper(self, realm: str, scope_name: str = "groups") -> None: if scope_id is None: raise RuntimeError(f"client-scope {scope_name!r} creation failed") + desired_config = { + "full.path": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + } mappers = self._request( "GET", f"/{realm}/client-scopes/{scope_id}/protocol-mappers/models", ) or [] - if any(m["name"] == "group-membership" for m in mappers): - log.info("scope %r already has group-membership mapper", scope_name) + existing = next((m for m in mappers if m["name"] == "group-membership"), None) + if existing is not None: + # An operator-managed scope may pre-create the mapper with + # ``full.path: false`` — that yields ``groups: ["admin"]`` in + # the token, but the KC admin API returns role-groups as + # ``["/admin"]``, so the spawner's intersection comes up + # empty and ``/shared/`` never mounts. Reconcile the + # mapper config to the chart's desired state on every run. + current = existing.get("config") or {} + if all(current.get(k) == v for k, v in desired_config.items()): + log.info("scope %r group-membership mapper already in desired state", scope_name) + return + log.info( + "reconciling group-membership mapper config on scope %r (was %s)", + scope_name, {k: current.get(k) for k in desired_config}, + ) + merged = {**current, **desired_config} + self._request( + "PUT", + f"/{realm}/client-scopes/{scope_id}/protocol-mappers/models/{existing['id']}", + body={**existing, "config": merged}, + ) return log.info("adding oidc-group-membership-mapper to scope %r", scope_name) @@ -219,14 +249,7 @@ def ensure_groups_mapper(self, realm: str, scope_name: str = "groups") -> None: "name": "group-membership", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", - "config": { - "full.path": "true", - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", - }, + "config": desired_config, }, accept_409=True, ) @@ -253,6 +276,47 @@ def enable_service_accounts(self, realm: str, client_uuid: str) -> None: client["serviceAccountsEnabled"] = True self._request("PUT", f"/{realm}/clients/{client_uuid}", body=client) + def ensure_hub_client_urls( + self, + realm: str, + client_uuid: str, + hub_external_url: str, + ) -> None: + """Set ``rootUrl`` / ``baseUrl`` / ``initiate.login.uri`` on the hub + OIDC client so KC-initiated flows (account console "Sign in", + third-party launchers) route through ``/hub/oauth_login`` first, + which gives JupyterHub a chance to set its ``oauthenticator-state`` + cookie before the callback runs. Without these the + OAuth flow lands directly on ``/hub/oauth_callback`` with no + matching cookie and JupyterHub raises a 400 "OAuth state mismatch". + """ + if not hub_external_url: + log.info( + "hub client URL reconcile skipped: HUB_EXTERNAL_URL unset", + ) + return + client = self._request("GET", f"/{realm}/clients/{client_uuid}") + desired_root = hub_external_url + desired_base = "/hub" + desired_initiate = f"{hub_external_url}/hub/oauth_login" + attrs = dict(client.get("attributes") or {}) + if ( + client.get("rootUrl") == desired_root + and client.get("baseUrl") == desired_base + and attrs.get("initiate.login.uri") == desired_initiate + ): + log.info("hub client URLs already in desired state") + return + log.info( + "reconciling hub client URLs (rootUrl=%r, baseUrl=%r, initiate.login.uri=%r)", + desired_root, desired_base, desired_initiate, + ) + attrs["initiate.login.uri"] = desired_initiate + client["rootUrl"] = desired_root + client["baseUrl"] = desired_base + client["attributes"] = attrs + self._request("PUT", f"/{realm}/clients/{client_uuid}", body=client) + def get_service_account_user_id( self, realm: str, @@ -414,6 +478,7 @@ def run(config: BootstrapConfig, kc: KCAdmin) -> None: log.info("==> 2. hub OIDC client + service account") hub_uuid = kc.get_client_uuid(config.realm, config.hub_client_id) kc.enable_service_accounts(config.realm, hub_uuid) + kc.ensure_hub_client_urls(config.realm, hub_uuid, config.hub_external_url) sa_user_id = kc.get_service_account_user_id(config.realm, hub_uuid) log.info("==> 3. realm-management roles bound to hub SA") diff --git a/templates/keycloak-rbac-bootstrap-job.yaml b/templates/keycloak-rbac-bootstrap-job.yaml index ebcccec..f57d3fb 100644 --- a/templates/keycloak-rbac-bootstrap-job.yaml +++ b/templates/keycloak-rbac-bootstrap-job.yaml @@ -108,6 +108,11 @@ spec: value: {{ .Values.rbac.bootstrap.sharedMountRoleName | quote }} - name: SHARED_MOUNT_GROUPS value: {{ .Values.rbac.bootstrap.sharedMountGroups | join "," | quote }} + - name: HUB_EXTERNAL_URL + {{- /* Defaults to https://{nebariapp.hostname} when unset so the + bootstrap can patch rootUrl / baseUrl / initiate.login.uri on the + hub OIDC client. Empty value disables the patch. */}} + value: {{ .Values.rbac.bootstrap.hubExternalUrl | default (printf "https://%s" .Values.nebariapp.hostname) | quote }} - name: PYTHONUNBUFFERED value: "1" volumeMounts: diff --git a/tests/unit/test_keycloak_rbac_bootstrap.py b/tests/unit/test_keycloak_rbac_bootstrap.py index bd3e756..9333a22 100644 --- a/tests/unit/test_keycloak_rbac_bootstrap.py +++ b/tests/unit/test_keycloak_rbac_bootstrap.py @@ -84,6 +84,17 @@ def respond(method, path, *, body=None, accept_409=False): scope_id = path.split("/")[-3] state["mappers"].setdefault(scope_id, []).append(body) return None + # PUT //client-scopes//protocol-mappers/models/ + if method == "PUT" and "/protocol-mappers/models/" in path: + parts = path.split("/") + scope_id = parts[3] + mapper_id = parts[-1] + for m in state["mappers"].get(scope_id, []): + if m.get("id") == mapper_id: + m.clear() + m.update(body) + return None + raise AssertionError(f"mapper {mapper_id} not found on scope {scope_id}") # GET //clients?clientId= if method == "GET" and path.startswith(f"/{REALM}/clients?clientId="): cid = path.rsplit("=", 1)[1] @@ -220,7 +231,7 @@ def fresh_state(): } -def make_config(groups=("/admin",)): +def make_config(groups=("/admin",), hub_external_url="https://hub.example.test"): return rbac.BootstrapConfig( kc_host="http://kc.test", admin_password="p", @@ -228,6 +239,7 @@ def make_config(groups=("/admin",)): hub_client_id=HUB_CLIENT_ID, role_name=ROLE_NAME, shared_mount_groups=tuple(groups), + hub_external_url=hub_external_url, ) @@ -351,6 +363,81 @@ def respond(method, path, **kw): # Role with stale attributes is reconciled # --------------------------------------------------------------------------- +def test_existing_group_mapper_with_short_group_paths_is_reconciled(): + """A nebari-operator-managed ``groups`` scope ships the mapper with + ``full.path: false``. KC's admin API returns role-group paths with + a leading ``/`` so the spawner's intersection silently goes empty + and ``/shared/`` never mounts. Bootstrap must PUT the existing + mapper to flip ``full.path`` to ``true``.""" + state = fresh_state() + state["mappers"]["scope-groups"] = [{ + "id": "mapper-1", + "name": "group-membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "full.path": "false", + "claim.name": "groups", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + }, + }] + + def respond(method, path, **kw): + if method == "PUT" and path == f"/{REALM}/clients/{HUB_UUID}": + enable_sa_then_appear(state) + return _fake_realm(state)(method, path, **kw) + + kc = make_kc(respond) + rbac.run(make_config(), kc) + + mapper = state["mappers"]["scope-groups"][0] + assert mapper["config"]["full.path"] == "true" + assert mapper["config"]["claim.name"] == "groups" + + +def test_hub_client_urls_are_set_when_hub_external_url_given(): + """KC-initiated OAuth flows need ``rootUrl`` / ``baseUrl`` / + ``initiate.login.uri`` on the hub client so they route through + ``/hub/oauth_login`` first and set the JupyterHub state cookie. + Without these, JupyterHub raises ``400 OAuth state mismatch``.""" + state = fresh_state() + + def respond(method, path, **kw): + if method == "PUT" and path == f"/{REALM}/clients/{HUB_UUID}": + enable_sa_then_appear(state) + return _fake_realm(state)(method, path, **kw) + + kc = make_kc(respond) + rbac.run(make_config(hub_external_url="https://hub.example.test"), kc) + + client = next(c for c in state["clients"] if c["id"] == HUB_UUID) + assert client["rootUrl"] == "https://hub.example.test" + assert client["baseUrl"] == "/hub" + assert client["attributes"]["initiate.login.uri"] == ( + "https://hub.example.test/hub/oauth_login" + ) + + +def test_hub_client_urls_skipped_when_hub_external_url_empty(): + """Empty HUB_EXTERNAL_URL = deployer opted out; chart still bootstraps + the rest of the RBAC config but leaves rootUrl/baseUrl alone.""" + state = fresh_state() + + def respond(method, path, **kw): + if method == "PUT" and path == f"/{REALM}/clients/{HUB_UUID}": + enable_sa_then_appear(state) + return _fake_realm(state)(method, path, **kw) + + kc = make_kc(respond) + rbac.run(make_config(hub_external_url=""), kc) + + client = next(c for c in state["clients"] if c["id"] == HUB_UUID) + assert "rootUrl" not in client or client["rootUrl"] is None + assert "baseUrl" not in client or client["baseUrl"] is None + + def test_existing_role_with_wrong_attributes_is_reconciled(): """Deployer (or an older bootstrap) left the role around with the wrong attribute pair. The script must PUT to fix it, not leave the diff --git a/values.yaml b/values.yaml index 1ec275d..77590a9 100644 --- a/values.yaml +++ b/values.yaml @@ -225,6 +225,16 @@ rbac: # - /admin # - /developer sharedMountGroups: [] + # External hub URL (origin) used to set ``rootUrl`` / ``baseUrl`` / + # ``initiate.login.uri`` on the hub OIDC client. Without these, + # KC-initiated SSO flows (account console, third-party launchers) + # redirect straight to ``/hub/oauth_callback`` without first hitting + # ``/hub/oauth_login``, so JupyterHub has no ``oauthenticator-state`` + # cookie set and returns ``400 OAuth state mismatch``. + # Leave empty to default to ``https://{nebariapp.hostname}``; set to + # an explicit URL for non-standard layouts. Set to "false" string or + # remove via Helm override to skip the patch entirely. + hubExternalUrl: "" # Keycloak server URL the bootstrap script talks to (Admin REST API # lives at ``/admin/realms/...``). In-cluster service URL by # default; override if Keycloak sits at a different hostname. From 3e0d3be9469df8cffed47646a6fba4ad8d643767 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 15:21:24 +0100 Subject: [PATCH 9/9] test(integration): pass hub_external_url to BootstrapConfig Integration tests instantiate BootstrapConfig directly. Pass empty string for hub_external_url so the existing tests stay focused on the RBAC flow they cover; the new ensure_hub_client_urls path is unit- tested separately. --- tests/integration/test_keycloak_rbac_bootstrap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_keycloak_rbac_bootstrap.py b/tests/integration/test_keycloak_rbac_bootstrap.py index 7dbd03d..78dbab4 100644 --- a/tests/integration/test_keycloak_rbac_bootstrap.py +++ b/tests/integration/test_keycloak_rbac_bootstrap.py @@ -178,6 +178,7 @@ def _config_for(scratch: Scratch, realm: str, kc_url: str, hub_client_id=scratch.client_id, role_name=ROLE_NAME, shared_mount_groups=groups or (scratch.group_path,), + hub_external_url="", )