fix(jupyterhub): make the chart configurable — unlock schema, expose 9 values, stop rotating hub secrets and rerunning the pre-pull hook on upgrade - #299
Conversation
…ured
Every scalar in charts/jupyterhub/values.schema.json was generated as
`enum: [<current value>]` — 49 of 49 leaves. A single-value enum makes that
value the ONLY legal one, so *any* change is rejected at install/upgrade time:
Error: UPGRADE FAILED: values don't meet the specifications of the
schema(s) in the following chart(s):
- at '/scheduling/userScheduler/replicas': value must be 2
The chart was therefore not configurable at all — a settings form could render
the fields, but saving anything failed. Removing the single-value enums restores
normal schema behaviour (types are still enforced); no genuine multi-choice
enums existed, so nothing loosens that shouldn't.
Separately, helm-manager only emits a form field for a leaf carrying
`mutable: true` (see charts/litellm/values.schema.json, whose form works). This
chart had `category` on all six top-level objects but no `mutable` anywhere, so
its published form was empty and the UI fell back to a generic field list. The
14 values an operator actually needs are now marked mutable:
notebook image singleuser.image.name / .tag
storage singleuser.storage.capacity / .type
auth hub.config.JupyterHub.authenticator_class / .admin_access,
hub.baseUrl
https proxy.https.enabled / .hosts
idle culling cull.enabled / .timeout / .every
scheduling scheduling.userScheduler.enabled / .replicas
The remaining 35 (network policies, cloud-metadata iptables, uid/fsGid,
prePuller internals) are editable via values but deliberately left out of the
form to keep it usable.
Tested on a local cluster: installs clean with values the old schema rejected
outright — replicas=1, cull.timeout=7200, storage.capacity=20Gi — and all three
are present in the rendered config (user-scheduler runs 1 replica instead of 2).
Note the test overrode proxy Service type to NodePort, since
templates/proxy/service.yaml hardcodes LoadBalancer and the local cluster has no
LB controller; that is pre-existing and untouched here.
Follow-up to the previous commit, which unlocked all 49 leaves and marked 14 as
mutable. 49 entries is more than the schema should carry: most are internals no
operator sets, and surfacing them is actively harmful — scheduling.userScheduler
.replicas is an internal scheduler knob, and it being editable is what produced
a failed release in the first place.
The schema now describes exactly the values worth exposing:
singleuser.image.name / .tag which notebook environment
singleuser.storage.capacity per-user home size
hub.config.JupyterHub.authenticator_class login method — the default "dummy"
accepts ANY username/password
hub.config.JupyterHub.admin_access admin access to user servers
cull.enabled / .timeout reclaim idle notebook compute
proxy.https.enabled / .hosts TLS at the proxy
Dropped from the previous 14, with reasons:
scheduling.userScheduler.enabled/.replicas internal scheduling detail
hub.baseUrl only for path-based routing
singleuser.storage.type "static" also needs pvcName, which
is not exposed — a half-usable
option is worse than none
cull.every polling interval; timeout is the
meaningful knob
Omitting a value from the schema does not remove it: it keeps its values.yaml
default and can still be overridden, it simply is not validated or offered as a
form field. Verified that scheduling.userScheduler.replicas=1 is still accepted.
Each field carries a description so the form is self-explaining, and the four
top-level groups were re-categorised (hub/proxy from "advanced" to
"environment", cull to "compute") so auth and HTTPS are not buried.
Tested: helm lint clean; all 9 fields accept overrides; defaults still render;
`helm install --wait` -> STATUS: deployed with cull.timeout=7200 and
singleuser.storage.capacity=20Gi both present in the rendered config.
Four categories for nine fields split them too thinly. Day-to-day settings
(notebook image, home size, idle culling) now sit under Runtime, and the ones
that change how the deployment is reached (login method, admin access, HTTPS)
under Advanced:
runtime singleuser.image.name / .tag, singleuser.storage.capacity,
cull.enabled / .timeout
advanced hub.config.JupyterHub.authenticator_class / .admin_access,
proxy.https.enabled / .hosts
Category only — no fields added, removed or retyped. helm lint clean and all
nine still accept overrides.
|
Per This PR currently only touches helm package charts/jupyterhub --version 0.0.4
mv jupyterhub-0.0.4.tgz docs/
cd docs && helm repo index . --url https://helm.zop.devand commit |
Per CONTRIBUTING.md steps 4-6, a chart change is only published once the built package and the regenerated index are committed: docs/ IS the Helm repo, served by GitHub Pages (see CNAME -> helm.zop.dev). Without this, merging would leave docs/index.yaml pointing at jupyterhub-v0.0.3.tgz and the schema fix would never reach helm-manager or the UI. helm package charts/jupyterhub --version v0.0.4 -d . mv jupyterhub-v0.0.4.tgz docs/ cd docs && helm repo index . --url https://helm.zop.dev Packaged as v0.0.4 (v-prefixed) to match every existing package in docs/ — Chart.yaml carries the bare 0.0.4, the same split every other chart here uses. Verified before committing: - docs/index.yaml now lists jupyterhub v0.0.4 -> jupyterhub-v0.0.4.tgz - the packaged tgz carries the fixed schema: 9 fields, 0 single-value enum locks, all mutable, grouped runtime/advanced - no other chart's version list changed (29 charts before and after); the rest of the index diff is only regenerated `created` timestamps + digests, the same churn as #295 and #290
|
Good catch — you're right, and I'd misread how publishing works here. I'd assumed Done in 84a09dc: helm package charts/jupyterhub --version v0.0.4 -d .
mv jupyterhub-v0.0.4.tgz docs/
cd docs && helm repo index . --url https://helm.zop.devOne deviation from your snippet: I packaged as Verified before pushing:
PR description updated with the packaging step and these checks. Thanks for the pointer to the CONTRIBUTING steps and the prior PRs — that made the expected shape unambiguous. |
|
Packaging looks good — verified the actual Two smaller things worth addressing before merge, neither blocking:
|
Review follow-up. The leaves declared `type` + `mutable` + `description` but no `default`, so the settings form had nothing to pre-fill with and rendered every field blank — inviting a save that writes empty over a working value. Each default is read programmatically from charts/jupyterhub/values.yaml rather than hand-copied, and asserted equal to it, so the schema cannot drift from the values it describes: singleuser.image.name quay.io/jupyterhub/k8s-singleuser-sample singleuser.image.tag 4.1.1-0.dev.git.6957.h0e735928 singleuser.storage.capacity 10Gi hub.config.JupyterHub.authenticator_class dummy hub.config.JupyterHub.admin_access true cull.enabled true cull.timeout 3600 proxy.https.enabled false proxy.https.hosts [] Defaults are JSON-typed (`true`, `3600`, `[]`), not stringified, matching how charts/litellm and charts/postgres already store non-string defaults — openobserve-standalone only demonstrates the string case. Repackaged, since the published .tgz is what the UI actually reads: helm package charts/jupyterhub --version v0.0.4 -d . mv jupyterhub-v0.0.4.tgz docs/ && cd docs && helm repo index . --url https://helm.zop.dev Verified: all 9 leaves carry a default INSIDE the packaged tgz; index digest matches the rebuilt tgz byte-for-byte; 29 charts and the v0.0.1-v0.0.4 version list unchanged; helm lint clean; helm template against the EXTRACTED package still accepts cull.timeout=7200 and scheduling.userScheduler.replicas=1 and renders on defaults alone.
|
Fixed in d3c9862 — all 9 leaves now carry a Rather than hand-copying the values, the script reads each one out of
One note on typing: I stored these JSON-typed ( Repackaged and re-indexed, since the
PR description updated with the defaults table. One thing — you mentioned "two smaller things" but only item 1 made it into the comment; there's no item 2 in the body. Did the second point get cut off? Happy to pick it up in the same pass. |
arunesh-j
left a comment
There was a problem hiding this comment.
Approving — the schema-lock bug is fixed and verified end-to-end (extracted the packaged `.tgz`, confirmed digest matches `docs/index.yaml`, `helm lint`/`helm template` pass on defaults and on the override that broke the old schema, all 9 exposed fields now carry matching `default`s).
Re: `hub.config.JupyterHub.authenticator_class` staying under `advanced` — acknowledged, keeping it there per discussion. Not a blocker.
Found while testing an actual `helm upgrade` on a live cluster: the install is
fine, but ANY config change left the hub in CrashLoopBackOff:
api_request to proxy failed: HTTP 403: Forbidden
Two bugs compounding.
1. The password helpers looked the hub Secret up under the WRONG NAME. They used
`jupyterhub.hub.fullname` -> "<release>-jupyterhubhub", while
templates/hub/secret.yaml creates it as "jupyterhub-secrets". The lookup
therefore always missed and fell through to `randAlphaNum`, so every upgrade
minted fresh values. Measured across one upgrade:
auth_token before: fuOrDC8TfcdR
auth_token after : XP4hBshwGqRb
This affected all FOUR helpers, not just the proxy token — cookie_secret
(logs every user out), CryptKeeper.keys (makes stored encrypted auth state
undecryptable) and the service api_tokens were rotated the same way.
2. templates/proxy/deployment.yaml had an `annotations:` block containing only
the COMMENTS describing a `checksum/auth-token` annotation; the annotation
line itself was missing. So the proxy never restarted to pick up a changed
token — confirmed by the proxy pod keeping 0 restarts and the same name
across an upgrade. The hub rolled with a new token, the proxy kept the old
one, and every request between them 403'd.
Fix: one `jupyterhub.hub-secret.fullname` helper as the single source of truth
for that Secret's name, used by the Secret itself, its two consumers and all
four lookups — the bug was two places disagreeing about a hardcoded string, so
the name now exists once. Plus the missing annotation restored, as the safety
net upstream intends for the case where the token legitimately changes.
Chart 0.0.4 -> 0.0.5, repackaged and re-indexed.
Verified on a local cluster with the PACKAGED tgz — install, then TWO successive
upgrades:
- install: rc=0, hub 1/1
- upgrade x2: rc=0, hub settles 1/1 with no 403 crashloop
- auth_token and cookie_secret PRESERVED across both upgrades
- values applied each time (timeout 7100 -> 7200, replicas 1, capacity 20Gi)
- the proxy pod now rolls with the hub instead of being left behind
v0.0.4 only ever existed on this branch — it was never published — and its upgrade path is the one that left the hub in a 403 CrashLoopBackOff. Shipping it alongside v0.0.5 would publish a version we already know breaks on upgrade, so it is removed and the index regenerated. Published history stays v0.0.1-v0.0.3 plus v0.0.5.
|
Before merging I ran a real Symptom: install was fine, but any config change left the hub in CrashLoopBackOff: Two bugs compounding, both pre-existing:
Fix: a single Verified against the packaged tgz — install, then TWO successive upgrades: Also dropped This does mean the PR now touches templates, where it previously said "schema and version only" — description updated accordingly. Happy to split the upgrade fix into its own PR if you'd rather keep this one schema-only; the two are independent, though merging the schema change alone would ship a chart whose values still can't be edited without breaking the hub. |
|
Final end-to-end verification, run against the tgz downloaded from this branch ( Live single-node cluster, fresh namespace, cluster-scoped RBAC cleared first: What each part is checking:
One limitation, stated plainly: the local cluster has no load-balancer controller, so |
Neither 0.0.4 nor 0.0.5 was ever published — both only existed on this branch — so there is no reason to consume two version numbers for one change. The chart ships as 0.0.4, carrying BOTH the schema fix and the upgrade fix, and the intermediate 0.0.5 package is removed. Published history stays v0.0.1-v0.0.3 plus v0.0.4. Re-verified after repackaging rather than assuming a version-string change is inert — the packaged tgz is what publishes: schema inside the tgz: 9 fields, 9 defaults, 0 enum locks 4 repointed secret lookups + the checksum/auth-token annotation present index digest matches the tgz; 29 charts unchanged install rc=0, hub 1/1, 0x403; three successive upgrades rc=0, hub 1/1, 0x403 auth_token / cookie_secret / CryptKeeper.keys all preserved across every one replicas moved 2 -> 1 -> 1 -> 2 on the live Deployment
|
Since there is fix it bumped from v0.0.4 to v0.0.5 .. We can keep it has v0.0.4 only and use package that version again and do the helm index. |
arunesh-j
left a comment
There was a problem hiding this comment.
Re-approving after the template changes (secret-rotation fix) — treating the prior approval as stale since scope materially grew from schema-only to 5 template files fixing a real bug.
Verified independently:
- The naming mismatch is real: `hub/secret.yaml` hardcodes the Secret name as `jupyterhub-secrets`, but all 4 `lookup` call sites in `hub/_helpers-passwords.tpl` looked it up via `jupyterhub.hub.fullname` (`-jupyterhubhub`) — grepped the pre-PR code to confirm these never matched.
- The fix (`jupyterhub.hub-secret.fullname` helper) is applied consistently everywhere a Secret name is needed — no leftover references to the old path anywhere in the template tree.
- Pulled the packaged `docs/jupyterhub-v0.0.4.tgz`, confirmed its digest matches `docs/index.yaml` byte-for-byte, and confirmed the fix is actually baked into the extracted templates (not just source).
- `helm lint`/`helm template` clean against the packaged artifact; `checksum/auth-token` renders correctly.
- CI (`lint-test`) passing.
One caveat: `lookup` is always empty under `helm template`, so I can't independently reproduce the secret-persists-across-upgrades behavior myself — that requires the live-cluster `helm upgrade` cycle already described in the PR's testing section.
Filing a separate follow-up issue for a related-but-out-of-scope finding: `image-puller/_helpers-daemonset.tpl` has the same lookup-name-mismatch pattern against a ConfigMap, which silently breaks the `prePuller.hook.pullOnlyOnChanges` optimization. Not a blocker for this PR.
…name Fixes #300. `image-puller/_helpers-daemonset.tpl` looked the hub ConfigMap up as `jupyterhub.hub.fullname` ("<release>-jupyterhubhub"), but `hub/configmap.yaml` creates it as "jupyterhub-configs". The lookup therefore always missed, so `$old_checksum` fell back to "" and never equalled the sha256 in `$new_checksum` — making `prePuller.hook.pullOnlyOnChanges: true` (the default) a no-op that recreated and reran the pre-pull hook Job, DaemonSet, RBAC and ServiceAccount on every single `helm upgrade`. Same shape as the hub Secret fix in this PR: the bug was three places disagreeing about one hardcoded string, so the name now exists once as `jupyterhub.hub-configmap.fullname`, and the ConfigMap that creates it, the hub Deployment that mounts it and the lookup that reads it all resolve it there. Repackaged and re-indexed as v0.0.4 rather than bumping — v0.0.4 has never been published, so there is no released artifact to supersede.
|
Both open points are in 1. Kept as 2. #300 fixed in the same commit — you were right that it's the same pattern. I reproduced it before fixing rather than taking the reasoning on trust. Same scenario on the pre-fix and post-fix packages, only variable being the name the lookup asks for. The rendered decision states the miss outright: Counting pre-pull hook resources the render decides to create: The third row is the one that makes the second row mean something — without it, "0" could just as well be the hook switched off. Then real upgrades on a release installed with the true default singleuser image, counting So a config edit — the case the UI actually generates — no longer drags an image-pull DaemonSet along with it. Re-proved #299's guarantees, since this fix touches the ConfigMap the hub Deployment mounts and checksums: four consecutive upgrades on a second release, all three secrets ( No resource churn: neither helper changes any resource's real name — both return the literal that was hardcoded before. Description and title updated for the third fix. Still no item 2 from your "two smaller things" — only item 1 (the missing |
arunesh-j
left a comment
There was a problem hiding this comment.
Re-approving — the PR now also includes the fix for #300 (image-puller ConfigMap lookup using the wrong name).
Verified:
- New `jupyterhub.hub-configmap.fullname` helper (literal `jupyterhub-configs`) is used consistently by the ConfigMap's own name, the hub Deployment's volume reference, and the `lookup` call in `image-puller/_helpers-daemonset.tpl:218` that previously targeted `jupyterhub.hub.fullname` — grepped the tree, no leftover references to the old broken path.
- Pulled the packaged `docs/jupyterhub-v0.0.4.tgz`, confirmed its digest matches `docs/index.yaml`, and confirmed the fix is baked into the extracted templates.
- `helm lint`/`helm template` clean against the packaged artifact.
- CI (`lint-test`) passing.
Ref: #300 (leaving that issue open per request, not closing it from here).
Three fixes that together make JupyterHub configurable. The schema rejected every edit; once an edit was accepted, applying it broke the hub; and every apply needlessly reran the image pre-pull hook. The first two each leave the chart unusable on their own, so they belong together; the third (#300) was found while reviewing this PR and is the same class of bug as the second.
Chart
0.0.3→0.0.4.Problem 1 — the schema rejected everything
Every scalar in
values.schema.jsonwas generated asenum: [<current value>]— 49 of 49 leaves. A single-value enum makes that value the only legal one:Separately, helm-manager only emits a form field for a leaf carrying
mutable: true(comparecharts/litellm, whose form works). This chart hadcategoryon all six top-level objects butmutableon nothing, so its published form came back empty and the UI fell back to a generic field list.Problem 2 — applying a change broke the hub
Found by running an actual
helm upgradeon a live cluster, not justhelm template. Install was fine; any config change gave:Two causes compounding:
jupyterhub.hub.fullname(<release>-jupyterhubhub) whiletemplates/hub/secret.yamlcreates it asjupyterhub-secrets. Thelookupalways missed and fell through torandAlphaNum, minting fresh secrets on every upgrade — measuredauth_tokenchangingfuOrDC8TfcdR→XP4hBshwGqRbacross one apply. This hit all four helpers:auth_token,cookie_secret(logs every user out),CryptKeeper.keys(renders stored encrypted auth state undecryptable) and the serviceapi_tokens.templates/proxy/deployment.yaml'sannotations:block contained only the comments describing achecksum/auth-tokenannotation — the annotation line itself was missing, so the proxy never restarted to pick up a changed token. Confirmed by the proxy pod keeping 0 restarts and the same name across an upgrade: hub rolled with a new token, proxy kept the old one.Problem 3 — every upgrade reran the pre-pull hook (#300)
The same wrong-name pattern, one file over:
image-puller/_helpers-daemonset.tpllooked the hub ConfigMap up asjupyterhub.hub.fullname(<release>-jupyterhubhub) whilehub/configmap.yamlcreates it asjupyterhub-configs.That ConfigMap is where
checksum_hook-image-pulleris stored so the next release can tell whether the pre-pull DaemonSet actually changed. The lookup never found it, so$old_checksumwas always"", never equalled the sha256 in$new_checksum, andprePuller.hook.pullOnlyOnChanges: true— the default — was a no-op. The hook Job, DaemonSet, RBAC and ServiceAccount were recreated and rerun on everyhelm upgrade, including one that changed nothing. Not a correctness bug like Problem 2, just an image-pull DaemonSet spun up on every apply.Reproduced directly, comparing the two packaged charts under identical conditions — the rendered decision spells the miss out:
Changes
Schema — describes exactly the 9 values worth exposing, each
mutable: truewith adescriptionand adefaultmatchingvalues.yaml:runtimesingleuser.image.namestringquay.io/jupyterhub/k8s-singleuser-sampleruntimesingleuser.image.tagstring4.1.1-0.dev.git.6957.h0e735928runtimesingleuser.storage.capacitystring10Giadvancedhub.config.JupyterHub.authenticator_classstringdummyadvancedhub.config.JupyterHub.admin_accessbooleantrueruntimecull.enabledbooleantrueruntimecull.timeoutinteger3600advancedproxy.https.enabledbooleanfalseadvancedproxy.https.hostsarray[]Defaults are JSON-typed (
true,3600,[]) to match howcharts/litellmandcharts/postgresalready store non-string defaults, and each is read programmatically fromvalues.yamland asserted equal to it, so schema and values cannot drift.Deliberately not exposed:
scheduling.userScheduler.*(internal scheduler tuning — and this field being editable is what produced a failed release),hub.baseUrl(path-based routing only),singleuser.storage.type(staticalso needspvcName, which isn't exposed — a half-usable option is worse than none),cull.every(polling interval;timeoutis the knob), and ~35 internals (network policies, cloud-metadata iptables,uid/fsGid, prePuller). Omitting a value does not disable it: it keeps itsvalues.yamldefault and is still settable via--set— verifiedscheduling.userScheduler.replicas=1still works after removal.Templates — both bugs were places disagreeing about a hardcoded string, so each name now exists exactly once:
jupyterhub.hub-secret.fullnamejupyterhub-secretshub/secret.yaml, its two consumers, all 4 password lookupsjupyterhub.hub-configmap.fullnamejupyterhub-configshub/configmap.yaml, the hub Deployment's volume, the image-puller lookupPlus the missing
checksum/auth-tokenannotation restored, as the safety net upstream intends.Neither helper changes any resource's actual name — both resolve to the same literal that was hardcoded before. Confirmed by diffing
helm templateacross the change: identical once the per-runrandAlphaNumsecrets are masked (2271 lines each), so existing releases see no resource churn.Packaging
Per CONTRIBUTING.md steps 4–6, matching #295 / #290 —
docs/is the repo itself (GitHub Pages,CNAME→ helm.zop.dev), so an unpackaged change publishes nothing:Published history is
v0.0.1–v0.0.3plusv0.0.4.Verification (live cluster, against the packaged tgz)
Schema + secret fixes — install then three upgrades, because the
lookupbug only manifests once a prior Secret exists and a stale token only surfaces on the next apply:Pre-pull hook fix (#300) — the identical scenario run on the chart as it stood before this commit and after it, so the only variable is which ConfigMap name the lookup asks for. Counted as pre-pull hook resources the render decided to create, and as
hook-image-awaiterJob events actually recorded in the namespace:Then real upgrades, not dry-runs, on a release installed with the true default singleuser image:
A config edit no longer drags an image-pull DaemonSet along with it — which is the case the UI actually generates.
Also checked: all 9 defaults present inside the packaged tgz (not just the source); index digest matches the tgz byte-for-byte (
7ff775eacab9…); the index diff is exactly one digest, jupyterhubv0.0.4— 29 charts and every other chart's version list byte-identical;helm lintclean.Notes
v0.0.4rather than bumping, per review —v0.0.4has never been published, so there is no released artifact to supersede.templates/proxy/service.yamlhardcodestype: LoadBalancerand is not values-configurable, sohelm --waitcannot complete on a cluster with no LB controller; the local runs poll pod readiness directly instead. Fine on a cloud provider.0.0.4or reinstalling to pick this up. New installs get it automatically.