From 12fc36b2b13f5b3c4804a3170b2d9fdf7f35a7c0 Mon Sep 17 00:00:00 2001 From: Oren Fromberg Date: Fri, 10 Jul 2026 08:52:30 -0400 Subject: [PATCH 1/2] feat(hub): extend trust-bundle mechanism to the hub pod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #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. --- tests/unit/test_hub_ca_bundle.py | 201 +++++++++++++++++++++++++++++++ values.yaml | 75 +++++++++++- 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_hub_ca_bundle.py diff --git a/tests/unit/test_hub_ca_bundle.py b/tests/unit/test_hub_ca_bundle.py new file mode 100644 index 0000000..f89ce92 --- /dev/null +++ b/tests/unit/test_hub_ca_bundle.py @@ -0,0 +1,201 @@ +"""Structural tests for the hub-side enterprise CA bundle wiring in values.yaml. + +The singleuser side is covered by ``test_spawner_ca_bundle.py`` (runtime Python +in ``01-spawner.py``). The hub-side wiring lives in the chart's static +``values.yaml`` — z2jh renders the hub Deployment from those values, so +there is no Python to unit-test. Instead we assert the shape of the values +file itself: the required volumes, mounts, init container, and env vars +are present under ``jupyterhub.hub.*`` with the expected shape. These +assertions pin the contract described 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. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +VALUES_YAML = REPO_ROOT / "values.yaml" + +MERGED = "/etc/ssl/certs-extra/ca-bundle.crt" + + +def _hub_values(): + """Load the ``jupyterhub.hub`` block from the chart's values.yaml.""" + with VALUES_YAML.open() as f: + values = yaml.safe_load(f) + return values["jupyterhub"]["hub"] + + +# --------------------------------------------------------------------------- +# extraVolumes — org-ca (optional ConfigMap) + ca-merged (emptyDir) +# --------------------------------------------------------------------------- + +def test_extra_volumes_contains_org_ca_optional_configmap(): + """org-ca must be an OPTIONAL ConfigMap volume — missing trust-manager + is a no-op, not a spawn/pod-start failure.""" + volumes = _hub_values()["extraVolumes"] + org_ca = next((v for v in volumes if v["name"] == "org-ca"), None) + assert org_ca is not None, "missing 'org-ca' entry in hub.extraVolumes" + assert "configMap" in org_ca, "org-ca must be a ConfigMap volume" + assert org_ca["configMap"].get("optional") is True, ( + "org-ca ConfigMap must be optional so hub startup does not depend on " + "trust-manager being installed" + ) + + +def test_extra_volumes_contains_ca_merged_emptydir(): + """ca-merged is the emptyDir the merge init container writes into.""" + volumes = _hub_values()["extraVolumes"] + merged = next((v for v in volumes if v["name"] == "ca-merged"), None) + assert merged is not None, "missing 'ca-merged' entry in hub.extraVolumes" + assert "emptyDir" in merged, "ca-merged must be an emptyDir volume" + + +def test_extra_volumes_preserves_pre_existing_chart_entries(): + """The two chart defaults (custom-config, oauth-client) must stay in the + list so this edit does not regress the existing wiring.""" + names = {v["name"] for v in _hub_values()["extraVolumes"]} + assert {"custom-config", "oauth-client"}.issubset(names) + + +# --------------------------------------------------------------------------- +# extraVolumeMounts — the merged bundle must be visible in the hub container +# --------------------------------------------------------------------------- + +def test_extra_volume_mounts_exposes_ca_merged_at_expected_path(): + mounts = _hub_values()["extraVolumeMounts"] + ca_merged = next((m for m in mounts if m["name"] == "ca-merged"), None) + assert ca_merged is not None, "missing 'ca-merged' mount in hub.extraVolumeMounts" + assert ca_merged["mountPath"] == "/etc/ssl/certs-extra", ( + "hub-side must use the same merged-bundle mount path as the " + "singleuser side for path consistency" + ) + + +def test_extra_volume_mounts_preserves_pre_existing_chart_entries(): + names = {m["name"] for m in _hub_values()["extraVolumeMounts"]} + assert {"custom-config", "oauth-client"}.issubset(names) + + +# --------------------------------------------------------------------------- +# initContainers — merge-ca-bundle produces /etc/ssl/certs-extra/ca-bundle.crt +# --------------------------------------------------------------------------- + +def test_init_container_merge_ca_bundle_present(): + init_containers = _hub_values().get("initContainers", []) + merge = next( + (c for c in init_containers if c["name"] == "merge-ca-bundle"), None, + ) + assert merge is not None, ( + "missing 'merge-ca-bundle' init container — hub cannot reach TLS-" + "inspected external endpoints (Keycloak external URL, etc.) without it" + ) + + +def test_init_container_uses_hub_image(): + """The init container must read the SAME system CA the main container + has. Using the hub image guarantees that; a generic busybox would not. + + Kept as a soft assertion (contains 'jupyterhub' in name) so a maintainer + who bumps the tag doesn't have to update this test in lockstep with the + hub.image field. If the image reference is refactored to a chart-side + helper later, this assertion can be removed. + """ + init_containers = _hub_values().get("initContainers", []) + merge = next(c for c in init_containers if c["name"] == "merge-ca-bundle") + hub_image = _hub_values()["image"] + assert merge["image"] == f'{hub_image["name"]}:{hub_image["tag"]}', ( + "merge-ca-bundle image must match hub.image so the system CA store " + "the init container reads matches what the hub container has at " + "runtime; if you bump hub.image, bump the init container image too" + ) + + +def test_init_container_appends_org_ca_conditionally(): + """The shell command must: + 1. Copy the image's system CA into /merged/ca-bundle.crt (always) + 2. Append org CA IFF the mounted file exists (so missing trust-manager + is a no-op — merged bundle == system bundle) + """ + init_containers = _hub_values().get("initContainers", []) + merge = next(c for c in init_containers if c["name"] == "merge-ca-bundle") + # command list is ['/bin/sh', '-c', '