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
80 changes: 78 additions & 2 deletions config/jupyterhub/01-spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,12 @@ def _setup_trust_bundle(spawner):
# ``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).
# Keys used only for group gating; KubeSpawner must never see them.
# Keys used only for group gating; KubeSpawner must never see them. They are
# stripped per user in _filter_profiles because gating is a per-user decision.
# ``image-variant`` is the other chart-only key — it is stripped once at load
# in _resolve_image_variants because its effect (image injection) is the same
# for every user and jhub-apps reads the resolved list too. A future custom key
# belongs in whichever of the two matches how it is evaluated.
_PROFILE_GATING_KEYS = ("access", "groups", "users")


Expand Down Expand Up @@ -442,7 +447,78 @@ async def _render_profile_list(spawner):
return visible


_profiles = get_config("custom.profiles", [])
def _resolve_image_variants(profiles, base_name, base_tag, overrides):
"""Inject a variant of the singleuser image into ``image-variant`` profiles.

Every jupyterlab image variant (today only ``gpu``) is built from the same
commit as the CPU image and shares its ``sha-<short>`` tag, so a profile
marked ``image-variant: gpu`` resolves to
``<singleuser.image.name>-gpu:<singleuser.image.tag>`` and stays current
across pack updates without a hardcoded SHA in the overlay (issue #230).

Order of precedence for the injected image:
1. the profile's own ``kubespawner_override.image`` — never touched
2. ``custom.image-variants.<variant>`` — deployer override, full ref
(mirrored registries, a variant published elsewhere)
3. ``<base_name>-<variant>:<base_tag>`` — derived
4. nothing — ``base_name``/``base_tag`` empty (schema-valid in z2jh);
the profile falls back to the CPU default image and the hub warns.

The ``image-variant`` key is stripped whatever its value — KubeSpawner
must never see it. Returns new dicts; the input list is left untouched.

Two cases only warn, because raising here would break hub startup (and
therefore login) for every user: the empty-ref fallback above, and a
profile that also declares ``profile_options.image`` — KubeSpawner applies
the selected choice's ``kubespawner_override`` AFTER the profile-level one
and replaces rather than merges, so the choice's image silently wins over
the injected one at spawn time (while jhub-apps still displays the
injected one).
"""
resolved = []
for profile in profiles:
if "image-variant" not in profile:
resolved.append(profile)
continue
name = profile.get("slug") or profile.get("display_name")
variant = profile["image-variant"]
profile = {k: v for k, v in profile.items() if k != "image-variant"}
if not variant:
resolved.append(profile)
continue
override = dict(profile.get("kubespawner_override") or {})
if not override.get("image"):
image = (overrides or {}).get(variant)
if not image and base_name and base_tag:
image = f"{base_name}-{variant}:{base_tag}"
if image:
override["image"] = image
log.info("profiles: %r image-variant %r — injected image %s", name, variant, image)
else:
log.warning(
"profiles: %r has image-variant %r but no image could be derived "
"(singleuser.image.name/tag empty?) and custom.image-variants.%s is "
"unset — the profile will spawn the CPU default image",
name, variant, variant,
)
if "image" in (profile.get("profile_options") or {}):
log.warning(
"profiles: %r has image-variant %r but declares profile_options.image; "
"the selected choice's image overrides the injected one at spawn time — "
"drop the option or point its choices at the variant image",
name, variant,
)
profile["kubespawner_override"] = override
resolved.append(profile)
return resolved


_profiles = _resolve_image_variants(
get_config("custom.profiles", []),
get_config("singleuser.image.name", ""),
get_config("singleuser.image.tag", ""),
get_config("custom.image-variants", {}) or {},
)
if _profiles:
c.KubeSpawner.profile_list = _render_profile_list
log.info(
Expand Down
66 changes: 66 additions & 0 deletions docs/src/content/docs/server-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,72 @@ kubectl -n data-science logs deploy/hub | grep -i "profiles:\|groups"

## GPU profiles

### The GPU image, without hardcoding a SHA

Mark a profile `image-variant: gpu` and the chart injects the matching GPU JupyterLab image
(`nebari-data-science-pack-jupyterlab-gpu`) at the chart's current tag:

```yaml
- slug: gpu-instance
display_name: "G4 GPU Instance"
image-variant: gpu
access: yaml
groups:
- gpu-access
kubespawner_override:
# no image needed — the -gpu image is injected automatically
node_selector:
node.kubernetes.io/instance-type: g4dn.xlarge
extra_resource_limits:
nvidia.com/gpu: 1
```

Both JupyterLab images are built from the same commit and share the same `sha-` tag, so
the derived ref (`<singleuser.image.name>-gpu:<singleuser.image.tag>`) exists on
`quay.io/nebari` for every release and GPU profiles track pack updates exactly like CPU
profiles — no more stale SHAs in the overlay
([issue #230](https://github.com/nebari-dev/data-science-pack/issues/230)).

The derivation is generic — `image-variant: <name>` resolves to
`<singleuser.image.name>-<name>:<singleuser.image.tag>` — but `gpu` is the only variant
published today. Like the gating keys, `image-variant` is stripped (whatever its value) before
the profile reaches KubeSpawner. The hub logs the injected ref at startup:

```bash
kubectl -n data-science logs deploy/hub | grep "profiles:.*gpu"
```

:::caution[Mirrored registries]
The derivation only rewrites the image *name*. If you point `jupyterhub.singleuser.image.name`
at a mirror (ECR, an airgapped registry), the chart derives `<mirror>-gpu:<tag>`, which does
not exist unless you mirrored it too. Nothing validates the ref at `helm upgrade` time — the
first GPU spawn fails with `ImagePullBackOff`. Either mirror the `-gpu` image under that name
or map it under `jupyterhub.custom.image-variants`:

```yaml
image-variants:
gpu: 123456789012.dkr.ecr.us-east-1.amazonaws.com/lab-gpu:sha-abc1234
```

The `-gpu` image is built for `linux/amd64` only; the CPU image is multi-arch.
:::

Two things override the injection:

- **An explicit `kubespawner_override.image`** always wins. Note that
`scripts/bump_image_tags.py` only rewrites the CPU image ref, so a hand-pinned `-gpu`
image stays frozen across releases — the exact problem `image-variant` exists to solve. Prefer
the key over pinning.
- **`profile_options.image`.** KubeSpawner applies the selected choice's
`kubespawner_override` *after* the profile-level one and replaces rather than merges, so
an image choice silently puts the CPU image on the GPU node (while jhub-apps' Create App
still displays the injected GPU image). The shipped CPU profiles carry such an option —
do not copy it onto an `image-variant` profile. The hub logs a warning if you do.

`jupyterhub.custom.image-variants.<name>` changes which image gets injected for a variant chart-wide.

### Scheduling onto GPU nodes

A GPU profile requests the resource through `extra_resource_limits`, but scheduling onto a
tainted GPU node group also needs a toleration — whether you must add it yourself depends on
whether the cluster runs the `ExtendedResourceToleration` admission controller (EKS and GKE
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 @@ -190,6 +190,7 @@ always win.
| `external-url` | `""` *(derived)* | Hub bind hostname. |
| `nebi-image` | `""` *(derived)* | `repository:tag` copied into user pods. |
| `nebi-image-pull-policy` | `IfNotPresent` | — |
| `image-variants` | `{}` | Per-variant image overrides for `image-variant: <name>` profiles. Default derivation is `<singleuser.image.name>-<name>:<tag>` — see [Server profiles](/server-profiles/#gpu-profiles). |
| `jhub-app-proxy-version` | `v0.2.3` | Installed at app-spawn time. Must be ≥ v0.2.3 for apps to run inside a Nebi (pixi) environment; older versions only activate conda and fall back to the base env. |
| `nebi-remote-url` | `""` *(derived)* | Browser-facing Nebi URL. |
| `nebi-internal-url` | `""` *(derived)* | In-cluster Nebi URL. |
Expand Down
116 changes: 116 additions & 0 deletions docs/superpowers/specs/2026-08-24-gpu-profile-image-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# GPU profile image auto-derivation

Issue: https://github.com/nebari-dev/data-science-pack/issues/230

## Problem

Deployers who add GPU JupyterLab profiles must hardcode
`quay.io/nebari/nebari-data-science-pack-jupyterlab-gpu:sha-<short>` in their
overlay. CPU profiles inherit the chart's image tag on every pack update; GPU
profiles silently fall behind until someone remembers to bump the SHA.

Both `nebari-data-science-pack-jupyterlab` and
`nebari-data-science-pack-jupyterlab-gpu` are built by the same
`build-images.yaml` workflow from the same commit, so they always share the
same `sha-<short>` tag. The chart therefore already knows the correct GPU
image ref: `<singleuser.image.name>-gpu:<singleuser.image.tag>`.

## Design

A new per-profile key `image-variant: <name>` in `jupyterhub.custom.profiles`:

```yaml
- slug: gpu
display_name: "GPU Access"
image-variant: gpu
access: yaml
groups: [gpu-access]
kubespawner_override:
# no image needed — injected automatically
node_selector: {node.kubernetes.io/instance-type: g4dn.xlarge}
extra_resource_limits: {nvidia.com/gpu: 1}
```

### Components

1. **Spawner config** (`config/jupyterhub/01-spawner.py`): at load,
`_resolve_image_variants(profiles, base_name, base_tag, overrides)`
walks `custom.profiles`, reading `singleuser.image.name`/`.tag` and
`custom.image-variants` from z2jh. For each `image-variant: <name>`:
* no `kubespawner_override.image` → inject, in order of precedence,
`custom.image-variants.<name>` if set, else
`<base_name>-<name>:<base_tag>`; if neither can be produced
(`singleuser.image.name`/`tag` empty, schema-valid in z2jh) warn and
fall back to the CPU default image
* explicit image → leave it alone (explicit wins)
* the key is always stripped so KubeSpawner never sees it
* `profile_options.image` present → warn (see Rejected alternatives)

Resolution happens once at module load, before `_render_profile_list`
filtering, so jhub-apps' server-types endpoint sees the same image.

2. **Override map** `jupyterhub.custom.image-variants: {}` in `values.yaml`
— full refs per variant, for mirrored registries or a variant published
elsewhere. Read directly via z2jh `get_config` (it is a mapping, so the
`get_chart_config` empty-string convention does not apply).

No Helm-side change: the derivation needs the variant name, which only the
profile knows, so it lives in Python where both inputs are available.

### Why `image-variant: <name>` rather than a boolean `gpu: true`

* The image naming already has a variant axis (`-gpu`), and a `-rocm` or
arm64 runtime image is plausible. A boolean would have to coexist with a
string key forever once it shipped in `values.yaml`.
* Same amount of code today; the derivation string is the only place the
variant name appears.
* Cost: `image-variant: gpu` reads slightly less naturally than `gpu: true`
in an overlay. Mitigated by the example in `values.yaml` and the docs.

### Rejected alternatives

* **Boolean `gpu: true`.** The first draft of this PR. Rejected in review
for the reason above: it is an API surface we cannot drop without a
deprecation cycle, and the string key costs nothing extra.
* **Deriving in Helm (`_CHART_DERIVED["gpu-image"]`).** Also the first
draft. Works for one hardcoded variant but cannot be generic — Helm does
not see the profile list the z2jh subchart consumes, so it cannot know
which variant names are in use.
* **Injecting into `profile_options.image.choices` too.** Choices always
carry an explicit image (that is their purpose), so injecting there
would overwrite explicit deployer values and contradict "explicit wins".
Instead the hub warns when a variant profile declares
`profile_options.image`, and the docs say not to combine them.
* **Raising on an empty derived image.** Would break hub startup, and
therefore login, for every user. A warning plus fallback to the CPU
default image is the right level.

### Automatic tag currency

`scripts/bump_image_tags.py` already bumps `jupyterhub.singleuser.image.tag`
every release; the derived GPU ref follows with zero script changes.

### Out of scope (YAGNI)

* Auto-injecting `extra_resource_limits` / tolerations — cluster-specific,
already documented.
* Rewriting `profile_options.image.choices` for GPU profiles — explicit
choices keep winning at spawn time; the hub warns when a variant profile
declares that option (see Rejected alternatives).
* Teaching `scripts/bump_image_tags.py` to bump explicitly pinned `-gpu`
refs, and recording the build invariant (both jupyterlab images from one
`build-images.yaml` run) — follow-ups.

## Testing

* Unit (`tests/unit/test_spawner_profiles.py`): derivation, generic
variant names, override map, explicit-image precedence, key stripping
(including empty variant), empty-base fallback + warning,
`profile_options.image` warning (+ negative), input non-mutation,
load-time wiring from z2jh keys, load-time log naming the injected ref.

## Docs

* `docs/src/content/docs/server-profiles.md` GPU section: document `image-variant`.
* `docs/src/content/docs/values-reference.md`: `image-variants` row.
* `values.yaml` comments for `image-variants` + profile example.
1 change: 1 addition & 0 deletions tests/unit/test_chart_derived.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ def test_get_chart_config_explicit_override_wins(rendered_chart_derived):
)
got = ns["get_chart_config"]("external-url")
assert got == "explicit.example.com"

Loading
Loading