diff --git a/Chart.yaml b/Chart.yaml index cc27314..ed0ab95 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -17,4 +17,4 @@ sources: - https://github.com/rocicorp/zero - https://github.com/synapdeck/zero-cache-chart type: application -version: 2.1.3 +version: 2.2.0 diff --git a/README.md b/README.md index 72c72fc..82ac3c9 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,44 @@ See [`values.yaml`](values.yaml) for all configurable values with documentation. | `s3.enabled` | Enable S3-backed Litestream replication | `false` | | `viewSyncer.replicas` | Number of view syncer replicas | `2` | | `viewSyncer.autoscaling.enabled` | Enable HPA for view syncers | `false` | +| `common.extraEnv` / `extraEnvFrom` | Escape hatch for unmodelled env vars | `[]` | + +### Extra Environment Variables + +`extraEnv` and `extraEnvFrom` exist for environment variables this chart does +not model, so a consumer is not blocked on a chart release. They are an escape +hatch, not the preferred way to configure zero-cache: anything with real +semantics — a flag, a URL, a tuning knob — should get a typed value, so +consumers get validation and a default instead of a bag of strings. + +`common.extraEnv` applies to every component. Each component +(`singleNode`, `replicationManager`, `viewSyncer`) also takes its own +`extraEnv`, appended after the common entries. + +```yaml +common: + extraEnv: + - name: MY_FLAG + value: "1" # EnvVar.value must be a string — quote numbers +viewSyncer: + extraEnv: + - name: MY_SECRET + valueFrom: + secretKeyRef: {name: my-secret, key: token} + extraEnvFrom: + - configMapRef: {name: my-config} +``` + +Semantics: + +- Entries are appended **after** the chart's own variables, so repeating a name + the chart already sets overrides it — Kubernetes takes the last entry for a + duplicate name. This is supported, not merely tolerated. +- They apply to the **zero-cache container only**. Init containers are a + separate concern and are left untouched. +- Omitted or empty renders exactly as before, so it is a safe no-op on upgrade. +- Entries are passed through verbatim as Kubernetes `EnvVar` / `EnvFromSource` + objects and are not validated by the chart. ## Automated Version Management diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index d6017af..31a0def 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -419,3 +419,27 @@ Advanced/optional environment variables. {{- end }} {{- end }} {{- end -}} + +{{/* +Escape-hatch environment variables for a component's zero-cache container. +Emitted after every chart-modelled variable, so a name repeated here overrides +the chart's value under Kubernetes' last-one-wins rule for a container's env. + +Call with (dict "component" .Values. "root" .) +*/}} +{{- define "zero-cache.env.extra" -}} +{{- with concat (.root.Values.common.extraEnv | default list) (.component.extraEnv | default list) }} +{{- toYaml . }} +{{- end }} +{{- end -}} + +{{/* +Escape-hatch envFrom sources for a component's zero-cache container. + +Call with (dict "component" .Values. "root" .) +*/}} +{{- define "zero-cache.envFrom.extra" -}} +{{- with concat (.root.Values.common.extraEnvFrom | default list) (.component.extraEnvFrom | default list) }} +{{- toYaml . }} +{{- end }} +{{- end -}} diff --git a/templates/replication-manager-statefulset.yaml b/templates/replication-manager-statefulset.yaml index 1469ff7..10a605f 100644 --- a/templates/replication-manager-statefulset.yaml +++ b/templates/replication-manager-statefulset.yaml @@ -86,6 +86,13 @@ spec: {{- include "zero-cache.env.ratelimit" . | nindent 12 }} {{- include "zero-cache.env.mutators" . | nindent 12 }} {{- include "zero-cache.env.advanced" . | nindent 12 }} + {{- with include "zero-cache.env.extra" (dict "component" .Values.replicationManager "root" .) }} + {{- . | nindent 12 }} + {{- end }} + {{- with include "zero-cache.envFrom.extra" (dict "component" .Values.replicationManager "root" .) }} + envFrom: + {{- . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.replicationManager.resources | nindent 12 }} volumeMounts: diff --git a/templates/single-node-deployment.yaml b/templates/single-node-deployment.yaml index 0b2fa29..4a4a9a0 100644 --- a/templates/single-node-deployment.yaml +++ b/templates/single-node-deployment.yaml @@ -86,6 +86,13 @@ spec: {{- include "zero-cache.env.ratelimit" . | nindent 12 }} {{- include "zero-cache.env.mutators" . | nindent 12 }} {{- include "zero-cache.env.advanced" . | nindent 12 }} + {{- with include "zero-cache.env.extra" (dict "component" .Values.singleNode "root" .) }} + {{- . | nindent 12 }} + {{- end }} + {{- with include "zero-cache.envFrom.extra" (dict "component" .Values.singleNode "root" .) }} + envFrom: + {{- . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.singleNode.resources | nindent 12 }} volumeMounts: diff --git a/templates/view-syncer-statefulset.yaml b/templates/view-syncer-statefulset.yaml index 059714f..b1105ae 100644 --- a/templates/view-syncer-statefulset.yaml +++ b/templates/view-syncer-statefulset.yaml @@ -101,6 +101,13 @@ spec: {{- include "zero-cache.env.ratelimit" . | nindent 12 }} {{- include "zero-cache.env.mutators" . | nindent 12 }} {{- include "zero-cache.env.advanced" . | nindent 12 }} + {{- with include "zero-cache.env.extra" (dict "component" .Values.viewSyncer "root" .) }} + {{- . | nindent 12 }} + {{- end }} + {{- with include "zero-cache.envFrom.extra" (dict "component" .Values.viewSyncer "root" .) }} + envFrom: + {{- . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.viewSyncer.resources | nindent 12 }} volumeMounts: diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..024daf7 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,135 @@ +"""Render the chart with helm and assert on the resulting manifests.""" + +import subprocess +from pathlib import Path + +import pytest +import yaml + +CHART = Path(__file__).resolve().parent.parent +UPSTREAM_URL = "common.database.upstream.url.value=postgres://t:t@h/d" + +# Each component's workload kind, name suffix, and the values key holding its +# own extraEnv/extraEnvFrom. +COMPONENTS = [ + ("Deployment", "zero-cache", "singleNode", ["--set", "singleNode.enabled=true"]), + ("StatefulSet", "zero-cache-replication-manager", "replicationManager", []), + ("StatefulSet", "zero-cache-view-syncer", "viewSyncer", []), +] + + +def render(*args: str) -> str: + result = subprocess.run( + ["helm", "template", "z", str(CHART), "--set", UPSTREAM_URL, *args], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def workload(rendered: str, kind: str, name: str) -> dict: + for doc in yaml.safe_load_all(rendered): + if doc and doc.get("kind") == kind and doc["metadata"]["name"].endswith(name): + return doc + raise AssertionError(f"no {kind} ending in {name!r} in rendered output") + + +def main_container(rendered: str, kind: str, name: str) -> dict: + return workload(rendered, kind, name)["spec"]["template"]["spec"]["containers"][0] + + +@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS) +def test_extra_env_empty_is_a_no_op(kind, name, component, extra_args): + """Omitted and explicitly-empty extraEnv render byte-identical output.""" + baseline = render(*extra_args) + explicit = render( + *extra_args, + "--set-json", f'{{"{component}":{{"extraEnv":[],"extraEnvFrom":[]}}}}', + "--set-json", '{"common":{"extraEnv":[],"extraEnvFrom":[]}}', + ) + assert baseline == explicit + assert "envFrom" not in main_container(baseline, kind, name) + + +@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS) +def test_extra_env_appends_in_order(kind, name, component, extra_args): + """Entries land after the chart's own env, common first, in listed order.""" + baseline_env = main_container(render(*extra_args), kind, name)["env"] + env = main_container( + render( + *extra_args, + "--set", "common.extraEnv[0].name=COMMON_ONE", + "--set", "common.extraEnv[0].value=c1", + "--set", f"{component}.extraEnv[0].name=OWN_ONE", + "--set", f"{component}.extraEnv[0].value=o1", + "--set", f"{component}.extraEnv[1].name=OWN_TWO", + "--set", f"{component}.extraEnv[1].value=o2", + ), + kind, + name, + )["env"] + + assert env[: len(baseline_env)] == baseline_env, "chart env must be untouched" + assert env[len(baseline_env):] == [ + {"name": "COMMON_ONE", "value": "c1"}, + {"name": "OWN_ONE", "value": "o1"}, + {"name": "OWN_TWO", "value": "o2"}, + ] + + +@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS) +def test_extra_env_overrides_chart_value_last(kind, name, component, extra_args): + """A duplicate name is appended last, so Kubernetes' last-wins rule applies.""" + env = main_container( + render( + *extra_args, + "--set", f"{component}.extraEnv[0].name=ZERO_LOG_LEVEL", + "--set", f"{component}.extraEnv[0].value=debug", + ), + kind, + name, + ) + entries = [e for e in env["env"] if e["name"] == "ZERO_LOG_LEVEL"] + assert len(entries) == 2 + assert entries[-1]["value"] == "debug" + + +@pytest.mark.parametrize("kind,name,component,extra_args", COMPONENTS) +def test_extra_env_from_sources(kind, name, component, extra_args): + container = main_container( + render( + *extra_args, + "--set", "common.extraEnvFrom[0].configMapRef.name=common-cm", + "--set", f"{component}.extraEnvFrom[0].secretRef.name=own-secret", + ), + kind, + name, + ) + assert container["envFrom"] == [ + {"configMapRef": {"name": "common-cm"}}, + {"secretRef": {"name": "own-secret"}}, + ] + + +def test_extra_env_is_scoped_to_its_own_component(): + """A component's extraEnv does not leak into the other component.""" + rendered = render( + "--set", "viewSyncer.extraEnv[0].name=VS_ONLY", + "--set", "viewSyncer.extraEnv[0].value=v1", + ) + vs = main_container(rendered, "StatefulSet", "zero-cache-view-syncer") + rm = main_container(rendered, "StatefulSet", "zero-cache-replication-manager") + assert "VS_ONLY" in [e["name"] for e in vs["env"]] + assert "VS_ONLY" not in [e["name"] for e in rm["env"]] + + +def test_extra_env_skips_init_containers(): + """extraEnv targets the zero-cache container only, not init containers.""" + rendered = render( + "--set", "common.extraEnv[0].name=COMMON_ONE", + "--set", "common.extraEnv[0].value=c1", + ) + pod = workload(rendered, "StatefulSet", "zero-cache-view-syncer")["spec"]["template"]["spec"] + for init in pod.get("initContainers", []): + assert "COMMON_ONE" not in [e["name"] for e in init.get("env", [])] diff --git a/values.yaml b/values.yaml index 9d6a1de..b6463b0 100644 --- a/values.yaml +++ b/values.yaml @@ -230,6 +230,30 @@ common: # WebSocket compression options (JSON string) websocketCompressionOptions: "" + # Escape hatch for environment variables this chart does not model, applied + # to the zero-cache container of every component. Entries are appended after + # the chart's own variables, so a duplicate name overrides the chart's value. + # Anything with real semantics deserves a typed value instead, so consumers + # get validation and a default rather than a bag of strings. + # Each entry is a Kubernetes EnvVar. + extraEnv: [] + # - name: MY_FLAG + # value: "1" + # - name: MY_SECRET + # valueFrom: + # secretKeyRef: + # name: my-secret + # key: token + + # Bulk-inject variables from ConfigMaps/Secrets into every component's + # zero-cache container. Each entry is a Kubernetes EnvFromSource. Values in + # `env` (chart-set or from extraEnv) take precedence over these. + extraEnvFrom: [] + # - configMapRef: + # name: my-config + # - secretRef: + # name: my-secret + ## Single Node Configuration ## This is a simplified deployment option for development or small deployments singleNode: @@ -282,6 +306,14 @@ singleNode: failureThreshold: 30 successThreshold: 1 + # Extra environment variables for this component's zero-cache container only, + # appended after `common.extraEnv`. See `common.extraEnv` for semantics. + extraEnv: [] + + # Extra envFrom sources for this component's zero-cache container only, + # appended after `common.extraEnvFrom`. + extraEnvFrom: [] + ## Replication Manager Configuration replicationManager: # Resource requests and limits @@ -330,6 +362,14 @@ replicationManager: failureThreshold: 30 successThreshold: 1 + # Extra environment variables for this component's zero-cache container only, + # appended after `common.extraEnv`. See `common.extraEnv` for semantics. + extraEnv: [] + + # Extra envFrom sources for this component's zero-cache container only, + # appended after `common.extraEnvFrom`. + extraEnvFrom: [] + ## View Syncer Configuration viewSyncer: # Number of replicas (horizontally scalable) @@ -418,6 +458,14 @@ viewSyncer: failureThreshold: 30 successThreshold: 1 + # Extra environment variables for this component's zero-cache container only, + # appended after `common.extraEnv`. See `common.extraEnv` for semantics. + extraEnv: [] + + # Extra envFrom sources for this component's zero-cache container only, + # appended after `common.extraEnvFrom`. + extraEnvFrom: [] + ## S3-compatible Storage Configuration s3: # Enable S3 backup with Litestream (strongly recommended for production)