Commit 75d8a41
authored
feat: user home directories, shared group storage, and NSS wrapper (#30)
* feat: add shared group directories and NSS wrapper
- Add shared-storage RWX PVC volume support with per-group subPaths
mounted at /shared/<group> inside user pods
- Add init container that creates group directories with chmod 2775
(setgid bit) so new files inherit the group and are group-writable
- Add libnss_wrapper.so configuration so whoami/id report the real
username instead of 'jovyan', with NB_UMASK=0002
- Refactor pre_spawn_hook into focused single-responsibility functions:
_get_user_groups, _setup_shared_storage, _setup_nss_wrapper
- Orchestrator _pre_spawn_hook chains Nebi auth, shared storage, and
NSS wrapper; always registered (NSS runs even without shared storage)
- Add sharedStorage.groups allowlist and mountPathPrefix values
- Add jupyterhub.custom.shared-storage-* config keys
* fix: correct NSS GID to 1000 (jovyan) and always create ~/shared dir
- gid default was 100 but z2jh sets pod securityContext GID to 1000;
add jovyan:x:1000: group entry so 'groups' command resolves the name
- when shared PVC is disabled, mkdir -p ~/shared instead of removing it
so users always see the directory regardless of storage configuration
* fix: store groups in auth_state and always create ~/shared/<group> dirs
- EnvoyOIDCAuthenticator now stores parsed groups in auth_state so the
spawner can read them at spawn time (JupyterHub groups table is empty
when manage_groups is not enabled)
- refresh_user also re-parses groups from the refreshed IdToken to keep
auth_state current
- _pre_spawn_hook always resolves user groups, not only when shared PVC
is enabled
- _setup_nss_wrapper creates local ~/shared/<group> dirs per group when
no shared PVC is configured, so users always see their group dirs
* feat: add in-cluster NFS server for shared storage on RWO-only clusters
Deploys quay.io/nebari/volume-nfs backed by a single RWO PVC and re-exports
it as RWX NFS, enabling shared group directories on providers like Hetzner
that only provide ReadWriteOnce storage (hcloud-volumes).
- templates/nfs-server.yaml: NFS Deployment, Service, backend RWO PVC
- templates/shared-pvc.yaml: StorageClass + PV (NFS path) + PVC when
nfsServer.enabled; falls back to external RWX PVC otherwise
- values.yaml: sharedStorage.nfsServer.{enabled,storageClass,image} fields
* fix: add DaemonSet to install nfs-common on k3s worker nodes
k3s worker nodes on minimal OS images (Hetzner) ship without nfs-common,
causing NFS PV mounts to fail with 'bad option'. The DaemonSet uses nsenter
to install nfs-common on the host via apt-get, skipping if already present.
Gated on sharedStorage.nfsServer.installClient (default false).
* fix: use alpine:3 sleep for DaemonSet pause container
* fix: NFS PV path /exports not / (overlayfs cannot be exported)
* fix: remove spawner.user.groups (DetachedInstanceError in async); add try-except
_get_user_groups accessed spawner.user.groups (SQLAlchemy lazy-loaded
relationship) from an async pre_spawn_hook, causing DetachedInstanceError
which silently aborted _setup_shared_storage and _setup_nss_wrapper.
Groups are now read only from auth_state (stored by EnvoyOIDCAuthenticator).
Each step is individually wrapped in try-except so failures are logged and
don't prevent subsequent steps from running.
* fix: address code review findings (I1-I7, C1-C3, M1, N3)
I1: set c.KubeSpawner.fs_gid=100 explicitly so shared dir file ownership
is deterministic (GID 100 = users group) rather than relying on z2jh default
I2: add Helm validation in _helpers.tpl that fails at template time if
sharedStorage.enabled and jupyterhub.custom.shared-storage-enabled diverge
I3: use Path(g).name like classic Nebari so /projects/myproj -> myproj,
not projects/myproj; deduplicate groups to prevent duplicate mountPaths
I4: add nodeSelector/nodeAffinity support to NFS server Deployment so
deployers can pin it to worker nodes and avoid slow RWO PVC reattachment
I7: add argocd.argoproj.io/sync-options: Prune=false to StorageClass and
PersistentVolume to prevent accidental deletion during ArgoCD force sync
C1: add chown 0:100 before chmod 2775 in initialize-shared-mounts init
container so shared dirs are explicitly owned by GID 100 (users)
C2: use printf instead of echo '...' for NSS file writes to safely handle
special characters in usernames without shell quoting issues
C3: deduplicate groups in _get_user_groups (via Path.name already handles
most cases; added explicit dedup set for belt-and-suspenders)
M1: log exception with exc_info=True in refresh_user JWT parse failure
N3: merge into existing lifecycle_hooks instead of replacing; warn if
a postStart hook already exists before overwriting
Logging: added comprehensive info/debug/warning logging throughout all
pre-spawn hook functions for both happy and failure paths
* docs: add JupyterLab profiles design spec
* Revert "docs: add JupyterLab profiles design spec"
This reverts commit 56c22cf.
* feat: add JupyterLab profiles for CPU/RAM resource sizing (closes #31)
Exposes a profile selector in JupyterHub matching the classic Nebari experience.
Profiles are defined under jupyterhub.custom.profiles in values.yaml and passed
directly to c.KubeSpawner.profile_list via get_config().
Default profiles:
- Small: 1 CPU / 2 GB RAM (default)
- Medium: 4 CPU / 8 GB RAM
kubespawner_override accepts any KubeSpawner trait so GPU profiles, custom
images, and node selectors work without code changes in the future.
When profiles list is empty, no selector is shown (single-instance mode).
* fix: add descriptive names to default server profiles
Update default profile display_name and description to be more
user-friendly (e.g. "Small Instance" with "Stable environment with
1 CPU / 2 GB RAM" instead of just "Small" / "1 CPU / 2 GB RAM").
* split: move JupyterLab profiles to separate branch
Profiles feature (#31) is out of scope for this PR. Moved to local
branch feat/jupyterlab-profiles for a follow-up PR.
* test: add k3d-based e2e smoke test
Replaces the inline-bash test workflow with a pytest-based suite that
manages the k3d cluster, helm install, and pod-wait lifecycle.
Conftest exposes a 'cluster' session fixture and a 'hub_url' fixture
that port-forwards proxy-public.
CI runs uvx pytest tests/e2e -v with PYTHONUNBUFFERED=1 so live logs
stream into the workflow output.
Locally:
uvx pytest tests/e2e -v # fresh cluster
K3D_CLUSTER=k3d-nebari-dev uvx pytest tests/e2e -v # reuse
* test: add NFS-backed shared-storage e2e tests
Switches the e2e harness to kind (k3d's busybox-on-scratch nodes lack a
package manager, so the chart's nfs-common installer DaemonSet can't
provision NFS client tools).
New tests in tests/e2e/test_shared_storage.py exercise the full PR #30
spawn path against a real cluster:
- test_user_in_group_can_write: alice-data writes /shared/data/...
- test_shared_dir_is_group_owned: dir mode is 2775 (setgid)
The DummyAuth shim in tests/e2e/fixtures/test-values.yaml maps the
login username to user+groups (alice-data -> alice in [data]) so we
can fake auth_state without running Keycloak. Everything else
(spawner hook, init container, NSS wrapper, NFS mount) is real.
Chart changes:
- sharedStorage.nfsServer.mountOptions added (default []). Tests
pass [nfsvers=3] because kind nodes use overlayfs which fails
the volume-nfs image's NFSv4 root export. Production unchanged.
Conftest infrastructure:
- kind cluster fixture with KIND_KEEP=1 reuse
- hosts-entry workaround so kubelet's host mount.nfs can resolve
the cluster-internal NFS service FQDN (kind nodes have no
cluster DNS in their host resolv.conf)
- structured logging + step counter + per-cycle pod state, events
from kubectl describe, and node-level kubelet journal lines
- autouse failure-dump fixture (kubectl get pods/events + hub
and singleuser logs)
* refactor(tests/e2e): split conftest into deep modules
Conftest had grown to 577 lines mixing five concerns. Extracted into
focused modules each with a small interface and large hidden impl:
_process.py subprocess + kubectl helpers + step counter
_hub.py HubClient (cookie/login/spawn/stop session)
_pod_observer.py wait_for_pod_ready + dedup'd pod-state polling
_cluster.py kind lifecycle + helm install + NFS hosts workaround
conftest.py shrinks to 218 lines holding only fixtures that compose the
modules. Eliminates duplication of:
- cookie-jar login flow (was repeated in _login_and_spawn + _stop_server)
- two parallel subprocess wrappers (_run + _kctl)
- inline pod-state polling loop in the spawn flow
Tests still pass locally (3 passed in 35s, cluster reused).
* ci: speed up e2e — disable z2jh prePuller + cache kindest/node
prePuller hooks pre-pull singleuser images on every node before helm
install completes. On a single-node test cluster this is pure overhead
(~30s of blocking wait). Disable in test-values.yaml.
kindest/node image pull was the largest variable cost in CI: 9s on a
fast runner, 130s on a slow one. Cache it as a docker tarball keyed on
the kind version so subsequent runs are deterministic and fast.
Expected: total CI time drops from variable 3-5min to ~90-120s.
* ci: fix kindest/node cache-save step under set -e
Previous heuristic used `[ -n "$img" ] && docker save` which exits 1
when grep finds no image, killing the whole step. Hardcode the
v1.32.2 tag (fixed by kind v0.27.0) and use plain commands so set -e
only triggers on real failures.
* ci: drop kindest/node cache attempt
GH Actions ubuntu-latest runners come with kindest/node preinstalled
(tagged "<none>"). The actual image fetch on cache miss was only ~12s
because docker just verifies the digest. The cache step was earning ~5s
in the best case and breaking the workflow when docker save couldn't
find the v1.32.2 tag (image is referenced by digest, not tag).
prePuller-disable change is keeping its ~30s saving — sufficient win
without the cache complexity.
* test(e2e): expand shared-storage suite to full permission contract
9 tests (was 2) covering the per-group /shared/<group> contract end-to-end:
- dir is root:users 2775 (parametrized over groups)
- pod is member of users group; NB_UMASK=0002 in env
- new files inherit gid=100 mode 0664; new subdirs gid=100 mode 02775
(setgid propagation)
- multi-group user sees + writes every group dir
- user does not see groups they don't belong to (mount-time isolation)
- file written by one user is readable + appendable by a groupmate
from a separate pod (cross-user collaboration)
Conftest adds PathStat + SpawnedUser.stat()/path_exists() so tests assert
against typed fields (mode/uid/gid) instead of parsing stat strings —
keeps tests short and behavior-focused.
* ci: cache singleuser image across runs to skip ~73s cold pull
Singleuser image (multi-GB) is currently pulled by kubelet inside the
kind node on first user spawn, costing ~73s of every CI run. Pull it
once on the runner host, save as tar, cache it (key = image ref so a
values.yaml bump auto-invalidates), and side-load with `kind load
image-archive`. Pre-create the cluster in the workflow so the side-load
happens before any pod is scheduled — the pytest fixture's
ensure_cluster() reuses the existing cluster.
Cache hit: skips the ~90s registry pull entirely; only kind-load (~20s)
remains. Cache miss: pull + save once (~120s), then every subsequent run
benefits.
* docs(shared-storage): position external RWX as primary, mark in-cluster NFS as transitional
Addresses comment on issue #29: the bundled `nfsServer.enabled=true` path
relies on `quay.io/nebari/volume-nfs:0.8-repack`, a manifest-schema repack
of an abandoned upstream image (nebari-dev/nebari-docker-images#230). We
should not be carrying that workaround image as the recommended path for
a greenfield chart.
The chart already supported bringing your own RWX StorageClass; this
change makes that path the documented primary:
- values.yaml: reframe the sharedStorage block. Recommend an external
RWX class with provider-specific examples (Longhorn, EFS, Filestore,
Azure Files, nfs-subdir-external-provisioner). Add a deprecation
note on the nfsServer.image block linking to issue #29.
- README: add a "Shared Storage" section with the same matrix and an
explicit pointer to the issue tracking removal of the in-cluster
NFS path.
No template changes — the external-RWX path was already rendered when
nfsServer.enabled=false. Verified via `helm template` that setting only
`sharedStorage.storageClass=longhorn` produces a single RWX PVC and no
nfs-server pod.
* docs(shared-storage): correct provider list — only longhorn is NIC-provisioned
Previous commit listed EFS/Filestore/Azure Files as recommended RWX
backends. NIC does not provision those — they are separate cloud-managed
services no one in NIC has wired up. NIC's actual storage reality:
hetzner : longhorn (longhorn.Install in pkg/provider/hetzner)
aws : longhorn (longhorn.Install in pkg/provider/aws)
existing : longhorn (longhorn.Install in pkg/provider/existing)
gcp : standard-rwo (no RWX provisioned)
azure : managed-csi (no RWX provisioned)
local : (no storage layer)
So the accurate recommendation is just longhorn. Updated values.yaml and
README to say so directly. The in-cluster NFS fallback stays — it covers
the providers where NIC has not yet wired up an RWX class — with a
pointer to issue #29 for tracking removal once that lands everywhere.
---------
Co-authored-by: Amit Kumar <aktech@users.noreply.github.com>1 parent 8f2e545 commit 75d8a41
20 files changed
Lines changed: 1668 additions & 50 deletions
File tree
- .github/workflows
- config/jupyterhub
- templates
- tests/e2e
- fixtures
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
| 10 | + | |
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
| 18 | + | |
25 | 19 | | |
26 | | - | |
| 20 | + | |
| 21 | + | |
27 | 22 | | |
28 | | - | |
29 | | - | |
| 23 | + | |
| 24 | + | |
30 | 25 | | |
31 | | - | |
32 | | - | |
| 26 | + | |
| 27 | + | |
33 | 28 | | |
34 | | - | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
35 | 36 | | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
52 | 40 | | |
53 | | - | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
54 | 50 | | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
66 | 69 | | |
67 | | - | |
| 70 | + | |
68 | 71 | | |
69 | | - | |
| 72 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
61 | 81 | | |
62 | 82 | | |
63 | 83 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
93 | 93 | | |
94 | 94 | | |
95 | 95 | | |
96 | | - | |
| 96 | + | |
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
101 | 107 | | |
102 | 108 | | |
103 | 109 | | |
104 | 110 | | |
105 | 111 | | |
106 | 112 | | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
107 | 117 | | |
108 | 118 | | |
109 | 119 | | |
| |||
114 | 124 | | |
115 | 125 | | |
116 | 126 | | |
| 127 | + | |
117 | 128 | | |
118 | 129 | | |
119 | 130 | | |
| |||
130 | 141 | | |
131 | 142 | | |
132 | 143 | | |
| 144 | + | |
133 | 145 | | |
134 | 146 | | |
135 | 147 | | |
| |||
157 | 169 | | |
158 | 170 | | |
159 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
160 | 188 | | |
161 | 189 | | |
162 | 190 | | |
163 | 191 | | |
164 | 192 | | |
| 193 | + | |
165 | 194 | | |
166 | 195 | | |
167 | 196 | | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
168 | 201 | | |
169 | 202 | | |
170 | 203 | | |
| |||
0 commit comments