feat(valkey): one Valkey instance per application (1.0.0+up9.1.2.alpine)
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

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.
This commit was merged in pull request #53.
This commit is contained in:
jklein committed 2026-09-27 11:24:55 +00:00
1 parent e0b552d93e
commit 813c796a7b
19 files changed
+1687

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+33
View File
@@ -0,0 +1,33 @@
apiVersion: v2
name: valkey
description: A Helm chart to deploy a single Valkey instance (cache or queue backend) for one application on Kubernetes
icon: https://avatars.githubusercontent.com/u/164458127
type: application
# appVersion is the exact image tag, "9.1.2-alpine" - the version AND the base
# flavour, so the "+up" suffix carries both with the dash normalized to a dot.
#
# Why "-alpine" and not the fully numbered "-alpine3.24" (Gitea #46, third plan
# comment): neither tag is immutable. Upstream rebuilds the whole tag matrix
# periodically, and the build timestamps prove it - "9.1-alpine" and
# "9.0.6-alpine3.24" were pushed within minutes of each other on 2026-09-21
# although 9.0.6 is the older release line. Only a digest is immutable. So the
# choice was never "immutable vs. moving" but between two equally moving tags
# with different Renovate behaviour, and "-alpine" is the better one: docker
# versioning splits at the first dash, reads 9.1.2 as the version and "alpine"
# as the compatibility suffix, so the suffix stays constant forever and the
# update path can never freeze. With "-alpine3.24" it would: upstream builds
# exactly one Alpine base per Valkey release, so the next base arrives as
# "9.1.3-alpine3.25" - a candidate a suffix-bound rule never offers, and
# "no update" looks identical to a working rule.
#
# The price, named rather than discovered: a rebuild of 9.1.2-alpine changes the
# running image with no change in this repository and nothing reports it. The
# remedy if that ever matters is pinDigests for this one image - see the valkey
# block in renovate.json5.
version: 1.0.0+up9.1.2.alpine
appVersion: "9.1.2-alpine"
maintainers:
- name: jklein
+160
View File
@@ -0,0 +1,160 @@
{{- $replicas := include "valkey.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.valkey.replicas) -}}
{{- $limitBytes := include "valkey.memoryLimitBytes" .Values.valkey.resources -}}
{{- $maxmemory := include "valkey.maxmemoryBytes" (dict "resources" .Values.valkey.resources "maxmemory" .Values.valkey.maxmemory "maxmemoryFactor" .Values.valkey.maxmemoryFactor) -}}
Valkey {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
ACCESS — IN-CLUSTER ONLY
{{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }}.svc.cluster.local:6379
There is no Ingress and no value to add one: this is one application's cache
or queue backend, and its only authentication is the password. Applications
address it with a redis:// URL (Valkey speaks the same protocol, and every
client library still calls the scheme redis://):
redis://:<password>@{{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }}.svc.cluster.local:6379/0
The password belongs in the CONSUMING application's own vault item, where its
connection URL lives. Nothing here writes it there.
Check by hand from inside the namespace, with the password out of the pod's own
mounted file so it never reaches your shell history:
kubectl -n {{ .Release.Namespace }} exec {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0 -- /bin/sh /etc/valkey/ping.sh && echo OK
THE 1PASSWORD ITEM MUST CARRY ONE FIELD, AND ITS NAME IS PART OF THE CHART
secrets.authSecretRef points at
{{ .Values.secrets.authSecretRef }}
The 1Password operator materializes that item as the Kubernetes Secret
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per field of the item. This chart
reads exactly one field:
auth-conf a COMPLETE Valkey configuration line, not the bare
password:
requirepass <the password>
A differently named field produces no such key, and the pod then stays in
ContainerCreating with "couldn't find key auth-conf" in its events.
Why a whole configuration line instead of the password: the file is mounted and
"include"d by the configuration, so the password never appears in the process
list, in an environment variable, or in anything Helm renders. The cost is that
a typo in that line would be a Valkey WITHOUT a password — running, healthy,
and reachable from the whole pod network, because the official image compiles
protected-mode off. An init container therefore checks the mounted file for a
"requirepass " line before the server starts and refuses to let the pod run
otherwise. If the pod is stuck in Init:Error or Init:CrashLoopBackOff, read it:
kubectl -n {{ .Release.Namespace }} logs {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0 -c {{ .Release.Name }}-{{ .Chart.Name }}-check-auth
It never prints the field's contents.
Watch the materialization with
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ .Release.Name }}-{{ .Chart.Name }}-secret
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
THIS RELEASE IS CONFIGURED AS A {{ if .Values.persistence.enabled }}{{ if eq .Values.valkey.maxmemoryPolicy "noeviction" }}QUEUE{{ else }}PERSISTENT CACHE{{ end }}{{ else }}{{ if eq .Values.valkey.maxmemoryPolicy "noeviction" }}VOLATILE STORE — PROBABLY BY MISTAKE{{ else }}CACHE{{ end }}{{ end }}
persistence.enabled: {{ .Values.persistence.enabled }}
valkey.maxmemoryPolicy: {{ .Values.valkey.maxmemoryPolicy }}
memory limit (computed): {{ include "valkey.humanBytes" $limitBytes }} ({{ $limitBytes }} bytes)
maxmemory: {{ include "valkey.humanBytes" $maxmemory }} ({{ $maxmemory }} bytes){{ if kindIs "invalid" .Values.valkey.maxmemory }}, derived as maxmemoryFactor {{ .Values.valkey.maxmemoryFactor | default 0.75 }} x the limit{{ else }}, set explicitly in values and checked against the limit{{ end }}
{{ if .Values.persistence.enabled }}
{{- if eq .Values.valkey.maxmemoryPolicy "noeviction" }}
The combination this chart is built for. When memory runs out Valkey REFUSES
WRITES instead of discarding keys, so a full queue is an error in the
application rather than jobs that quietly disappeared.
{{- else }}
Persistence with an evicting policy: the data survives a restart but not
memory pressure. Fine for a cache that is expensive to rebuild — wrong for a
Sidekiq or Celery queue, which would lose jobs while staying green.
{{- end }}
{{- else }}
{{- if eq .Values.valkey.maxmemoryPolicy "noeviction" }}
READ THIS: no persistence AND noeviction is almost never intended. The data is
gone on every pod restart, so nothing is being protected — but once memory is
full, writes start FAILING instead of cold keys being dropped. For a cache set
valkey.maxmemoryPolicy: allkeys-lru; for a queue set persistence.enabled: true.
{{- else }}
Cache shape: the dataset lives in the pod only. Both AOF and the RDB save
points are switched off, so a restart starts from empty and nothing is written
to ephemeral storage.
{{- end }}
{{- end }}
{{- if .Values.persistence.enabled }}
STORAGE
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.persistence.size }}, {{ if .Values.persistence.storageClass }}storage class "{{ .Values.persistence.storageClass }}"{{ else }}on the cluster's default storage class
(persistence.storageClass is unset — the default class is not encrypted){{ end }}.
Persistence mode: {{ if .Values.persistence.appendonly }}append-only file (every write appended; a crash loses at most
the last fsync interval){{ else }}RDB snapshots only (a crash loses every write since the last
snapshot, silently — the instance comes back healthy, only shorter){{ end }}.
helm uninstall does NOT delete this PVC, which is deliberate — the data
survives a reinstall of the same release name. It also means a release removed
and recreated under a DIFFERENT name starts on an empty volume while the old
one keeps occupying space. On encrypted Longhorn volumes that space is never
returned to the pool.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}valkey.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers on
the address above and every application configured against it will fail its own
checks. Nothing else is removed: Service and Secret stay{{ if .Values.persistence.enabled }}, the PVC keeps
the data{{ end }}{{ if .Values.networkPolicy.enabled }}, and the NetworkPolicy stays in force{{ end }}.
{{ end }}
CHANGING THE PASSWORD IS NOT COVERED BY A CHECKSUM
The pod template carries checksum/config over the configuration ConfigMap and
checksum/secret-ref over the rendered OnePasswordItem. So a changed
maxmemory/policy rolls the pod, and so does repointing secrets.authSecretRef at
a DIFFERENT vault item. What cannot be covered is a new password inside the
same item: Helm never sees the content of a Secret the 1Password operator
fills in. After changing it there:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset {{ .Release.Name }}-{{ .Chart.Name }}-sfs
(operator.1password.io/auto-restart is not set on purpose — the operator
restarts Deployments only, so on a StatefulSet it would be a silent no-op.)
Unverified, and named so rather than promised: because the probes read the
password out of the same mounted file, the kubelet's refresh of that file
(roughly a minute) should make the liveness probe fail against the still-old
server and restart the container by itself. Do the rollout restart instead of
waiting for it.
{{ if .Values.networkPolicy.enabled }}
NETWORK POLICY IS ACTIVE
Ingress to port 6379 is allowed from namespace {{ .Release.Namespace }}{{ if .Values.networkPolicy.allowedNamespaces }} and from
{{ join ", " .Values.networkPolicy.allowedNamespaces }}{{ end }}. Egress is unrestricted (this instance opens no
outbound connection of its own).
It is only a boundary where the CNI enforces NetworkPolicies. Where it does
not, the object is accepted and nothing is filtered — so verify it instead of
reading it off the manifest:
kubectl -n <some-other-namespace> run t --rm -it --image=docker.io/valkey/valkey:{{ .Chart.AppVersion }} \
-- valkey-cli -h {{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }} ping
That must hang or be refused. "NOAUTH Authentication required." means the
policy is not being enforced and the password is your only boundary.
{{- else }}
NO NETWORK POLICY
networkPolicy.enabled is false, so the password is the only thing between this
instance and every pod in the cluster. The official image compiles
protected-mode off, so there is no second line of defence behind it.
{{- end }}
WHAT A GREEN POD PROVES
All three probes run "valkey-cli -e ping" AUTHENTICATED with the password from
the mounted file and require the answer PONG. So Ready means: the server
accepted a connection, accepted that password, and answered.
Two things it does not mean. It does not say anything about the dataset — a
restarted instance whose AOF was lost answers PONG on an empty database. And
it does not say your application's keys are reachable: that depends on the
connection URL in the application's own vault item, which this chart neither
writes nor reads.
The probe asserts the answer (PONG), not just that the command returned, and
it passes "-e" so that valkey-cli reports an error reply through its exit code
at all — without it a bare "valkey-cli ping" exits 0 while printing
"NOAUTH Authentication required.", which would be a probe that cannot fail.
+434
View File
@@ -0,0 +1,434 @@
{{/*
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 -}}
+164
View File
@@ -0,0 +1,164 @@
{{- $maxmemory := include "valkey.maxmemoryBytes" (dict "resources" .Values.valkey.resources "maxmemory" .Values.valkey.maxmemory "maxmemoryFactor" .Values.valkey.maxmemoryFactor) -}}
{{- $limitBytes := include "valkey.memoryLimitBytes" .Values.valkey.resources -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
data:
# The server's whole configuration. It is mounted as a directory at
# /etc/valkey and passed to the image as an argument, so the entrypoint starts
# "valkey-server /etc/valkey/valkey.conf".
#
# The pod template carries checksum/config over this ConfigMap: without it a
# changed configuration would restart nothing, and Valkey reads its
# configuration exactly once, at start.
valkey.conf: |
# Rendered by the valkey Helm chart - do not edit in the cluster, the next
# helm upgrade overwrites it. Every value below comes from values.yaml.
port 6379
# The data directory. It is also the image's WORKDIR, so this is only made
# explicit for whoever reads the file; it is a PVC when
# persistence.enabled is true and an emptyDir otherwise.
dir /data
# maxmemory is derived from the container's memory limit
# ({{ $limitBytes }} bytes = {{ include "valkey.humanBytes" $limitBytes }}), not configured independently of it:
# {{ if kindIs "invalid" .Values.valkey.maxmemory }}maxmemoryFactor {{ .Values.valkey.maxmemoryFactor | default 0.75 }} x that limit{{ else }}valkey.maxmemory was set explicitly and checked against that limit{{ end }}.
# The remainder of the limit is for what maxmemory does NOT cover: client
# output buffers, the AOF buffer, and the copy-on-write pages of the fork()
# that writes a snapshot or rewrites the AOF.
maxmemory {{ $maxmemory }}
maxmemory-policy {{ required "valkey.maxmemoryPolicy must be set (see values.yaml for the two sensible pairs)" .Values.valkey.maxmemoryPolicy }}
{{- if .Values.persistence.enabled }}
{{- if .Values.persistence.appendonly }}
# Append-only file: every write is appended, so a crash loses at most the
# last fsync interval instead of everything since the last snapshot. The
# RDB save points stay at their defaults, as a second, cheaper copy.
appendonly yes
{{- else }}
# persistence.appendonly is false: the dataset survives only as periodic RDB
# snapshots (Valkey's default save points), so a crash loses every write
# since the last one - and it does so silently, because the instance comes
# back looking healthy, only shorter.
appendonly no
{{- end }}
{{- else }}
# persistence.enabled is false: /data is an emptyDir, so nothing written
# there survives a pod restart anyway. Both persistence mechanisms are
# switched off rather than left writing into ephemeral storage - "save """
# disables the RDB save points, which are on by default.
appendonly no
save ""
{{- end }}
{{- with .Values.valkey.extraConfig }}
# valkey.extraConfig, verbatim.
{{ tpl . $ | trim | nindent 4 }}
{{- end }}
# The password, as a complete configuration line out of the 1Password item
# (field "auth-conf"), mounted as a file. Two things about this line are
# deliberate:
#
# - it is an "include" rather than a "requirepass" written here, so the
# password is never part of this ConfigMap, of the process list, or of
# anything Helm renders;
# - it is LAST. In a Valkey configuration the last occurrence of a
# directive wins, so nothing in valkey.extraConfig above can switch the
# password off, by accident or otherwise.
#
# An init container has already verified that this file carries a
# "requirepass <password>" line before the server ever starts.
include /etc/valkey-auth/auth.conf
# The probe command for all three probes. A script rather than an inline shell
# one-liner, because the two things that make it a real check need to be
# written down next to them.
ping.sh: |
#!/bin/sh
# Authenticated liveness/readiness/startup check for Valkey.
#
# WHY "-e" AND grep, i.e. two assertions rather than one: a bare
# "valkey-cli ping" probe CANNOT FAIL. Read off the source (valkey 9.1.2,
# src/valkey-cli.c): cliReadReply turns an error reply into exit(1) in
# exactly one branch, guarded by config.set_errcode, and "-e" is the only
# thing that sets it ("-e Return exit error code when command execution
# fails."). Without -e the command prints "NOAUTH Authentication required."
# and exits 0 - green, forever, whatever the server does.
#
# Either half alone would catch that: "grep -qx PONG" asserts the ANSWER, so
# it fails on an error reply even without -e. Both are here on purpose. The
# grep is the assertion; "-e" makes the failure explicit and, because the
# error then goes to stderr instead of stdout, puts the server's own message
# ("NOAUTH...", "WRONGPASS...") into the probe's failure output where
# kubectl describe shows it.
#
# WHY THE PASSWORD COMES THROUGH THE ENVIRONMENT: "valkey-cli -a <password>"
# puts the password in the process list, where every container in this pod
# can read it. VALKEYCLI_AUTH is valkey-cli's own environment variable for
# it (REDISCLI_AUTH is still honoured as the legacy spelling), and it is set
# only for the duration of this one command.
#
# Nothing here prints the password: it only ever lives in a shell variable,
# and valkey-cli's own error messages do not echo it.
set -eu
AUTH_FILE=/etc/valkey-auth/auth.conf
# The same file the server was configured from, so probe and server can
# never disagree about the password. sed's character class is written the
# POSIX way (no \+): this runs in the image's BusyBox shell.
pass="$(sed -n 's/^requirepass[[:space:]][[:space:]]*//p' "$AUTH_FILE" | head -n 1)"
# Valkey's configuration parser allows the value to be quoted; strip one
# surrounding pair of double quotes if present.
case "$pass" in
'"'*'"') pass="${pass#\"}"; pass="${pass%\"}" ;;
esac
VALKEYCLI_AUTH="$pass" valkey-cli -e -p 6379 ping | grep -qx PONG
# The init container's check. It validates the mounted auth file and does NOT
# assemble it - the password is a finished configuration line from the vault.
check-auth.sh: |
#!/bin/sh
# Refuse to start unless the mounted auth file really carries a
# "requirepass <password>" line.
#
# The failure this prevents: the password arrives as a ready-made
# configuration line from a 1Password field, and a misspelled key
# ("requirepas"), a stray quote or an empty field would leave Valkey running
# WITHOUT any password - no render error, no crash, a green pod, and an open
# server (the official image compiles protected-mode off). Of all the ways
# this chart can be misconfigured, that is the only one nothing else would
# report.
#
# It never prints the file: an error message containing the password would
# put it in the pod's event log and in every CI diagnostics dump.
set -eu
AUTH_FILE=/etc/valkey-auth/auth.conf
if [ ! -r "$AUTH_FILE" ]; then
echo "valkey: $AUTH_FILE is missing or unreadable." >&2
echo " The 1Password item behind secrets.authSecretRef must carry a field" >&2
echo " named 'auth-conf'. The 1Password operator turns each field of the" >&2
echo " item into one key of the Kubernetes Secret, so a differently named" >&2
echo " field produces no such file." >&2
exit 1
fi
if ! grep -q '^requirepass[[:space:]][[:space:]]*[^[:space:]]' "$AUTH_FILE"; then
echo "valkey: the mounted auth file carries no 'requirepass <password>' line." >&2
echo " Expected the field 'auth-conf' of the 1Password item to contain" >&2
echo " exactly one configuration line:" >&2
echo " requirepass <the password>" >&2
echo " Refusing to start: Valkey would come up without a password, report" >&2
echo " healthy, and accept connections from the whole pod network." >&2
echo " (The file's contents are deliberately not printed.)" >&2
exit 1
fi
@@ -0,0 +1,34 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
# Ingress only. Egress is deliberately unrestricted: this instance opens no
# outbound connection of its own (no replication, no cluster bus), so an
# egress rule here would constrain nothing while making the object look
# stricter than it is.
policyTypes:
- Ingress
ingress:
# One rule with a list of sources. Note the shape: an empty "from" would
# allow EVERYTHING, so the namespaces are always spelled out - the release's
# own namespace first, then whatever networkPolicy.allowedNamespaces adds.
- from:
- namespaceSelector:
matchLabels:
# Set by Kubernetes itself on every namespace (>= 1.21), so this
# needs no labelling work on the target namespaces.
kubernetes.io/metadata.name: {{ .Release.Namespace }}
{{- range .Values.networkPolicy.allowedNamespaces }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
ports:
- protocol: TCP
port: 6379
{{- end }}
+19
View File
@@ -0,0 +1,19 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
# Deliberately NOT null-safe: an erased persistence block yields an
# invalid PVC that is rejected at apply time, which is loud. A chart-side
# fallback would silently size somebody's queue volume - and on an
# existing release it could not help anyway, because a PVC cannot shrink.
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
{{- end }}
+11
View File
@@ -0,0 +1,11 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
spec:
# The item must carry the field "auth-conf", holding the complete
# configuration line "requirepass <password>" - the 1Password operator
# materializes one Secret key per item field, so the field NAME is what the
# chart depends on. NOTES.txt prints this, and the init container refuses to
# start the pod if the line is not there.
itemPath: {{ required "You must provide an auth Secret Reference (secrets.authSecretRef) - the 1Password item holding this instance's 'requirepass <password>' line" .Values.secrets.authSecretRef }}
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
ports:
- protocol: TCP
port: 6379
targetPort: valkey
name: valkey
# ClusterIP, and there is deliberately no value to change it: this chart
# deploys one application's cache or queue backend. A NodePort or LoadBalancer
# would put a Valkey whose only authentication is one password on a routable
# address. Applications reach it at
# <release>-valkey-service.<namespace>.svc.cluster.local:6379
type: ClusterIP
+162
View File
@@ -0,0 +1,162 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
replicas: {{ include "valkey.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.valkey.replicas) }}
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
annotations:
# Hash of the rendered ConfigMap: a changed configuration changes the pod
# template and thus restarts the pod. Without it a changed maxmemory or
# eviction policy would apply to nothing - Valkey reads its
# configuration once, at start, and a mounted file is not re-read.
checksum/config: {{ include (print $.Template.BasePath "/valkey.configmap.yaml") . | sha256sum }}
# Hash of the rendered OnePasswordItem. This covers the SOURCE of the
# password, not its content: repointing secrets.authSecretRef at a
# different vault item rolls the pod instead of leaving the old password
# in the running server. A change INSIDE the item cannot be covered -
# Helm never sees a secret the 1Password operator materializes (see
# values.yaml).
#
# operator.1password.io/auto-restart is deliberately NOT set: the
# operator only restarts Deployments, so on a StatefulSet it is a silent
# no-op that advertises a guarantee which does not exist.
checksum/secret-ref: {{ include (print $.Template.BasePath "/valkey.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "valkey.podSecurityContext" .Values.valkey.securityContext | nindent 8 }}
initContainers:
# Validates the mounted auth file and nothing else. It does not compose
# the configuration line - that comes finished out of the vault - it only
# refuses to let the pod start when the line is missing, which is the one
# misconfiguration of this chart that would otherwise produce a healthy,
# password-less, network-reachable Valkey. The script, and the reason it
# never prints the file, are in templates/valkey.configmap.yaml.
#
# Same image as the server, so there is no second image to pin, no
# Renovate block to maintain for it and nothing extra to pull.
- name: {{ .Release.Name }}-{{ .Chart.Name }}-check-auth
image: "docker.io/valkey/valkey:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
# command, not args: args would be handed to the image's entrypoint,
# which starts valkey-server for anything that looks like a config
# file or an option.
command:
- /bin/sh
- /etc/valkey/check-auth.sh
securityContext:
{{- include "valkey.containerSecurityContext" .Values.valkey.securityContext | nindent 12 }}
# Fixed and deliberately small: this container runs one grep. Not
# values-configurable - there is nothing here to tune.
resources:
requests:
cpu: 10m
memory: 16Mi
ephemeral-storage: 1Mi
limits:
cpu: 100m
memory: 32Mi
ephemeral-storage: 10Mi
volumeMounts:
- name: config
mountPath: /etc/valkey
readOnly: true
- name: auth
mountPath: /etc/valkey-auth
readOnly: true
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
image: "docker.io/valkey/valkey:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
# args, NOT command: the image's entrypoint is
# "tini -- docker-entrypoint.sh", and replacing it would lose tini as
# PID 1 (zombie reaping) and the entrypoint's umask. An argument
# ending in .conf makes the entrypoint start
# "valkey-server /etc/valkey/valkey.conf", which is exactly what is
# wanted. Note which branch of that entrypoint is NOT taken: running as
# uid 999 skips its root-only "chown /data + setpriv" step, so the
# volume is made writable by fsGroup instead.
args:
- /etc/valkey/valkey.conf
# The chart sets no environment variable of its own - everything the
# server is told comes from its configuration file, which is what keeps
# the password out of the environment. So the block is rendered only
# when the operator actually adds an entry, instead of leaving an empty
# "env:" (i.e. env: null) in the manifest.
{{- with .Values.valkey.env }}
env:
{{- include "valkey.env" (dict "root" $ "env" .) | nindent 12 }}
{{- end }}
ports:
- containerPort: 6379
protocol: TCP
name: valkey
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
livenessProbe:
{{- include "valkey.livenessProbe" .Values.valkey.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "valkey.readinessProbe" .Values.valkey.readinessProbe | nindent 12 }}
startupProbe:
{{- include "valkey.startupProbe" .Values.valkey.startupProbe | nindent 12 }}
resources:
{{- include "valkey.effectiveResources" .Values.valkey.resources | nindent 12 }}
securityContext:
{{- include "valkey.containerSecurityContext" .Values.valkey.securityContext | nindent 12 }}
volumeMounts:
# The image's WORKDIR. Everything Valkey writes goes here (the AOF
# directory, RDB snapshots), and it is the only writable path the
# server needs - which is what lets readOnlyRootFilesystem stay true
# without a single extra emptyDir.
- name: data
mountPath: /data
- name: config
mountPath: /etc/valkey
readOnly: true
# The auth file, mounted as a DIRECTORY rather than with subPath, so
# the kubelet keeps it up to date when the 1Password operator writes
# a new password into the Secret. That does not restart the server by
# itself (see the annotations above).
- name: auth
mountPath: /etc/valkey-auth
readOnly: true
restartPolicy: Always
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
{{- else }}
# persistence.enabled is false: the dataset lives in the pod and is
# gone with it. The configuration switches both AOF and the RDB save
# points off in that case, so this emptyDir stays empty - it exists
# because the image's WORKDIR has to be writable.
emptyDir: {}
{{- end }}
- name: config
configMap:
name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
- name: auth
secret:
secretName: {{ .Release.Name }}-{{ .Chart.Name }}-secret
items:
# The 1Password item's field name on the left, the file name on the
# right. Spelled out rather than mounting the whole Secret, so a
# missing field fails the pod with a named reference instead of
# producing a directory without the file.
- key: auth-conf
path: auth.conf
+223
View File
@@ -0,0 +1,223 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "valkey values",
"description": "Values schema for the valkey chart (issue #68, guideline 12). additionalProperties is false on every chart-owned object level, so a misspelled key fails the render instead of being silently ignored. The objects that go verbatim into the pod spec (probes, security contexts, global) stay open, because the chart does not interpret their contents. secrets.authSecretRef has no valid default and is therefore enforced by required() in the template, not here - a required array next to a key that ships as null could never fire.",
"type": "object",
"additionalProperties": false,
"properties": {
"global": {
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"scaleDown": {
"description": "true renders the StatefulSet with replicas: 0 and overrides valkey.replicas. PVC, Secret, Service and NetworkPolicy stay in place.",
"type": "boolean"
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"authSecretRef": {
"description": "REQUIRED (enforced by required() in the template). op:// path of the 1Password item carrying the field 'auth-conf' with the complete configuration line 'requirepass <password>'.",
"type": ["string", "null"]
}
}
},
"valkey": {
"type": "object",
"additionalProperties": false,
"properties": {
"replicas": {
"description": "0 or 1. This chart runs exactly one Valkey: a second pod would fight over the same ReadWriteOnce volume, and clients behind the Service would silently see two different datasets.",
"type": "integer",
"minimum": 0,
"maximum": 1
},
"resources": {
"$ref": "#/definitions/resources"
},
"maxmemoryFactor": {
"description": "Fraction of the container's computed memory limit that Valkey may fill with data. The rest covers what maxmemory does not: client buffers, the AOF buffer and the copy-on-write pages of the snapshot/AOF-rewrite fork. Ignored when valkey.maxmemory is set.",
"type": ["number", "null"],
"exclusiveMinimum": 0,
"maximum": 1
},
"maxmemory": {
"description": "Explicit maxmemory as a Kubernetes quantity (e.g. '512Mi'), overriding maxmemoryFactor. A value above the container's memory limit aborts the render. Valkey's own spellings ('512mb') are rejected on purpose - one quantity syntax per chart.",
"type": ["string", "null"],
"pattern": "^[0-9]+(\\.[0-9]+)?(k|M|G|T|Ki|Mi|Gi|Ti)?$"
},
"maxmemoryPolicy": {
"description": "Valkey's maxmemory-policy. Enumerated rather than free text: a typo would make the server refuse to start, and the enum names the value instead. 'noeviction' for queues (the default), 'allkeys-lru' for caches.",
"type": "string",
"enum": [
"noeviction",
"allkeys-lru",
"allkeys-lfu",
"allkeys-random",
"volatile-lru",
"volatile-lfu",
"volatile-random",
"volatile-ttl"
]
},
"extraConfig": {
"description": "Extra configuration lines, appended verbatim (rendered through tpl). Cannot switch the password off: the auth include is rendered after it and the last directive wins.",
"type": ["string", "null"]
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"securityContext": {
"$ref": "#/definitions/securityContext"
},
"env": {
"$ref": "#/definitions/env"
}
}
},
"persistence": {
"description": "The data volume. Deliberately NOT null-safe: an erased block yields an invalid PVC, which fails at apply time instead of sizing somebody's queue volume by chart default. Issue #99 covers the silent class of failure, and this one is loud.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "true = PVC, false = emptyDir (and the chart then switches AOF and the RDB save points off). Pairs with valkey.maxmemoryPolicy - see values.yaml.",
"type": "boolean"
},
"appendonly": {
"description": "AOF instead of RDB-only. Has no effect while persistence.enabled is false: there is nothing to append to, and the chart writes 'appendonly no' plus 'save \"\"'.",
"type": "boolean"
},
"size": {
"type": ["string", "null"]
},
"storageClass": {
"description": "null uses the cluster's default class - which on this cluster is NOT encrypted, so leaving the default 'longhorn-encrypted-ns' is a decision about data at rest.",
"type": ["string", "null"]
}
}
},
"networkPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "On by default. Only a real boundary where the cluster's CNI enforces NetworkPolicies - an unenforced policy is accepted by the API server and filters nothing.",
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces allowed in addition to the release's own, matched on the kubernetes.io/metadata.name label Kubernetes sets itself. A name that does not exist matches nothing and is silently ineffective.",
"type": ["array", "null"],
"items": {
"type": "string"
}
}
}
}
},
"definitions": {
"resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting. The memory limit computed here is also what valkey.maxmemory is derived from and checked against.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"limitFactor": {
"type": "number",
"exclusiveMinimum": 0
},
"requests": {
"$ref": "#/definitions/resourceList"
},
"limits": {
"$ref": "#/definitions/resourceList"
}
}
},
"resourceList": {
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"cpu": {
"type": ["string", "number", "null"]
},
"memory": {
"type": ["string", "number", "null"]
},
"ephemeral-storage": {
"type": ["string", "number", "null"]
},
"ephemeralStorage": {
"type": ["string", "number", "null"]
}
}
},
"env": {
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
},
"valueFrom": {
"type": "object",
"additionalProperties": false,
"properties": {
"secretKeyRef": {
"$ref": "#/definitions/keyRef"
},
"configMapKeyRef": {
"$ref": "#/definitions/keyRef"
}
}
}
}
}
},
"keyRef": {
"type": "object",
"additionalProperties": false,
"required": ["name", "key"],
"properties": {
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
},
"securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS deviation, not the repo standard - uid 999 / gid 1000 are the image's own 'valkey' user, and fsGroup has to keep matching runAsGroup or the data volume is not writable.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"pod": {
"description": "Merged onto the chart's pod hardening defaults; kept open because the chart does not interpret the individual fields. seccompProfile lives here so it also covers the init container.",
"type": ["object", "null"]
},
"container": {
"description": "Merged onto the chart's container hardening defaults and applied to BOTH the server and the auth-check init container; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"probe": {
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The default carries the exec command - for these probes the command IS the check (guideline 12.5).",
"type": ["object", "null"]
}
}
}
+281
View File
@@ -0,0 +1,281 @@
# One Valkey instance for ONE application - a cache or a queue backend, not a
# shared database. There is deliberately no cluster mode, no replication and no
# Ingress: a Valkey instance belongs on the cluster network, reachable through
# its ClusterIP Service, and everything that would expose it to the internet is
# absent rather than switched off.
#
# The two shapes this chart is meant for, and they are PAIRS of values, not two
# independent switches:
#
# QUEUE (the chart default) - Sidekiq, Celery, anything whose entries are work
# that must not vanish:
# persistence.enabled: true
# valkey.maxmemoryPolicy: noeviction
#
# CACHE - recomputable data only:
# persistence.enabled: false
# valkey.maxmemoryPolicy: allkeys-lru
#
# Mixing them is what this comment exists to prevent. A queue with
# allkeys-lru throws away jobs under memory pressure while every application
# stays green - one of the hypotheses in cluster-config#102, and cheaper to
# prevent here than to diagnose there. And "persistence.enabled: false" together
# with the default "noeviction" is almost never what anyone wants: a full cache
# then starts REFUSING WRITES instead of dropping cold keys, and after a pod
# restart it is empty anyway - so the strictness buys nothing and costs write
# errors.
# Scale the whole chart down without uninstalling it: scaleDown: true renders the
# StatefulSet with replicas: 0 and takes precedence over valkey.replicas. The
# PVC, the Secret, the Service and the NetworkPolicy stay in place;
# scaleDown: false brings the workload back up.
scaleDown: false
secrets:
# op:// path of the 1Password item holding this instance's password.
#
# The item must carry ONE field, named "auth-conf", whose value is a complete
# Valkey configuration line:
#
# requirepass <the password>
#
# Not the bare password: the chart mounts this field as a file and the main
# configuration "include"s it, so the password never appears in the process
# list, in an environment variable, or anywhere Helm can see it. That is also
# why no init container assembles the line - the vault is the only place the
# secret exists in.
#
# A ready-made configuration line has one failure mode, and the chart closes
# it: a misspelled key ("requirepas"), a stray quote or an empty field would
# leave Valkey running WITHOUT a password and perfectly healthy - no render
# error, no crash, an open server (the official image compiles protected-mode
# OFF, so "no password" really does mean "anyone on the pod network"). The
# init container therefore greps the mounted file for a line beginning with
# "requirepass " and refuses to start the pod if it is absent. It never prints
# the file.
#
# What this chart CANNOT cover: a change of the field's CONTENT inside the
# vault. Helm never sees a secret materialized by the 1Password operator, so
# there is nothing to hash. Repointing this value at a DIFFERENT item is
# covered - checksum/secret-ref over the rendered OnePasswordItem rolls the
# pod. After changing the password inside the item, restart by hand:
# kubectl -n <ns> rollout restart statefulset <release>-valkey-sfs
# (operator.1password.io/auto-restart is NOT set: on a StatefulSet it is a
# silent no-op - see README, "Config changes must restart the workload".)
#
# One side effect of the probes reading the same file, stated as UNVERIFIED
# because it has not been measured: the auth file is a directory mount, so the
# kubelet refreshes it within about a minute of the operator writing the new
# password, while the running server still requires the old one. The liveness
# probe then fails and the container is restarted - i.e. a rotated password
# probably applies itself after roughly a minute of the instance being
# unreachable. Do not rely on it; do the rollout restart above and know when
# the interruption happens.
authSecretRef: null
valkey:
# Replica count for this workload; 0 is allowed and scales it down. Values
# above 1 are rejected by the schema: this chart runs ONE Valkey, and two
# pods would fight over one ReadWriteOnce volume while clients would silently
# get two different datasets through the Service.
# The chart-global scaleDown flag takes precedence.
replicas: 1
# Memory is the resource that decides what this chart does, because
# valkey.maxmemory is derived from the computed memory LIMIT (see below).
# 256Mi request x limitFactor 3 = 768Mi limit, of which Valkey may fill
# maxmemoryFactor (0.75) = 576Mi with data.
resources:
# Limits are optional: memory and ephemeral-storage limits default to
# limitFactor x the request, an explicitly set limit always wins, and a
# CPU limit is only set when configured explicitly under "limits".
limitFactor: 3
requests:
# Valkey is single-threaded for command execution; 100m is enough for a
# per-application cache and is a request, not a cap.
cpu: 0.1
memory: 256Mi
# The data volume is separate (persistence below). This covers the
# container's own writable scratch space, which under this chart is only
# /data when persistence is off.
ephemeral-storage: 64Mi
# The fraction of the container's memory LIMIT that Valkey may fill with data.
#
# Why a fraction and not the whole limit: maxmemory bounds the dataset, not
# the process. Client output buffers, replication/AOF buffers and above all
# the copy-on-write pages of the fork() that writes an RDB snapshot or rewrites
# the AOF all live OUTSIDE maxmemory. Handing Valkey the full limit means the
# kubelet OOM-kills the pod at the exact moment the eviction policy was
# supposed to take over - which is the failure this value exists to prevent.
maxmemoryFactor: 0.75
# maxmemory as an explicit Kubernetes quantity (e.g. "512Mi"), overriding the
# derivation from maxmemoryFactor. null = derive it.
#
# A value ABOVE the computed memory limit ABORTS THE RENDER with both numbers
# named. It is not clamped and not warned about: a maxmemory above the limit
# means the kubelet's OOM kill decides what the eviction policy was configured
# to decide, and that failure arrives as a restarted pod with no explanation.
maxmemory: null
# Valkey's maxmemory-policy. Default "noeviction" on purpose - see the pairs
# at the top of this file. Under pressure Valkey then rejects writes with an
# OOM error, which is loud in the application instead of silent in the data.
# For a pure cache use "allkeys-lru".
maxmemoryPolicy: noeviction
# Extra Valkey configuration lines, appended verbatim to the rendered
# configuration file (e.g. "appendfsync everysec" or "timeout 300").
#
# The auth include is rendered AFTER this block, and in a Valkey configuration
# the last occurrence of a directive wins - so nothing put here can switch the
# password off, by accident or otherwise. It CAN override maxmemory and
# maxmemory-policy, which then silently escapes the limit check above; set
# valkey.maxmemory instead of writing a maxmemory line here.
extraConfig: ""
# All three probes run the same AUTHENTICATED "valkey-cli ping" as an exec
# probe - Valkey serves no HTTP, so there is nothing to GET. The command is a
# script from the chart's ConfigMap (templates/valkey.configmap.yaml, key
# ping.sh), which is where the two properties that make it an assertion rather
# than a formality are written down and commented:
#
# - the answer is asserted, not just the exit code ("grep -qx PONG") AND
# valkey-cli is called with "-e" - because without -e it EXITS 0 on an
# error reply, so a bare "valkey-cli ping" probe would report success while
# printing "NOAUTH Authentication required.";
# - the password goes through the VALKEYCLI_AUTH environment variable, read
# out of the mounted auth file, never as an "-a" argument, which would put
# it in the process list.
#
# Keeping the script in the ConfigMap (instead of a one-line shell command
# inline here) has one consequence worth knowing: editing valkey.maxmemory or
# any other configuration value changes the ConfigMap, and checksum/config
# then rolls the pod - which it has to do anyway for the configuration itself.
#
# What these probes prove: the server accepted a connection, authenticated
# with the password from the vault, and answered PONG. What they do not prove:
# that the dataset is intact or that the application's keys are there.
livenessProbe:
exec:
command: ["/bin/sh", "/etc/valkey/ping.sh"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command: ["/bin/sh", "/etc/valkey/ping.sh"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness, so those need no
# initialDelaySeconds. Startup budget = periodSeconds x failureThreshold =
# 150s, which is what replaying a large append-only file from disk can cost; an
# empty instance answers on the first check.
startupProbe:
exec:
command: ["/bin/sh", "/etc/valkey/ping.sh"]
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
securityContext:
pod:
# uid 999 / gid 1000 are NOT the repo's usual 20000/1000 and they are not
# interchangeable: they are the "valkey" user the official image creates
# (9.1/alpine/Dockerfile: "addgroup -S -g 1000 valkey; adduser -S -G
# valkey -u 999 valkey" - gid 1000 because Alpine already uses gid 999).
# The image sets no USER and starts as root; its entrypoint then steps
# down to that user with setpriv, chown'ing /data on the way. Running the
# pod as uid 999 directly skips that branch of the entrypoint - which is
# the point (runAsNonRoot) and also why fsGroup has to match: without it
# nothing would make the volume writable for the process.
runAsNonRoot: true
runAsUser: 999
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: Always
# RuntimeDefault applies the container runtime's default seccomp filter.
# It sits in the pod context on purpose: from here it covers every
# container in the pod INCLUDING the init container that validates the
# auth file, while a container-level profile would have to be repeated for
# each one (and silently missed on the next one added).
seccompProfile:
type: RuntimeDefault
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
add: []
# Additive environment entries for the Valkey container, appended to what the
# chart sets. Supported forms: value (rendered through tpl), secretKeyRef,
# configMapKeyRef.
#
# Note what does NOT belong here: the password. The image's entrypoint appends
# $VALKEY_EXTRA_FLAGS to the server command line, so a password routed through
# this block would end up in the process list - exactly what the mounted
# configuration file avoids.
#
# Additive means additive: an entry repeating a name the chart already sets is
# applied at install time and LOST on an upgrade of an existing release
# (Kubernetes merges env by name), while helm still reports success.
env: []
persistence:
# true = the dataset lives on a PVC and survives a pod restart. Off for a pure
# cache; see the pairs at the top of this file.
enabled: true
# Append-only file instead of RDB snapshots.
#
# Why AOF is the default: for a queue, the question is not "is there a backup"
# but "how much is lost when the pod dies". An RDB snapshot is written every
# few minutes, so a crash loses every job accepted since the last one - and it
# loses them silently, because the queue comes back looking healthy, only
# shorter. AOF appends every write, so the loss window is the fsync interval.
# The cost is disk writes and a slower start (the file is replayed), which the
# startup budget above accounts for.
#
# With persistence.enabled: false this value has nothing to append to, and the
# chart then renders "appendonly no" plus "save ''" so that a pod without a
# volume writes nothing to its ephemeral storage at all. NOTES.txt prints
# which of the two is active for the release at hand.
appendonly: true
# Small on purpose. On encrypted Longhorn volumes space is never returned to
# the pool once written, so an oversized volume is paid for permanently -
# start tight and grow (a PVC can grow; it can never shrink).
size: 1Gi
# longhorn-encrypted-ns: the data here is an application's cache or queue
# content, which regularly contains the same payloads as its database.
# A different class (or null for the cluster default) is possible, but then
# the data is at rest unencrypted, which is a decision and not a detail.
storageClass: longhorn-encrypted-ns
networkPolicy:
# On by default. This is a new chart with no existing installation that could
# break on it, and a cache whose only boundary is a password is one leaked
# credential away from being open to every pod in the cluster.
#
# READ THIS BEFORE RELYING ON IT: a NetworkPolicy is only a boundary if the
# cluster's CNI enforces policies. Where it does not, the API server still
# accepts the object and everything looks configured while nothing is
# filtered - an unenforced policy is a false sense of security, not a weak
# one. Verify on the cluster (try to connect from an unrelated namespace)
# rather than reading it off this value.
enabled: true
# Namespaces whose pods may reach this instance in addition to the release's
# own namespace. Empty means: own namespace only.
#
# Matched by the "kubernetes.io/metadata.name" label, which Kubernetes sets on
# every namespace itself (>= 1.21), so no labelling work is needed - but also
# no typo protection: a namespace name that does not exist matches nothing and
# is silently ineffective.
allowedNamespaces: []