Skip to content
Draft
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
12 changes: 6 additions & 6 deletions config/jupyterhub/01-spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,12 @@ def _setup_trust_bundle(spawner):

env["NEBI_STORAGE_WORKSPACES_DIR"] = "/var/lib/nebi/workspaces"

# Tell jhub-app-proxy to activate an app's environment with pixi (it looks
# the workspace up via `nebi workspace list`) instead of conda. Set for
# every pod rather than only when a remote Nebi server is configured, so
# apps can also run in the user's local Nebi workspaces.
env["JHUB_APP_ENV_MANAGER"] = "pixi"

# 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
Expand Down Expand Up @@ -845,12 +851,6 @@ async def _nebi_pre_spawn_hook(spawner):
NEBI_AUTH_TOKEN into the pod environment.
Non-fatal: if any step fails, the pod still spawns without auto-auth.
"""
# Tell jhub-app-proxy to use pixi activation (instead of conda) for app pods.
# Set this first, before any early returns, because pixi activation is needed
# regardless of whether the token exchange succeeds — the nebi binary in the
# pod handles workspace pull and pixi env activation independently.
spawner.environment = {**spawner.environment, "JHUB_APP_ENV_MANAGER": "pixi"}

auth_state = await spawner.user.get_auth_state()
if not auth_state:
log.warning("No auth_state for %s, skipping Nebi auto-auth", spawner.user.name)
Expand Down
87 changes: 85 additions & 2 deletions config/jupyterhub/03-nebi-envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,87 @@ def get_nebi_environments(user):
return []


# ---------------------------------------------------------------------------
# Local workspace listing (no remote Nebi server)
# ---------------------------------------------------------------------------

# In-cluster address of the hub's proxy. The service name is hardcoded
# because JupyterHub starts jhub-apps with a scrubbed environment, so the
# PROXY_PUBLIC_SERVICE_* variables Kubernetes injects are not visible here.
_PROXY_URL = "http://proxy-public"


def get_local_nebi_environments(user):
"""Return the names of the user's installed local Nebi workspaces.

Used when no remote Nebi server is deployed. The workspaces live on the
user's own volumes, which the hub cannot read, so ask the Nebi instance
inside the user's running JupyterLab (jupyter-server-proxy serves it at
/user/<name>/nebi/). App pods mount the same volumes, so jhub-app-proxy
resolves the selected name there with `nebi workspace list` at launch.

Returns an empty list when JupyterLab is not running or on any failure.
"""
username = user.get("name", "<unknown>")
lab = (user.get("servers") or {}).get("") or {}
if not lab.get("ready"):
log.info(
"nebi-envs: JupyterLab is not running for %s, no local environments to list",
username,
)
return []

api_url = os.environ.get("JUPYTERHUB_API_URL", "")
api_token = os.environ.get("JUPYTERHUB_API_TOKEN", "")
try:
# Short-lived token acting as the user, to get past their
# JupyterLab server's hub authentication.
req = Request(
f"{api_url}/users/{username}/tokens",
data=json.dumps({"expires_in": 60}).encode(),
headers={
"Authorization": f"token {api_token}",
"Content-Type": "application/json",
},
method="POST",
)
with urlopen(req, timeout=10) as resp:
user_token = json.loads(resp.read())["token"]

# The first request may have to start `nebi serve`, which
# jupyter-server-proxy waits up to 20s for.
req = Request(
f"{_PROXY_URL}{lab['url']}nebi/api/v1/workspaces",
headers={"Authorization": f"token {user_token}"},
)
with urlopen(req, timeout=30) as resp:
workspaces = json.loads(resp.read())
except Exception:
log.exception("nebi-envs: failed to list local environments for %s", username)
return []

if not isinstance(workspaces, list):
log.error(
"nebi-envs: unexpected local workspaces response type %s for %s (expected list)",
type(workspaces).__name__, username,
)
return []

# Only installed workspaces: an uninstalled one would be installed by
# `pixi run` at app launch and overrun the app's ready timeout.
envs = [
ws["name"] for ws in workspaces
if ws.get("status") == "ready"
and ws.get("install_status") == "installed"
and ws.get("name")
]
log.info(
"nebi-envs: listed %d installed local environments for %s (total workspaces: %d)",
len(envs), username, len(workspaces),
)
return envs


# ---------------------------------------------------------------------------
# Register the callable with jhub-apps
# ---------------------------------------------------------------------------
Expand All @@ -177,6 +258,8 @@ def get_nebi_environments(user):
c.JAppsConfig.conda_envs = get_nebi_environments
log.info("nebi-envs: environment selector enabled (nebi_url=%s)", _nebi_internal_url)
else:
log.warning(
"nebi-envs: nebi-internal-url is not set, environment selector will not be enabled"
c.JAppsConfig.conda_envs = get_local_nebi_environments
log.info(
"nebi-envs: no remote Nebi server configured, "
"environment selector lists local workspaces"
)
15 changes: 15 additions & 0 deletions docs/src/content/docs/nebi-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@ jhub-apps offers a Nebi environment when deploying an app. Two things gate it:
activate conda environments and silently fall back to the base environment, so an app
launched into a Nebi environment comes up missing its packages.

### Without a Nebi server

When nebi-pack is not deployed, turn the server integration off:

```yaml
nebi:
remote:
enabled: false
```

The environment selector then lists the user's own local workspaces, the ones built in the
Nebi tile inside JupyterLab, and the app runs straight from that workspace. Only installed
workspaces are listed, and the user's JupyterLab server must be running for the list to
load. The token exchange and the Nebi card are switched off.

## Behind a TLS-inspecting proxy

The init container that pre-pulls Nebi environments makes its own outbound HTTPS calls
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/values-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ Admin guide: [Nebi integration](/nebi-integration/).
| `nebi.image.repository` | `quay.io/nebari/nebi` | Binary copied into user pods by an init container. |
| `nebi.image.tag` | `sha-5ca877a` | Pinned per chart release. Must be ≥ v0.15 for `jupyterlab-launchpad` (≥ 1.1.1) to receive `nebi:job-completed`; older nebi never sends it and kernel refresh silently falls back to the ~61s poll. Empty disables the init container. |
| `nebi.image.pullPolicy` | `IfNotPresent` | — |
| `nebi.remote.enabled` | `true` | Set `false` when nebi-pack is not deployed. Turns off the token exchange and the Nebi card; the jhub-apps environment selector then lists the user's local Nebi workspaces. |
| `nebi.remoteURL` | `""` *(derived)* | Browser-facing Nebi URL, used for the OIDC redirect. |
| `nebi.internalURL` | `""` *(derived)* | In-cluster URL for the token-exchange path. |
| `nebi.namespace` | `nebi` | Where nebi-pack runs; drives the NetworkPolicy and derived URL. |
Expand Down
7 changes: 6 additions & 1 deletion templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,10 @@ Empty when neither is available.
External Nebi URL. Order of precedence:
1. .Values.nebi.remoteURL (explicit)
2. https://<subdomains.nebi>.<baseDomain(keycloak.hostname)>
Empty when neither is available.
Empty when neither is available, or when nebi.remote.enabled is false.
*/}}
{{- define "nebari-data-science-pack.nebiRemoteURL" -}}
{{- if .Values.nebi.remote.enabled -}}
{{- if .Values.nebi.remoteURL -}}
{{- .Values.nebi.remoteURL -}}
{{- else -}}
Expand All @@ -144,19 +145,23 @@ Empty when neither is available.
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{/*
In-cluster Nebi URL. Order of precedence:
1. .Values.nebi.internalURL (explicit)
2. http://nebi-pack-nebari-nebi-pack.<nebi.namespace>.svc.cluster.local
Empty when nebi.remote.enabled is false.
*/}}
{{- define "nebari-data-science-pack.nebiInternalURL" -}}
{{- if .Values.nebi.remote.enabled -}}
{{- if .Values.nebi.internalURL -}}
{{- .Values.nebi.internalURL -}}
{{- else -}}
{{- printf "http://nebi-pack-nebari-nebi-pack.%s.svc.cluster.local" (.Values.nebi.namespace | default "nebi") -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{/*
Nebi image reference (repository:tag). Empty when nebi.image.tag is not
Expand Down
8 changes: 8 additions & 0 deletions values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ nebi:
# to the ~61s KernelSpecManager poll — no error is logged anywhere.
tag: "sha-5ca877a"
pullPolicy: IfNotPresent
# Set enabled: false when no Nebi server (nebari-nebi-pack) is deployed
# alongside this pack. remoteURL and internalURL then resolve to empty,
# which turns off the hub <-> Nebi token exchange and the Nebi launcher
# card, and the jhub-apps environment selector lists the user's own
# local Nebi workspaces (built in the JupyterLab Nebi tile) instead of
# the server's. The user's JupyterLab must be running to list them.
remote:
enabled: true
# External Nebi FQDN (browser-side, used for OIDC redirect).
# If empty, derived from keycloak.hostname as
# `https://<subdomains.nebi>.<base-domain>`.
Expand Down
Loading