Files
helm-charts/valkey/templates/_helpers.tpl
jklein 813c796a7b
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 11s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m22s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Valkey Helm Chart / Publish Valkey Helm Chart (push) Successful in 7s
feat(valkey): one Valkey instance per application (1.0.0+up9.1.2.alpine)
A new chart for a single Valkey instance per application, replacing the
shared cluster-redis whose separation rests on one ACL file in one
1Password field - a construction in which every change touches six
applications and which cost a month of Paperless standstill.

Shape: StatefulSet with one replica, ClusterIP Service, no cluster mode,
no Ingress and no value to add one.

The password arrives as a finished configuration line ("requirepass
<secret>") in the 1Password field auth-conf, is mounted as a file and
included by the main configuration - never in argv, never in the
environment. The include is rendered last, so nothing in extraConfig can
override it. Because a misspelled or empty field would leave an open
server that looks perfectly healthy, an init container greps the mounted
file for the line and refuses to start the pod without it; it never
prints the file.

maxmemory is derived from the computed memory limit (maxmemoryFactor,
default 0.75) and a configured value above that limit aborts the render:
otherwise the kubelet's OOM kill decides what the eviction policy was
configured to decide. Persistence and eviction are documented as pairs -
queue (PVC + noeviction, the default) and cache (emptyDir +
allkeys-lru) - and the combination "no persistence + noeviction" carries
a warning in values.yaml and in NOTES.txt.

Probes: valkey-cli ping as an exec probe, authenticated through
VALKEYCLI_AUTH read out of the mounted file, with "-e" and an assertion
on the answer PONG. Without -e valkey-cli exits 0 on an error reply
(valkey 9.1.2, src/valkey-cli.c: the config.set_errcode branch in
cliReadReply), which would make the probe unable to fail.

Hardening: uid 999 / gid 1000 - the image's own valkey user - with
fsGroup matching, runAsNonRoot, readOnlyRootFilesystem, all capabilities
dropped and seccompProfile RuntimeDefault in the pod context so the init
container is covered too. /data is the only writable path.

NetworkPolicy on by default (first chart in this repo with one), with a
switch and a list of additional namespaces; values.yaml names that it is
only a boundary where the CNI enforces policies.

Wiring: publish-valkey.yml, the chart-name choice list, ci/valkey
fixtures for the persistent variant, a plain docker-versioning Renovate
block, README chart row.
2026-09-27 11:24:55 +00:00

435 lines
18 KiB
Smarty

{{/*
Render the body of a container "resources:" block from a values dict of the form
resources:
limitFactor: 3 # optional, default 3
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
limits: { ... } # optional, every entry optional
Rules (variant B, see issue #19):
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
to the Kubernetes key "ephemeral-storage").
- an explicitly set limit always wins.
- a missing memory / ephemeral-storage limit is computed as
limitFactor x the corresponding request.
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
unlimited; CPU is compressible, so this cannot kill the workload).
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. template-chart holds the reference implementation, so a
fix belongs there as well and not here alone.
*/}}
{{- define "valkey.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "valkey.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "valkey.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "valkey.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "valkey.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
results are printed without a decimal point.
*/}}
{{- define "valkey.multiplyQuantity" -}}
{{- $quantity := include "valkey.formatNumber" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%s%s" (include "valkey.formatNumber" $product) $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render a number as a plain decimal string, never in exponential notation.
Why this exists (issue #157): the null-safe fallback helpers pass values
through fromYaml, which parses every number as float64. Go's "%v" switches
float64 to exponential notation from about 1e6 upwards, so a bare byte count
like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the
render aborted. Strings ("512Mi") are passed through untouched.
*/}}
{{- define "valkey.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
Render the replica count for a Deployment/StatefulSet.
Input: dict
"scaleDown" the chart-global .Values.scaleDown flag (may be nil)
"replicas" the workload's replicas value (may be nil)
Rules (issue #25):
- scaleDown: true takes precedence and always yields 0.
- otherwise an explicitly set replicas value is used as given, including an
explicit 0 — Helm's "default" would swallow 0, hence the explicit
kindIs "invalid" nil check.
- a missing replicas value defaults to 1.
*/}}
{{- define "valkey.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them.
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
values path used in error messages>. Output: YAML of the merged dict (consume
it with fromYaml).
Why this exists (issues #61 and #99): Helm merges values maps recursively, but
an override key written without children — e.g. a leftover
securityContext:
after deleting its last remaining sub-key — is YAML null. Where the chart
defines a default, Helm treats that null as a deletion of the default; the
template then renders "securityContext: null": no readOnlyRootFilesystem, no
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
and reports healthy while being unhardened, so nothing points at the mistake.
The same shape erases a probe (it disappears) or the resources block (no
requests, no computed limits). This helper defines the opposite semantics: an
empty block means "chart defaults apply".
NOTE: like the helpers above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix, because sharing
it would mean a library chart and this chart takes no dependency (README,
guideline 14).
*/}}
{{- define "valkey.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
{{- $given := .given -}}
{{- $path := .path | default "value" -}}
{{- if kindIs "invalid" $given -}}
{{- $given = dict -}}
{{- end -}}
{{- if not (kindIs "map" $given) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
{{- end -}}
{{- $out := deepCopy $defaults -}}
{{- range $key, $value := $given -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $default := index $defaults $key -}}
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
{{- $_ := set $out $key (fromYaml (include "valkey.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
{{- else if kindIs "map" $default -}}
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
{{- else -}}
{{- $_ := set $out $key $value -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Pick one sub-block out of a values block that may itself be null.
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
non-mapping block with a message that names the values path. Needed because
".Values.valkey.securityContext" can itself be erased, and indexing into nil
inside a template is not an error but silently yields nothing.
*/}}
{{- define "valkey.subBlock" -}}
{{- $block := .block -}}
{{- if kindIs "invalid" $block -}}
{{- $block = dict -}}
{{- end -}}
{{- if not (kindIs "map" $block) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
{{- end -}}
{{- toYaml (dict "value" (index $block .key)) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
The defaults below MUST stay in sync with the corresponding blocks in
values.yaml, which stays the documented, user-facing place for them; this copy
is only the fallback for the case where an override has erased the block. Every
reason behind a value is written down there.
Two rules these fallbacks exist to honour (README, "Chart style guidelines"):
- Reproduce THIS chart's defaults, not the repo standard. The uid here is 999
and the gid 1000, because that is the "valkey" user of the official image
(9.1/alpine/Dockerfile: "addgroup -S -g 1000 valkey; adduser -S -G valkey
-u 999 valkey"). Restoring the repo's usual 20000/1000 would leave the
process unable to write its own data directory, and fsGroup has to keep
matching runAsGroup or the PVC is not writable at all.
- Carry the probe's CONTEXT, not just its timings. All three probes here are
exec probes, so the command IS the probe - a fallback that lost it would
leave the workload with no working check at all.
*/}}
{{- define "valkey.podSecurityContext" -}}
{{- $given := (fromYaml (include "valkey.subBlock" (dict "block" . "key" "pod" "path" "valkey.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 999
"runAsGroup" 1000
"fsGroup" 1000
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" $given "path" "valkey.securityContext.pod") -}}
{{- end -}}
{{- define "valkey.containerSecurityContext" -}}
{{- $given := (fromYaml (include "valkey.subBlock" (dict "block" . "key" "container" "path" "valkey.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" $given "path" "valkey.securityContext.container") -}}
{{- end -}}
{{/*
The three probes. All of them run the same script from the chart's ConfigMap,
which authenticates with the password from the mounted auth file and asserts the
answer PONG - see templates/valkey.configmap.yaml for why "-e" and
VALKEYCLI_AUTH are what turn this from a formality into a check.
*/}}
{{- define "valkey.probeCommand" -}}
{{- list "/bin/sh" "/etc/valkey/ping.sh" | toJson -}}
{{- end -}}
{{- define "valkey.livenessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "valkey.probeCommand" . | fromJsonArray))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.livenessProbe") -}}
{{- end -}}
{{- define "valkey.readinessProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "valkey.probeCommand" . | fromJsonArray))
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.readinessProbe") -}}
{{- end -}}
{{- define "valkey.startupProbe" -}}
{{- $defaults := dict
"exec" (dict "command" (include "valkey.probeCommand" . | fromJsonArray))
"periodSeconds" 5
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.startupProbe") -}}
{{- end -}}
{{/*
The merged resources block: the chart's defaults with the user's overrides on
top, null-safe. Everything that needs the effective numbers goes through this,
so the memory limit the configuration derives maxmemory from is the SAME number
the container is given - not a second calculation that can drift from it.
*/}}
{{- define "valkey.mergedResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" 0.1 "memory" "256Mi" "ephemeral-storage" "64Mi") -}}
{{- include "valkey.defaultedDict" (dict "defaults" $defaults "given" . "path" "valkey.resources") -}}
{{- end -}}
{{- define "valkey.effectiveResources" -}}
{{- include "valkey.resources" (fromYaml (include "valkey.mergedResources" .)) -}}
{{- end -}}
{{/*
Convert a Kubernetes memory quantity to a plain byte count.
Input: a quantity (string or number). Output: the byte count as an integer
string. Needed because Valkey's maxmemory is compared against, and derived
from, the container's memory limit - and "576Mi" cannot be compared with
"1Gi" as text.
The "m" (milli) suffix is rejected rather than converted: a milli-byte memory
quantity is legal Kubernetes and meaningless here, and silently rounding it to
zero would render "maxmemory 0", which in Valkey means UNLIMITED - the exact
opposite of what this chart computes a number for.
*/}}
{{- define "valkey.quantityBytes" -}}
{{- $quantity := include "valkey.formatNumber" . -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse memory quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- $factors := dict
"" 1.0
"k" 1000.0 "M" 1000000.0 "G" 1000000000.0 "T" 1000000000000.0
"Ki" 1024.0 "Mi" 1048576.0 "Gi" 1073741824.0 "Ti" 1099511627776.0 -}}
{{- if not (hasKey $factors $suffix) -}}
{{- fail (printf "unsupported memory quantity suffix %q in %q (use one of k, M, G, T, Ki, Mi, Gi, Ti, or plain bytes)" $suffix $quantity) -}}
{{- end -}}
{{- printf "%d" (int64 (mulf (float64 $number) (index $factors $suffix))) -}}
{{- end -}}
{{/*
The container's effective memory limit, as a byte count.
It is read back out of the RENDERED resources block on purpose: an explicitly
set limits.memory wins there, and a missing one is limitFactor x the request -
so this cannot disagree with what the container actually gets.
*/}}
{{- define "valkey.memoryLimitBytes" -}}
{{- $block := fromYaml (include "valkey.effectiveResources" .) -}}
{{- $limit := "" -}}
{{- if $block.limits -}}
{{- $limit = index $block.limits "memory" -}}
{{- end -}}
{{- if kindIs "invalid" $limit -}}
{{- fail "valkey: no memory limit could be determined. valkey.resources needs either requests.memory (the limit is then limitFactor x the request) or an explicit limits.memory - maxmemory is derived from it, and an unbounded instance would be OOM-killed instead of evicting." -}}
{{- end -}}
{{- include "valkey.quantityBytes" $limit -}}
{{- end -}}
{{/*
Valkey's maxmemory for this release, as a byte count.
Input: the .Values.valkey block.
- valkey.maxmemory set -> used as given, AFTER checking it against the
container's memory limit. A value above the limit ABORTS the render: it
would hand the decision "what happens when memory runs out" from the
eviction policy to the kubelet's OOM killer, and that arrives as a
restarted pod with no explanation. Not clamped, because a clamp would mean
the running instance quietly disagrees with the values file.
- otherwise -> maxmemoryFactor x the limit, floored. The
remaining fraction is not slack: client output buffers, the AOF buffer and
above all the copy-on-write pages of the fork() that snapshots or rewrites
the AOF live outside maxmemory.
*/}}
{{- define "valkey.maxmemoryBytes" -}}
{{- $limitBytes := int64 (include "valkey.memoryLimitBytes" .resources) -}}
{{- if and (not (kindIs "invalid" .maxmemory)) (ne (printf "%v" .maxmemory) "") -}}
{{- $wanted := int64 (include "valkey.quantityBytes" .maxmemory) -}}
{{- if le $wanted (int64 0) -}}
{{- fail (printf "valkey.maxmemory is %v, which converts to %d bytes. In Valkey, maxmemory 0 means UNLIMITED, so this would switch off the very bound the chart derives - leave valkey.maxmemory unset to use maxmemoryFactor x the memory limit instead." .maxmemory $wanted) -}}
{{- end -}}
{{- if gt $wanted $limitBytes -}}
{{- fail (printf "valkey.maxmemory is %v (%d bytes) and thus ABOVE the container's memory limit (%d bytes). Valkey would keep accepting data until the kubelet OOM-kills the pod, which is what the eviction policy exists to prevent. Lower valkey.maxmemory, or raise valkey.resources.requests.memory / limitFactor / limits.memory." .maxmemory $wanted $limitBytes) -}}
{{- end -}}
{{- printf "%d" $wanted -}}
{{- else -}}
{{- $factor := 0.75 -}}
{{- if not (kindIs "invalid" .maxmemoryFactor) -}}
{{- $factor = float64 .maxmemoryFactor -}}
{{- end -}}
{{- $derived := int64 (floor (mulf (float64 $limitBytes) $factor)) -}}
{{- if le $derived (int64 0) -}}
{{- fail (printf "valkey.maxmemoryFactor %v applied to the memory limit (%d bytes) yields %d bytes. Valkey reads maxmemory 0 as UNLIMITED, so this would silently remove the bound." .maxmemoryFactor $limitBytes $derived) -}}
{{- end -}}
{{- printf "%d" $derived -}}
{{- end -}}
{{- end -}}
{{/*
Render a byte count as a human-readable size, for NOTES.txt and comments only.
Never used where an exact value matters.
*/}}
{{- define "valkey.humanBytes" -}}
{{- $bytes := float64 . -}}
{{- if ge $bytes 1073741824.0 -}}
{{- printf "%.2fGi" (divf $bytes 1073741824.0) -}}
{{- else if ge $bytes 1048576.0 -}}
{{- printf "%.2fMi" (divf $bytes 1048576.0) -}}
{{- else if ge $bytes 1024.0 -}}
{{- printf "%.2fKi" (divf $bytes 1024.0) -}}
{{- else -}}
{{- printf "%d bytes" (int64 $bytes) -}}
{{- end -}}
{{- end -}}
{{/*
Additive environment entries for the Valkey container.
Input: dict "root" <root context> "env" <the env list>.
Entries are rendered through tpl, so a value may contain Helm templating, and
the value, secretKeyRef and configMapKeyRef forms are supported.
*/}}
{{- define "valkey.env" -}}
{{- $root := .root -}}
{{- range $value := .env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "6379" or "true" renders
# unquoted as a YAML number/bool, and the API server then rejects the whole
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
# type string".
value: {{ tpl $value.value $root | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end -}}