Skip to content

feat(hub): extend trust-bundle mechanism to the hub pod#160

Open
oren-openteams wants to merge 2 commits into
nebari-dev:mainfrom
oren-openteams:feat/hub-trust-bundle
Open

feat(hub): extend trust-bundle mechanism to the hub pod#160
oren-openteams wants to merge 2 commits into
nebari-dev:mainfrom
oren-openteams:feat/hub-trust-bundle

Conversation

@oren-openteams

Copy link
Copy Markdown
Contributor

Closes #159.

Summary

Extends the chart's existing enterprise CA bundle mechanism (custom.trust-bundle-enabled + _setup_trust_bundle in 01-spawner.py) to also cover the hub pod. The singleuser side was already thorough; the hub was the last gap.

Before this PR, outbound HTTPS from the hub to any TLS-inspected external endpoint (external Keycloak URL, oauthenticator refresh, jhub-apps configuration fetches) had no way to trust the org CA short of overriding the entire authenticator via hub.extraConfig.

Changes

values.yaml jupyterhub.hub.*:

  • extraVolumes — adds org-ca (ConfigMap, optional: true) + ca-merged (emptyDir). Same names as the singleuser side.
  • extraVolumeMounts — adds ca-merged at /etc/ssl/certs-extra, matching the singleuser path.
  • initContainers — new merge-ca-bundle container. Copies the image's system CA into the emptyDir, then appends the org CA IFF /org-ca/<key> exists (so missing trust-manager is a no-op). Uses the hub image so the system CA store the init container reads matches what the hub container has at runtime.
  • extraEnv — adds all five CA env vars pointing at the merged file: REQUESTS_CA_BUNDLE, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, CURL_CA_BUNDLE, GIT_SSL_CAINFO. Rust tooling (rustls-native-certs) honours SSL_CERT_FILE but does not iterate SSL_CERT_DIR, so the explicit file path is required.

Also updates the trust-bundle-enabled docstring to describe the hub-side wiring and cross-references the two places that now consume trust-bundle-configmap/trust-bundle-key.

Design note — unconditional on the hub

Unlike the singleuser side (gated at spawn time by custom.trust-bundle-enabled via a Python hook), the hub-side wiring is unconditional. z2jh renders the hub Deployment from static values, so runtime gating isn't available without a chart-side helper (which would require the chart to interpose on how values reach z2jh).

optional: true on the ConfigMap and the [ -f ... ] guard on the shell append make the always-on wiring functionally identical to the singleuser disabled path on clusters without trust-manager — merged bundle equals system bundle, env vars point at a file that matches the system trust store, no behaviour change. Costs one extra init container run per hub pod start (< 1s wall-clock on typical images).

Deployers who need to opt out entirely can override hub.extraVolumes etc. in their own values, subject to the existing warning about re-including the pre-existing chart entries.

Follow-up work worth flagging

custom.trust-bundle-configmap and custom.trust-bundle-key now drive TWO sites (singleuser Python hook reads them at runtime; hub-side static YAML references the same default explicitly). Changing them requires editing values in two places until a chart helper binds them. Kept the duplication out of scope for this PR to keep the diff surgical, but happy to add the helper in a follow-up if reviewers prefer.

Tests

tests/unit/test_hub_ca_bundle.py — 12 assertions pinning the shape of the jupyterhub.hub.* block:

  • extraVolumes contains org-ca (optional ConfigMap) + ca-merged (emptyDir); preserves the two pre-existing chart entries (custom-config, oauth-client)
  • extraVolumeMounts exposes ca-merged at /etc/ssl/certs-extra; preserves pre-existing entries
  • initContainers has merge-ca-bundle; it uses the hub image; the shell command copies system CA and conditionally appends org CA; volume mounts are wired correctly
  • extraEnv sets all five CA env vars to the merged bundle path; preserves the existing OIDC-client-secret entry
  • Cross-cutting: the hub-side org-ca ConfigMap name matches the default custom.trust-bundle-configmap value (drift detector)

Runs standalone (pyyaml + pytest only, no chart deps):

$ python -m pytest tests/unit/test_hub_ca_bundle.py -v
=============== 12 passed in 0.21s ===============

Verification

Not deployed end-to-end yet — will be validated as part of the downstream deploy that motivated this. Change is declarative YAML plus a values-shape unit test; the maintainers' existing CI (helm lint / chart-render) should cover the rest. Happy to iterate if anything doesn't render cleanly.

Closes nebari-dev#159.

The chart already ships an enterprise CA bundle mechanism for singleuser
pods via ``custom.trust-bundle-enabled`` — ``_setup_trust_bundle`` in
``01-spawner.py`` mounts trust-manager's org-ca ConfigMap, merges it with
the image's system CA via an init container, and points the standard CA
env vars at the merged file so pip/conda/git/requests all see both public
CAs and the org CA. The equivalent wiring on the hub pod itself was
missing: outbound HTTPS from the hub to TLS-inspected external endpoints
(oauthenticator refresh against an external Keycloak, jhub-apps
configuration fetches, etc.) had no way to trust the org CA short of
overriding the entire authenticator via ``hub.extraConfig``.

This adds the missing hub-side wiring to ``values.yaml`` under
``jupyterhub.hub.*``:

- ``extraVolumes``: an ``org-ca`` ConfigMap volume with ``optional: true``
  (so a cluster without trust-manager still starts) plus a ``ca-merged``
  emptyDir volume.
- ``extraVolumeMounts``: the merged bundle mounted at
  ``/etc/ssl/certs-extra`` — same path as the singleuser side.
- ``initContainers``: a ``merge-ca-bundle`` init container that copies
  ``/etc/ssl/certs/ca-certificates.crt`` from the hub image into
  ``/merged/ca-bundle.crt`` and then appends the org CA IFF the mounted
  file exists. On clusters without trust-manager the merged bundle is a
  byte-copy of the system bundle — functionally a no-op. Uses the hub
  image so the system CA store the init container reads matches what
  the hub container has at runtime; a generic busybox would not.
- ``extraEnv``: ``REQUESTS_CA_BUNDLE``, ``SSL_CERT_FILE``,
  ``NODE_EXTRA_CA_CERTS``, ``CURL_CA_BUNDLE``, ``GIT_SSL_CAINFO`` all
  point at the merged bundle. Rust tooling (rustls-native-certs) honours
  ``SSL_CERT_FILE`` but does not iterate ``SSL_CERT_DIR``, so the
  explicit file path is required.

Design note: unlike the singleuser side (which gates on
``custom.trust-bundle-enabled`` at spawn time via a Python hook), the
hub-side wiring is unconditional. The hub Deployment is rendered by z2jh
from static values, so runtime gating is not available without a chart
helper. ``optional: true`` on the ConfigMap and the ``[ -f ... ]`` guard
on the append make the always-on wiring functionally identical to the
singleuser disabled path — merged bundle equals system bundle, env vars
point at a file that matches the system trust store, no behaviour change
on clusters without trust-manager. Costs one extra init container run
per hub pod start. Deployers who want to opt out entirely can override
``hub.extraVolumes`` etc. in their own values (with the existing warning
about re-including the pre-existing entries).

The ``trust-bundle-configmap`` and ``trust-bundle-key`` values under
``jupyterhub.custom.*`` drive BOTH sides. Changing them today requires
editing values in two places (the singleuser Python hook reads the
custom values at runtime; the hub-side static YAML references the same
default explicitly). A follow-up could bind them via a chart helper.

Tests: ``tests/unit/test_hub_ca_bundle.py`` pins the shape of the
``jupyterhub.hub.*`` block — the volumes, mounts, init container, env
vars, and the alignment between the hub-side static reference and the
default ``trust-bundle-configmap`` value. 12 assertions covering the
contract in the ``trust-bundle-enabled`` docstring so a future edit that
accidentally drops one of them fails CI instead of silently breaking
outbound HTTPS from the hub.
runAsNonRoot

z2jh renders the hub Deployment with a pod-level ``securityContext`` of
``runAsNonRoot: true`` but no ``runAsUser``, so containers inherit the
non-root requirement while relying on their image's default USER
directive to pick a uid. The hub image's default is root, so the
``merge-ca-bundle`` init container (which reuses the hub image so the
system CA store it reads matches what the hub container has at runtime)
was blocked by kubelet with ``container has runAsNonRoot and image will
run as root`` and the pod was stuck at ``Init:CreateContainerConfigError``
indefinitely.

Fix: give the init container an explicit ``securityContext`` that mirrors
the hub container itself — ``runAsUser: 1000``, ``runAsGroup: 1000``,
``allowPrivilegeEscalation: false``, drop all capabilities. Uid 1000 has
world-read access to ``/etc/ssl/certs/ca-certificates.crt``, and the
shared ``ca-merged`` emptyDir inherits the pod's ``fsGroup: 1000``, so
the merge script can read the system bundle and write the merged file
without privilege.

Adds a unit test pinning the shape so a future re-render that drops the
block fails loudly rather than silently reintroducing the CreateContainer
ConfigError. Caught by the e2e ``hub_login_page`` CI job on the original
push — hub pod never left ``Init:0/1``. Local kind reproduce confirmed
the fix.
@oren-openteams

Copy link
Copy Markdown
Contributor Author

Verified end-to-end on a private-VPC dev cluster.

The daa32b9 init-container fix was necessary — without it kubelet refuses to start merge-ca-bundle with container has runAsNonRoot and image will run as root, because z2jh sets a pod-level runAsNonRoot: true without an explicit uid and the hub image's default USER is root. Pod sits at Init:CreateContainerConfigError indefinitely with no logs from the init container itself — the CI failure surfaced it via hub_login_page timing out for 5 min waiting on the pod. Local kind reproduce confirmed the kubelet event. Fix mirrors what the hub container itself declares (runAsUser: 1000, runAsGroup: 1000, allowPrivilegeEscalation: false, capabilities.drop: [ALL]) so ownership on the shared ca-merged emptyDir stays coherent.

Post-fix: all 14 CI checks green (including the previously-failing e2e shared-storage suite and hub_login_page), and on the real cluster:

  • Init container completes cleanly, hub pod goes Ready
  • /etc/ssl/certs-extra/ca-bundle.crt in the hub contains both the system CAs and the org CA
  • Outbound HTTPS from the hub to the TLS-inspected external Keycloak URL validates cleanly for the first time (previously required a hub.extraConfig override with hard-coded in-cluster URLs to sidestep the CA trust gap)

One minor documentation note worth flagging: on deploys where the target trust-manager Bundle uses a different key than the chart default (ca-certificates.crt vs, e.g., ca-bundle.crt), the init container's shell script needs the key updated too — the custom.trust-bundle-key value only drives the singleuser side (the "duplication out of scope" note in the PR body). Not a blocker; something reviewers might want to keep in mind for a follow-up bind helper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: expose orgCABundle.configMapName for TLS trust injection (mirror nebi-pack #38 pattern)

1 participant