Skip to content

Commit 27ddf2d

Browse files
committed
feat(chart): add extraEnv and extraEnvFrom escape hatches
Consumers had no way to set an environment variable the chart does not model, and consumers rendering through nixidy cannot patch a list-typed field onto a helm-rendered workload, so every unmodelled variable required a chart release. `common.extraEnv` applies to all components; `singleNode`, `replicationManager` and `viewSyncer` each take their own, appended after the common entries. Both land after the chart's own variables, so repeating a name the chart sets overrides it under Kubernetes' last-wins rule. They apply to the zero-cache container only, and render byte-identically to before when empty.
1 parent 11caf18 commit 27ddf2d

8 files changed

Lines changed: 267 additions & 1 deletion

Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ sources:
1717
- https://github.com/rocicorp/zero
1818
- https://github.com/synapdeck/zero-cache-chart
1919
type: application
20-
version: 2.1.3
20+
version: 2.2.0

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,44 @@ See [`values.yaml`](values.yaml) for all configurable values with documentation.
5757
| `s3.enabled` | Enable S3-backed Litestream replication | `false` |
5858
| `viewSyncer.replicas` | Number of view syncer replicas | `2` |
5959
| `viewSyncer.autoscaling.enabled` | Enable HPA for view syncers | `false` |
60+
| `common.extraEnv` / `extraEnvFrom` | Escape hatch for unmodelled env vars | `[]` |
61+
62+
### Extra Environment Variables
63+
64+
`extraEnv` and `extraEnvFrom` exist for environment variables this chart does
65+
not model, so a consumer is not blocked on a chart release. They are an escape
66+
hatch, not the preferred way to configure zero-cache: anything with real
67+
semantics — a flag, a URL, a tuning knob — should get a typed value, so
68+
consumers get validation and a default instead of a bag of strings.
69+
70+
`common.extraEnv` applies to every component. Each component
71+
(`singleNode`, `replicationManager`, `viewSyncer`) also takes its own
72+
`extraEnv`, appended after the common entries.
73+
74+
```yaml
75+
common:
76+
extraEnv:
77+
- name: MY_FLAG
78+
value: "1" # EnvVar.value must be a string — quote numbers
79+
viewSyncer:
80+
extraEnv:
81+
- name: MY_SECRET
82+
valueFrom:
83+
secretKeyRef: {name: my-secret, key: token}
84+
extraEnvFrom:
85+
- configMapRef: {name: my-config}
86+
```
87+
88+
Semantics:
89+
90+
- Entries are appended **after** the chart's own variables, so repeating a name
91+
the chart already sets overrides it — Kubernetes takes the last entry for a
92+
duplicate name. This is supported, not merely tolerated.
93+
- They apply to the **zero-cache container only**. Init containers are a
94+
separate concern and are left untouched.
95+
- Omitted or empty renders exactly as before, so it is a safe no-op on upgrade.
96+
- Entries are passed through verbatim as Kubernetes `EnvVar` / `EnvFromSource`
97+
objects and are not validated by the chart.
6098

6199
## Automated Version Management
62100

templates/_helpers.tpl

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,27 @@ Advanced/optional environment variables.
419419
{{- end }}
420420
{{- end }}
421421
{{- end -}}
422+
423+
{{/*
424+
Escape-hatch environment variables for a component's zero-cache container.
425+
Emitted after every chart-modelled variable, so a name repeated here overrides
426+
the chart's value under Kubernetes' last-one-wins rule for a container's env.
427+
428+
Call with (dict "component" .Values.<component> "root" .)
429+
*/}}
430+
{{- define "zero-cache.env.extra" -}}
431+
{{- with concat (.root.Values.common.extraEnv | default list) (.component.extraEnv | default list) }}
432+
{{- toYaml . }}
433+
{{- end }}
434+
{{- end -}}
435+
436+
{{/*
437+
Escape-hatch envFrom sources for a component's zero-cache container.
438+
439+
Call with (dict "component" .Values.<component> "root" .)
440+
*/}}
441+
{{- define "zero-cache.envFrom.extra" -}}
442+
{{- with concat (.root.Values.common.extraEnvFrom | default list) (.component.extraEnvFrom | default list) }}
443+
{{- toYaml . }}
444+
{{- end }}
445+
{{- end -}}

templates/replication-manager-statefulset.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ spec:
8686
{{- include "zero-cache.env.ratelimit" . | nindent 12 }}
8787
{{- include "zero-cache.env.mutators" . | nindent 12 }}
8888
{{- include "zero-cache.env.advanced" . | nindent 12 }}
89+
{{- with include "zero-cache.env.extra" (dict "component" .Values.replicationManager "root" .) }}
90+
{{- . | nindent 12 }}
91+
{{- end }}
92+
{{- with include "zero-cache.envFrom.extra" (dict "component" .Values.replicationManager "root" .) }}
93+
envFrom:
94+
{{- . | nindent 12 }}
95+
{{- end }}
8996
resources:
9097
{{- toYaml .Values.replicationManager.resources | nindent 12 }}
9198
volumeMounts:

templates/single-node-deployment.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ spec:
8686
{{- include "zero-cache.env.ratelimit" . | nindent 12 }}
8787
{{- include "zero-cache.env.mutators" . | nindent 12 }}
8888
{{- include "zero-cache.env.advanced" . | nindent 12 }}
89+
{{- with include "zero-cache.env.extra" (dict "component" .Values.singleNode "root" .) }}
90+
{{- . | nindent 12 }}
91+
{{- end }}
92+
{{- with include "zero-cache.envFrom.extra" (dict "component" .Values.singleNode "root" .) }}
93+
envFrom:
94+
{{- . | nindent 12 }}
95+
{{- end }}
8996
resources:
9097
{{- toYaml .Values.singleNode.resources | nindent 12 }}
9198
volumeMounts:

templates/view-syncer-statefulset.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ spec:
101101
{{- include "zero-cache.env.ratelimit" . | nindent 12 }}
102102
{{- include "zero-cache.env.mutators" . | nindent 12 }}
103103
{{- include "zero-cache.env.advanced" . | nindent 12 }}
104+
{{- with include "zero-cache.env.extra" (dict "component" .Values.viewSyncer "root" .) }}
105+
{{- . | nindent 12 }}
106+
{{- end }}
107+
{{- with include "zero-cache.envFrom.extra" (dict "component" .Values.viewSyncer "root" .) }}
108+
envFrom:
109+
{{- . | nindent 12 }}
110+
{{- end }}
104111
resources:
105112
{{- toYaml .Values.viewSyncer.resources | nindent 12 }}
106113
volumeMounts:

tests/test_templates.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Render the chart with helm and assert on the resulting manifests."""
2+
3+
import subprocess
4+
from pathlib import Path
5+
6+
import pytest
7+
import yaml
8+
9+
CHART = Path(__file__).resolve().parent.parent
10+
UPSTREAM_URL = "common.database.upstream.url.value=postgres://t:t@h/d"
11+
12+
# Each component's workload kind, name suffix, and the values key holding its
13+
# own extraEnv/extraEnvFrom.
14+
COMPONENTS = [
15+
("Deployment", "zero-cache", "singleNode", ["--set", "singleNode.enabled=true"]),
16+
("StatefulSet", "zero-cache-replication-manager", "replicationManager", []),
17+
("StatefulSet", "zero-cache-view-syncer", "viewSyncer", []),
18+
]
19+
20+
21+
def render(*args: str) -> str:
22+
result = subprocess.run(
23+
["helm", "template", "z", str(CHART), "--set", UPSTREAM_URL, *args],
24+
capture_output=True,
25+
text=True,
26+
check=True,
27+
)
28+
return result.stdout
29+
30+
31+
def workload(rendered: str, kind: str, name: str) -> dict:
32+
for doc in yaml.safe_load_all(rendered):
33+
if doc and doc.get("kind") == kind and doc["metadata"]["name"].endswith(name):
34+
return doc
35+
raise AssertionError(f"no {kind} ending in {name!r} in rendered output")
36+
37+
38+
def main_container(rendered: str, kind: str, name: str) -> dict:
39+
return workload(rendered, kind, name)["spec"]["template"]["spec"]["containers"][0]
40+
41+
42+
@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS)
43+
def test_extra_env_empty_is_a_no_op(kind, name, component, extra_args):
44+
"""Omitted and explicitly-empty extraEnv render byte-identical output."""
45+
baseline = render(*extra_args)
46+
explicit = render(
47+
*extra_args,
48+
"--set-json", f'{{"{component}":{{"extraEnv":[],"extraEnvFrom":[]}}}}',
49+
"--set-json", '{"common":{"extraEnv":[],"extraEnvFrom":[]}}',
50+
)
51+
assert baseline == explicit
52+
assert "envFrom" not in main_container(baseline, kind, name)
53+
54+
55+
@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS)
56+
def test_extra_env_appends_in_order(kind, name, component, extra_args):
57+
"""Entries land after the chart's own env, common first, in listed order."""
58+
baseline_env = main_container(render(*extra_args), kind, name)["env"]
59+
env = main_container(
60+
render(
61+
*extra_args,
62+
"--set", "common.extraEnv[0].name=COMMON_ONE",
63+
"--set", "common.extraEnv[0].value=c1",
64+
"--set", f"{component}.extraEnv[0].name=OWN_ONE",
65+
"--set", f"{component}.extraEnv[0].value=o1",
66+
"--set", f"{component}.extraEnv[1].name=OWN_TWO",
67+
"--set", f"{component}.extraEnv[1].value=o2",
68+
),
69+
kind,
70+
name,
71+
)["env"]
72+
73+
assert env[: len(baseline_env)] == baseline_env, "chart env must be untouched"
74+
assert env[len(baseline_env):] == [
75+
{"name": "COMMON_ONE", "value": "c1"},
76+
{"name": "OWN_ONE", "value": "o1"},
77+
{"name": "OWN_TWO", "value": "o2"},
78+
]
79+
80+
81+
@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS)
82+
def test_extra_env_overrides_chart_value_last(kind, name, component, extra_args):
83+
"""A duplicate name is appended last, so Kubernetes' last-wins rule applies."""
84+
env = main_container(
85+
render(
86+
*extra_args,
87+
"--set", f"{component}.extraEnv[0].name=ZERO_LOG_LEVEL",
88+
"--set", f"{component}.extraEnv[0].value=debug",
89+
),
90+
kind,
91+
name,
92+
)
93+
entries = [e for e in env["env"] if e["name"] == "ZERO_LOG_LEVEL"]
94+
assert len(entries) == 2
95+
assert entries[-1]["value"] == "debug"
96+
97+
98+
@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS)
99+
def test_extra_env_from_sources(kind, name, component, extra_args):
100+
container = main_container(
101+
render(
102+
*extra_args,
103+
"--set", "common.extraEnvFrom[0].configMapRef.name=common-cm",
104+
"--set", f"{component}.extraEnvFrom[0].secretRef.name=own-secret",
105+
),
106+
kind,
107+
name,
108+
)
109+
assert container["envFrom"] == [
110+
{"configMapRef": {"name": "common-cm"}},
111+
{"secretRef": {"name": "own-secret"}},
112+
]
113+
114+
115+
def test_extra_env_is_scoped_to_its_own_component():
116+
"""A component's extraEnv does not leak into the other component."""
117+
rendered = render(
118+
"--set", "viewSyncer.extraEnv[0].name=VS_ONLY",
119+
"--set", "viewSyncer.extraEnv[0].value=v1",
120+
)
121+
vs = main_container(rendered, "StatefulSet", "zero-cache-view-syncer")
122+
rm = main_container(rendered, "StatefulSet", "zero-cache-replication-manager")
123+
assert "VS_ONLY" in [e["name"] for e in vs["env"]]
124+
assert "VS_ONLY" not in [e["name"] for e in rm["env"]]
125+
126+
127+
def test_extra_env_skips_init_containers():
128+
"""extraEnv targets the zero-cache container only, not init containers."""
129+
rendered = render(
130+
"--set", "common.extraEnv[0].name=COMMON_ONE",
131+
"--set", "common.extraEnv[0].value=c1",
132+
)
133+
pod = workload(rendered, "StatefulSet", "zero-cache-view-syncer")["spec"]["template"]["spec"]
134+
for init in pod.get("initContainers", []):
135+
assert "COMMON_ONE" not in [e["name"] for e in init.get("env", [])]

values.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,30 @@ common:
230230
# WebSocket compression options (JSON string)
231231
websocketCompressionOptions: ""
232232

233+
# Escape hatch for environment variables this chart does not model, applied
234+
# to the zero-cache container of every component. Entries are appended after
235+
# the chart's own variables, so a duplicate name overrides the chart's value.
236+
# Anything with real semantics deserves a typed value instead, so consumers
237+
# get validation and a default rather than a bag of strings.
238+
# Each entry is a Kubernetes EnvVar.
239+
extraEnv: []
240+
# - name: MY_FLAG
241+
# value: "1"
242+
# - name: MY_SECRET
243+
# valueFrom:
244+
# secretKeyRef:
245+
# name: my-secret
246+
# key: token
247+
248+
# Bulk-inject variables from ConfigMaps/Secrets into every component's
249+
# zero-cache container. Each entry is a Kubernetes EnvFromSource. Values in
250+
# `env` (chart-set or from extraEnv) take precedence over these.
251+
extraEnvFrom: []
252+
# - configMapRef:
253+
# name: my-config
254+
# - secretRef:
255+
# name: my-secret
256+
233257
## Single Node Configuration
234258
## This is a simplified deployment option for development or small deployments
235259
singleNode:
@@ -282,6 +306,14 @@ singleNode:
282306
failureThreshold: 30
283307
successThreshold: 1
284308

309+
# Extra environment variables for this component's zero-cache container only,
310+
# appended after `common.extraEnv`. See `common.extraEnv` for semantics.
311+
extraEnv: []
312+
313+
# Extra envFrom sources for this component's zero-cache container only,
314+
# appended after `common.extraEnvFrom`.
315+
extraEnvFrom: []
316+
285317
## Replication Manager Configuration
286318
replicationManager:
287319
# Resource requests and limits
@@ -330,6 +362,14 @@ replicationManager:
330362
failureThreshold: 30
331363
successThreshold: 1
332364

365+
# Extra environment variables for this component's zero-cache container only,
366+
# appended after `common.extraEnv`. See `common.extraEnv` for semantics.
367+
extraEnv: []
368+
369+
# Extra envFrom sources for this component's zero-cache container only,
370+
# appended after `common.extraEnvFrom`.
371+
extraEnvFrom: []
372+
333373
## View Syncer Configuration
334374
viewSyncer:
335375
# Number of replicas (horizontally scalable)
@@ -418,6 +458,14 @@ viewSyncer:
418458
failureThreshold: 30
419459
successThreshold: 1
420460

461+
# Extra environment variables for this component's zero-cache container only,
462+
# appended after `common.extraEnv`. See `common.extraEnv` for semantics.
463+
extraEnv: []
464+
465+
# Extra envFrom sources for this component's zero-cache container only,
466+
# appended after `common.extraEnvFrom`.
467+
extraEnvFrom: []
468+
421469
## S3-compatible Storage Configuration
422470
s3:
423471
# Enable S3 backup with Litestream (strongly recommended for production)

0 commit comments

Comments
 (0)