{{/* 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" "factor" . 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" "given" "path" . 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" "key" "path" . Returns YAML of dict "value" , 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" "env" . 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 -}}