Skip to content

Commit a5dcb88

Browse files
feat(ca-bundle): inject org CA into Ray head + worker pods via ConfigMap (#15) (#16)
* feat(ca-bundle): inject org CA into Ray head + worker pods (#15) Add a `singleuser.gallery`-style opt-in for organisation-supplied CA bundles. When `orgCABundle.secretName` is set, the chart renders: - An initContainer (default image `alpine:3.20`) that concatenates the base image's system trust store with the deployer-supplied CA into a shared emptyDir at /shared/combined-ca.crt - Two volumes on each pod spec: the deployer Secret (read-only) and the shared emptyDir - SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE env vars on both head and worker pointing at the combined bundle, alongside any existing head.containerEnv / worker.containerEnv entries - A read-only volumeMount of the combined bundle on each main container When `secretName` is empty (default), zero CA-related output is rendered — no initContainer, no volumes, no env vars. Backward- compatible. Implemented as five named helpers in _helpers.tpl (.enabled, .initContainers, .volumes, .volumeMounts, .env) so head and worker share one source of truth, and the rayservice template gates each block on the same `if .secretName` predicate. Covers everything that honors SSL_CERT_FILE: requests, urllib3, curl, git, pip, torch.hub, stdlib urllib, properly-constructed httpx clients. Does NOT cover httpx.Client(verify=True) defaults, which hardcode cafile=certifi.where() and ignore env vars — caller code needs to pass verify=ssl.create_default_context() explicitly. README calls this out as a known coverage caveat. Closes #15 * Add GIT_SSL_CAINFO env var and document ArgoCD ignoreDifferences footgun git's libcurl ignores SSL_CERT_FILE/REQUESTS_CA_BUNDLE/CURL_CA_BUNDLE and reads only GIT_SSL_CAINFO, so `pip install git+https://...` and other git-over-HTTPS calls failed certificate verification in worker pods even with the CA bundle mounted. Set GIT_SSL_CAINFO to the combined bundle too. Also document the ArgoCD footgun: the example sync policy's broad /spec/rayClusterConfig ignore rule combined with RespectIgnoreDifferences makes ArgoCD silently skip the CA injection (which lives under that path) while still reporting a healthy sync. See #17.
1 parent b6b54fc commit a5dcb88

4 files changed

Lines changed: 206 additions & 2 deletions

File tree

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ spec:
123123
- `managedNamespaceMetadata` with `nebari.dev/managed: "true"` is required for the nebari-operator to manage NebariApp resources
124124
- `redirectURI` must be `/oauth2/callback` (Envoy Gateway rejects `/`)
125125
- Set `serve.enabled: false` to keep the serve endpoint internal-only (recommended — notebooks access it via cluster DNS)
126+
- The `/spec/rayClusterConfig` ignore rule combined with `RespectIgnoreDifferences=true` makes ArgoCD stop managing everything under `rayClusterConfig`. If you enable [`orgCABundle`](#organization-ca-bundle-injection), the CA injection lives under that path and will **silently not apply** — see the ArgoCD footgun warning in that section before turning it on.
126127

127128
## Connecting from Jupyter
128129

@@ -225,6 +226,50 @@ Key values in `chart/values.yaml`:
225226
|-------|---------|-------------|
226227
| `serveApplications` | `[]` | Declarative Serve applications (see [Ray Serve config](https://docs.ray.io/en/latest/serve/production-guide/config.html)) |
227228

229+
### Organization CA Bundle Injection
230+
231+
For deployments behind a TLS-inspecting proxy (Netskope, Zscaler, BlueCoat, internal corporate CAs, etc.), point `orgCABundle.configMapName` at a ConfigMap containing your organization's root CA. The chart adds an initContainer that builds a combined CA bundle (system trust + org CA), mounts it into the head and worker pods, and sets `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` / `GIT_SSL_CAINFO` so any TLS client honoring those env vars (requests, urllib3, curl, git, pip, `torch.hub`, etc.) trusts the proxy's re-signed certs.
232+
233+
`GIT_SSL_CAINFO` is set in addition to the three OpenSSL env vars because git's libcurl ignores `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` and reads only `GIT_SSL_CAINFO`. Without it, `pip install git+https://...` and other git-over-HTTPS operations in worker contexts fail certificate verification even though plain `requests`/`pip` calls succeed.
234+
235+
| Value | Default | Description |
236+
|-------|---------|-------------|
237+
| `orgCABundle.configMapName` | `""` | Name of a ConfigMap with key `ca.crt` containing the org CA (PEM). Empty disables injection — no behavior change. |
238+
| `orgCABundle.initImage` | `alpine:3.20` | Image used by the bundle-building initContainer. Only needs `sh` + `cat`. |
239+
240+
```yaml
241+
# Create the ConfigMap out-of-band (gitops, kubectl, etc.):
242+
apiVersion: v1
243+
kind: ConfigMap
244+
metadata:
245+
name: org-ca-bundle
246+
data:
247+
ca.crt: |
248+
-----BEGIN CERTIFICATE-----
249+
...your org CA...
250+
-----END CERTIFICATE-----
251+
---
252+
# Then point the chart at it:
253+
orgCABundle:
254+
configMapName: org-ca-bundle
255+
```
256+
257+
> **Why ConfigMap rather than Secret?** A CA certificate is public material by design — the PKI trust model relies on root CAs being widely distributed. Kubernetes itself uses a ConfigMap for the cluster's own CA distribution (`kube-root-ca.crt`, auto-projected into every namespace), and cert-manager's trust-manager subproject does the same. Use a ConfigMap here; reserve Secret for things that actually need confidentiality (private keys, OAuth client secrets, etc.).
258+
259+
> **⚠️ ArgoCD footgun — the CA bundle silently won't apply.** The ArgoCD `Application` shown under [On a Nebari cluster (via ArgoCD)](#on-a-nebari-cluster-via-argocd) sets `RespectIgnoreDifferences=true` together with an `ignoreDifferences` rule on `/spec/rayClusterConfig`. With server-side apply, that combination tells ArgoCD to **stop managing every field under `rayClusterConfig`** — which is exactly where this chart injects the initContainer, volumes, volumeMounts, and CA env vars for the head and worker pods. The result is a silent failure: ArgoCD reports a healthy, fully-synced `Application`, but the running RayService never gets the CA bundle, and TLS calls keep failing with `CERTIFICATE_VERIFY_FAILED`.
260+
>
261+
> If you enable `orgCABundle` on a cluster managed by ArgoCD with the example sync policy, you must **narrow the ignore rule** so the CA fields are still reconciled. The broad `/spec/rayClusterConfig` ignore exists only to suppress the autoscaler/runtime mutations KubeRay makes; replace it with targeted pointers (or drop it and ignore only the specific subpaths KubeRay rewrites). After changing it, confirm the head and worker pods actually carry `SSL_CERT_FILE` (`kubectl exec ... -- printenv SSL_CERT_FILE`) rather than trusting the ArgoCD sync status. See [#17](https://github.com/nebari-dev/nebari-rayserve-pack/issues/17) for details.
262+
263+
**Coverage caveat — httpx default `verify=True`:** httpx hardcodes its SSL context to `cafile=certifi.where()`, which means it **ignores** `SSL_CERT_FILE`. Application code making httpx calls that need to traverse a TLS-inspecting proxy must construct an explicit context:
264+
265+
```python
266+
import ssl, httpx
267+
client = httpx.Client(verify=ssl.create_default_context())
268+
# or per-call: httpx.get(url, verify=ssl.create_default_context())
269+
```
270+
271+
`ssl.create_default_context()` with no `cafile=` honors `SSL_CERT_FILE` / `SSL_CERT_DIR` per the standard OpenSSL convention, so it picks up the bundle this chart injected. Other Python HTTP clients (`requests`, `urllib3`, stdlib `urllib`) and most non-Python TLS tooling honor the env vars automatically.
272+
228273
## Architecture
229274

230275
```mermaid

chart/templates/_helpers.tpl

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,89 @@ Ray serve service name - RayService creates a service named <rayservice-name>-se
6262
{{- printf "%s-serve-svc" (include "nebari-rayserve.fullname" .) }}
6363
{{- end }}
6464

65+
{{/*
66+
Whether organization CA bundle injection is enabled.
67+
*/}}
68+
{{- define "nebari-rayserve.orgCABundle.enabled" -}}
69+
{{- if and .Values.orgCABundle .Values.orgCABundle.configMapName -}}
70+
true
71+
{{- end }}
72+
{{- end }}
73+
74+
{{/*
75+
initContainers block for the combined-CA bundle build step. Renders empty
76+
when orgCABundle injection is disabled. Used by both head and worker pod
77+
specs so the SSL_CERT_FILE bundle exists before the main container starts.
78+
*/}}
79+
{{- define "nebari-rayserve.orgCABundle.initContainers" -}}
80+
{{- if include "nebari-rayserve.orgCABundle.enabled" . -}}
81+
- name: build-ca-bundle
82+
image: {{ .Values.orgCABundle.initImage | quote }}
83+
command:
84+
- sh
85+
- -c
86+
- |
87+
cat /etc/ssl/certs/ca-certificates.crt \
88+
/var/local/org-ca/ca.crt > /shared/combined-ca.crt
89+
volumeMounts:
90+
- name: org-ca
91+
mountPath: /var/local/org-ca
92+
readOnly: true
93+
- name: combined-ca
94+
mountPath: /shared
95+
{{- end }}
96+
{{- end }}
97+
98+
{{/*
99+
Volumes block for the org-ca ConfigMap (deployer-supplied) + the shared
100+
emptyDir that the initContainer writes the combined bundle into. Renders
101+
empty when orgCABundle injection is disabled.
102+
*/}}
103+
{{- define "nebari-rayserve.orgCABundle.volumes" -}}
104+
{{- if include "nebari-rayserve.orgCABundle.enabled" . -}}
105+
- name: org-ca
106+
configMap:
107+
name: {{ .Values.orgCABundle.configMapName | quote }}
108+
- name: combined-ca
109+
emptyDir: {}
110+
{{- end }}
111+
{{- end }}
112+
113+
{{/*
114+
Container volumeMounts for the combined-CA bundle (read-only). The main
115+
Ray container mounts only the combined-ca volume — it never sees the raw
116+
org-ca ConfigMap. Renders empty when orgCABundle injection is disabled.
117+
*/}}
118+
{{- define "nebari-rayserve.orgCABundle.volumeMounts" -}}
119+
{{- if include "nebari-rayserve.orgCABundle.enabled" . -}}
120+
- name: combined-ca
121+
mountPath: /shared
122+
readOnly: true
123+
{{- end }}
124+
{{- end }}
125+
126+
{{/*
127+
Container env entries pointing the standard OpenSSL trust-store env vars
128+
at the combined-CA bundle. Anything that honors SSL_CERT_FILE /
129+
REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE picks it up automatically.
130+
GIT_SSL_CAINFO is set separately because git's libcurl ignores the three
131+
above and reads only GIT_SSL_CAINFO — without it `pip install git+https://...`
132+
and other git-over-HTTPS calls fail certificate verification.
133+
Renders empty when orgCABundle injection is disabled.
134+
*/}}
135+
{{- define "nebari-rayserve.orgCABundle.env" -}}
136+
{{- if include "nebari-rayserve.orgCABundle.enabled" . -}}
137+
- name: SSL_CERT_FILE
138+
value: /shared/combined-ca.crt
139+
- name: REQUESTS_CA_BUNDLE
140+
value: /shared/combined-ca.crt
141+
- name: CURL_CA_BUNDLE
142+
value: /shared/combined-ca.crt
143+
- name: GIT_SSL_CAINFO
144+
value: /shared/combined-ca.crt
145+
{{- end }}
146+
{{- end }}
147+
65148
{{/*
66149
Tolerations for a Ray group (head or worker). Pass the group config
67150
(.Values.head or .Values.worker).

chart/templates/rayservice.yaml

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,30 @@ spec:
2727
{{- with .Values.head.runtimeClassName }}
2828
runtimeClassName: {{ . }}
2929
{{- end }}
30+
{{- with (include "nebari-rayserve.orgCABundle.initContainers" .) }}
31+
initContainers:
32+
{{- . | nindent 12 }}
33+
{{- end }}
34+
{{- with (include "nebari-rayserve.orgCABundle.volumes" .) }}
35+
volumes:
36+
{{- . | nindent 12 }}
37+
{{- end }}
3038
{{- with (include "nebari-rayserve.tolerations" .Values.head) }}
3139
tolerations:
3240
{{- . | nindent 12 }}
3341
{{- end }}
3442
containers:
3543
- name: ray-head
3644
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
37-
{{- with .Values.head.containerEnv }}
45+
{{- $headEnv := concat (.Values.head.containerEnv | default list) (fromYamlArray (include "nebari-rayserve.orgCABundle.env" .)) }}
46+
{{- with $headEnv }}
3847
env:
3948
{{- toYaml . | nindent 16 }}
4049
{{- end }}
50+
{{- with (include "nebari-rayserve.orgCABundle.volumeMounts" .) }}
51+
volumeMounts:
52+
{{- . | nindent 16 }}
53+
{{- end }}
4154
resources:
4255
{{- toYaml .Values.head.resources | nindent 16 }}
4356
ports:
@@ -68,17 +81,30 @@ spec:
6881
{{- with .Values.worker.runtimeClassName }}
6982
runtimeClassName: {{ . }}
7083
{{- end }}
84+
{{- with (include "nebari-rayserve.orgCABundle.initContainers" .) }}
85+
initContainers:
86+
{{- . | nindent 14 }}
87+
{{- end }}
88+
{{- with (include "nebari-rayserve.orgCABundle.volumes" .) }}
89+
volumes:
90+
{{- . | nindent 14 }}
91+
{{- end }}
7192
{{- with (include "nebari-rayserve.tolerations" .Values.worker) }}
7293
tolerations:
7394
{{- . | nindent 14 }}
7495
{{- end }}
7596
containers:
7697
- name: ray-worker
7798
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
78-
{{- with .Values.worker.containerEnv }}
99+
{{- $workerEnv := concat (.Values.worker.containerEnv | default list) (fromYamlArray (include "nebari-rayserve.orgCABundle.env" .)) }}
100+
{{- with $workerEnv }}
79101
env:
80102
{{- toYaml . | nindent 18 }}
81103
{{- end }}
104+
{{- with (include "nebari-rayserve.orgCABundle.volumeMounts" .) }}
105+
volumeMounts:
106+
{{- . | nindent 18 }}
107+
{{- end }}
82108
resources:
83109
{{- toYaml .Values.worker.resources | nindent 18 }}
84110
{{- with .Values.worker.readinessProbe }}

chart/values.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,56 @@ serve:
7272
# num_replicas: 2
7373
serveApplications: []
7474

75+
# =============================================================================
76+
# Organization CA Bundle Injection
77+
# =============================================================================
78+
# Mount an organization-managed CA bundle into the Ray head and worker pods
79+
# so outbound HTTPS calls (model registries, dataset URLs, Hugging Face hub,
80+
# S3, etc.) succeed in environments running a TLS-inspecting proxy
81+
# (Netskope, Zscaler, BlueCoat, internal corporate CAs, etc.).
82+
#
83+
# When `configMapName` is set, the chart adds:
84+
# - An initContainer that concatenates the base image's system trust store
85+
# (/etc/ssl/certs/ca-certificates.crt) with the deployer-supplied CA
86+
# (mounted from the ConfigMap at the key `ca.crt`) into
87+
# /shared/combined-ca.crt
88+
# - A shared emptyDir volume that the main container mounts read-only
89+
# - SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE env vars on both
90+
# head and worker pointing at /shared/combined-ca.crt
91+
#
92+
# When `configMapName` is empty (default), nothing is rendered — the
93+
# resulting RayService is byte-identical to the no-CA-injection case.
94+
#
95+
# Why ConfigMap rather than Secret: a CA certificate is public material
96+
# by design (the entire PKI trust model relies on root CAs being widely
97+
# distributed — Mozilla's bundle ships in every browser/OS, corporate
98+
# inspecting proxy roots are pushed to every device that traverses them).
99+
# Kubernetes itself uses a ConfigMap for the cluster's own CA distribution
100+
# (`kube-root-ca.crt`, auto-projected into every namespace). cert-manager's
101+
# trust-manager subproject distributes CA bundles as ConfigMaps via its
102+
# Bundle CR. We follow that precedent here.
103+
#
104+
# Known coverage gap: httpx clients constructed with the default
105+
# `verify=True` hardcode `cafile=certifi.where()` and ignore SSL_CERT_FILE.
106+
# Application code that needs its httpx calls covered must pass
107+
# `verify=ssl.create_default_context()` (or equivalent) explicitly.
108+
#
109+
# ConfigMap shape (created out-of-band, e.g. by your gitops layer):
110+
# apiVersion: v1
111+
# kind: ConfigMap
112+
# metadata:
113+
# name: org-ca-bundle
114+
# data:
115+
# ca.crt: |
116+
# -----BEGIN CERTIFICATE-----
117+
# ...your org CA...
118+
# -----END CERTIFICATE-----
119+
orgCABundle:
120+
configMapName: ""
121+
# Image used by the initContainer that builds the combined CA bundle.
122+
# Only needs `sh` and `cat` — defaults to a tiny Alpine image.
123+
initImage: "alpine:3.20"
124+
75125
# =============================================================================
76126
# Ray Image
77127
# =============================================================================

0 commit comments

Comments
 (0)