Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.<component> "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.<component> "root" .)
*/}}
{{- define "zero-cache.envFrom.extra" -}}
{{- with concat (.root.Values.common.extraEnvFrom | default list) (.component.extraEnvFrom | default list) }}
{{- toYaml . }}
{{- end }}
{{- end -}}
7 changes: 7 additions & 0 deletions templates/replication-manager-statefulset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions templates/single-node-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions templates/view-syncer-statefulset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions tests/test_templates.py
Original file line number Diff line number Diff line change
@@ -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", [])]
48 changes: 48 additions & 0 deletions values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading