From dba2664752bbc4b1d99ac8affcf1a38865189774 Mon Sep 17 00:00:00 2001 From: Mathieu REHO Date: Fri, 3 Jul 2026 18:46:10 +0200 Subject: [PATCH 1/5] fix(valkey): harden failover safety, node/sentinel identity, and secret handling --- charts/valkey/docs/sentinel.md | 11 +- charts/valkey/templates/_helpers.tpl | 16 + charts/valkey/templates/configmap.yaml | 348 ++++++++++++++++++ .../templates/sentinel-node-statefulset.yaml | 163 ++------ .../templates/sentinel-statefulset.yaml | 114 +++--- charts/valkey/values.schema.json | 33 ++ charts/valkey/values.yaml | 14 + 7 files changed, 495 insertions(+), 204 deletions(-) diff --git a/charts/valkey/docs/sentinel.md b/charts/valkey/docs/sentinel.md index 70319eae..89e4d3c5 100644 --- a/charts/valkey/docs/sentinel.md +++ b/charts/valkey/docs/sentinel.md @@ -114,8 +114,15 @@ sentinel: Anti-split-brain safety does not depend on the bootstrap marker surviving reschedules. When Sentinel is unreachable, each node probes peer `INFO replication` before choosing a role. -Data loss is possible only if every data node restarts at the same time with no peers online. -Enable `node.persistence.enabled=true` when you need RDB/AOF to survive pod reschedules. +Without additional safeguards, a single restart of the active master pod inside the +`down-after-milliseconds` window would resume it as an empty master and make every +replica full-resync from an empty dataset. The chart mitigates this with +`sentinel.gracefulFailover` (preStop-triggered failover on voluntary disruptions) and +`sentinel.startupFailoverGuard` (a fresh master refuses to resume while peers still +hold data and forces a failover first). Residual data loss remains possible if these +guards are disabled, if no replica is promotable, or if every data node restarts at +the same time. Enable `node.persistence.enabled=true` when you need RDB/AOF to +survive pod reschedules. ### Fail-closed with persistence enabled diff --git a/charts/valkey/templates/_helpers.tpl b/charts/valkey/templates/_helpers.tpl index a642cb6f..dbd52ad3 100644 --- a/charts/valkey/templates/_helpers.tpl +++ b/charts/valkey/templates/_helpers.tpl @@ -484,3 +484,19 @@ topologySpreadConstraints: {{- end }} {{- end }} {{- end -}} + +{{/* +Probe commands for sentinel-mode pods. Auth flows through the VALKEYCLI_AUTH +environment variable set on the container, never through argv. +*/}} +{{- define "valkey.nodeProbeCommand" -}} +valkey-cli {{ include "valkey.probeTlsArgs" . }} -p {{ .Values.service.ports.valkey }} ping +{{- end -}} + +{{- define "valkey.sentinelProbeCommand" -}} +valkey-cli {{ include "valkey.probeTlsArgs" . }} -p {{ .Values.service.ports.sentinel }} ping +{{- end -}} + +{{- define "valkey.sentinelReadyCommand" -}} +valkey-cli {{ include "valkey.probeTlsArgs" . }} -p {{ .Values.service.ports.sentinel }} sentinel get-master-addr-by-name {{ .Values.sentinel.masterSet }} | grep -q . +{{- end -}} diff --git a/charts/valkey/templates/configmap.yaml b/charts/valkey/templates/configmap.yaml index b77e9e1e..3fbcd0a5 100644 --- a/charts/valkey/templates/configmap.yaml +++ b/charts/valkey/templates/configmap.yaml @@ -64,6 +64,354 @@ data: {{ . | nindent 4 }} {{- end }} # HelmForge managed sentinel config end + node-entrypoint.sh: | + #!/bin/sh + # Shared bootstrap for sentinel-mode data nodes. + # Usage: node-entrypoint.sh -> discover role, write runtime config, exec valkey-server + # node-entrypoint.sh --wait-only -> bounded init-container wait (never blocks recovery) + set -eu + + MODE="${1:-run}" + VALKEY_PORT={{ .Values.service.ports.valkey }} + SENTINEL_HOST="{{ include "valkey.sentinelServiceFqdn" . }}" + SENTINEL_PORT={{ .Values.service.ports.sentinel }} + MASTER_SET="{{ .Values.sentinel.masterSet }}" + SEED_MASTER="{{ include "valkey.nodePodFqdn" . }}" + PEERS="{{ include "valkey.nodePeerFqdns" . }}" + TLS_ARGS="{{ include "valkey.cliTlsArgs" . }}" + MARKER=/data/.helmforge-sentinel-node-bootstrapped + SELF_FQDN="$(hostname -f)" + SELF_IP="$(hostname -i 2>/dev/null | awk '{ print $1 }')" + + # Data-plane auth flows through VALKEYCLI_AUTH set on the container (never on argv). + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + vcli() { valkey-cli $TLS_ARGS "$@"; } + {{- if and .Values.auth.enabled (not .Values.auth.sentinel) }} + # Sentinel control plane is unauthenticated: strip inherited credentials. + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + scli() { env -u VALKEYCLI_AUTH valkey-cli $TLS_ARGS -h "$SENTINEL_HOST" -p "$SENTINEL_PORT" "$@"; } + {{- else }} + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + scli() { valkey-cli $TLS_ARGS -h "$SENTINEL_HOST" -p "$SENTINEL_PORT" "$@"; } + {{- end }} + + is_self() { [ "$1" = "$SELF_FQDN" ] || [ "$1" = "$SELF_IP" ]; } + + sentinel_master() { + scli sentinel get-master-addr-by-name "$MASTER_SET" 2>/dev/null | awk 'NR == 1 { print $1 }' + } + + peer_info() { vcli -h "$1" -p "$VALKEY_PORT" info replication 2>/dev/null || true; } + + peer_master() { + for peer in $PEERS; do + [ "$peer" = "$SELF_FQDN" ] && continue + info="$(peer_info "$peer")" + [ -z "$info" ] && continue + role="$(printf '%s\n' "$info" | sed -n 's/^role://p' | tr -d '\r')" + case "$role" in + master) + echo "$peer" + return 0 + ;; + slave|replica) + mh="$(printf '%s\n' "$info" | sed -n 's/^master_host://p' | tr -d '\r')" + if [ -n "$mh" ]; then + echo "$mh" + return 0 + fi + ;; + esac + done + return 1 + } + + peer_replicates_self() { + for peer in $PEERS; do + [ "$peer" = "$SELF_FQDN" ] && continue + info="$(peer_info "$peer")" + [ -z "$info" ] && continue + role="$(printf '%s\n' "$info" | sed -n 's/^role://p' | tr -d '\r')" + mh="$(printf '%s\n' "$info" | sed -n 's/^master_host://p' | tr -d '\r')" + case "$role" in + slave|replica) is_self "$mh" && return 0 ;; + esac + done + return 1 + } + + discover_master() { + i=0 + while [ "$i" -lt 30 ]; do + m="$(sentinel_master)" + if [ -n "$m" ]; then + echo "$m" + return 0 + fi + i=$((i + 1)) + echo "waiting for Sentinel master discovery ($i/30)" >&2 + sleep 2 + done + peer_master + } + + if [ "$MODE" = "--wait-only" ]; then + # Bounded, re-resolving wait: the previous unbounded ping loop could deadlock + # every replica behind a permanently dead master while Sentinel had no live + # replica left to promote. If the master cannot be confirmed in time, defer to + # the main container, which owns the fail-closed decision. + i=0 + while [ "$i" -lt 24 ]; do + m="$(sentinel_master)" + [ -z "$m" ] && m="$(peer_master || true)" + if [ -z "$m" ] || is_self "$m"; then + exit 0 + fi + if vcli -h "$m" -p "$VALKEY_PORT" ping >/dev/null 2>&1; then + exit 0 + fi + i=$((i + 1)) + echo "waiting for master $m:$VALKEY_PORT ($i/24)" + sleep 5 + done + echo "master not confirmed after bounded wait; deferring to main container" + exit 0 + fi + + master="$(discover_master || true)" + + {{- if .Values.sentinel.startupFailoverGuard.enabled }} + # Anti-wipe guard: a master pod recreated on an empty volume inside the + # down-after-milliseconds window is still designated master by Sentinel. Starting + # it empty would make every replica full-resync from an empty dataset. If peers + # still replicate from us and we have no bootstrap marker, force a failover so a + # data-bearing replica is promoted first, then join as its replica. + if [ -n "$master" ] && is_self "$master" && [ ! -f "$MARKER" ] && peer_replicates_self; then + echo "fresh data dir while topology still designates this pod as master and peers hold data" + echo "requesting Sentinel failover to avoid replicas resyncing from an empty dataset" + i=0 + while [ "$i" -lt {{ int .Values.sentinel.startupFailoverGuard.maxAttempts }} ]; do + scli sentinel failover "$MASTER_SET" >/dev/null 2>&1 || true + sleep 3 + m="$(sentinel_master)" + if [ -n "$m" ] && ! is_self "$m"; then + master="$m" + break + fi + i=$((i + 1)) + done + if is_self "$master"; then + echo "WARNING: forced failover did not complete; starting as master (replicas may resync from an empty dataset)" + fi + fi + {{- end }} + + if [ -n "$master" ]; then + if is_self "$master"; then + echo "this pod is the current master target; starting without replicaof" + master="" + else + echo "starting as replica of $master:$VALKEY_PORT" + fi + elif [ -f "$MARKER" ]; then + echo "master discovery failed for an already bootstrapped node; refusing to start as an unconfirmed master" + exit 1 + else + case "$(hostname)" in + *-0) + echo "Sentinel and peers unavailable during first bootstrap; starting as seed master" + ;; + *) + echo "Sentinel and peers unavailable during first bootstrap; seeding from $SEED_MASTER:$VALKEY_PORT" + master="$SEED_MASTER" + ;; + esac + fi + + # Secrets and per-pod directives are written to a 0600 runtime config instead of + # argv, so they never show up in /proc//cmdline. replica-announce-* keeps + # Sentinel state hostname-based across failovers instead of decaying to pod IPs. + RUNTIME=/data/.valkey-runtime.conf + umask 077 + rm -f "$RUNTIME" + cp /etc/valkey/valkey.conf "$RUNTIME" + { + echo "replica-announce-ip $SELF_FQDN" + echo "replica-announce-port $VALKEY_PORT" + [ -n "$master" ] && echo "replicaof $master $VALKEY_PORT" + {{- if .Values.auth.enabled }} + esc="$(printf '%s' "$VALKEY_PASSWORD" | sed 's/[\\"]/\\&/g')" + printf 'masterauth "%s"\n' "$esc" + printf 'requirepass "%s"\n' "$esc" + {{- end }} + } >> "$RUNTIME" + touch "$MARKER" + exec valkey-server "$RUNTIME" + node-prestop.sh: | + #!/bin/sh + # Graceful demotion: when the active master receives SIGTERM (rollout, drain, + # delete), promote a replica through Sentinel before shutting down instead of + # letting clients eat the full down-after-milliseconds detection window. + # Total budget must stay below terminationGracePeriodSeconds. Never fails. + set -u + + VALKEY_PORT={{ .Values.service.ports.valkey }} + SENTINEL_HOST="{{ include "valkey.sentinelServiceFqdn" . }}" + SENTINEL_PORT={{ .Values.service.ports.sentinel }} + MASTER_SET="{{ .Values.sentinel.masterSet }}" + TLS_ARGS="{{ include "valkey.cliTlsArgs" . }}" + SELF_FQDN="$(hostname -f)" + SELF_IP="$(hostname -i 2>/dev/null | awk '{ print $1 }')" + + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + vcli() { valkey-cli $TLS_ARGS "$@"; } + {{- if and .Values.auth.enabled (not .Values.auth.sentinel) }} + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + scli() { env -u VALKEYCLI_AUTH valkey-cli $TLS_ARGS -h "$SENTINEL_HOST" -p "$SENTINEL_PORT" "$@"; } + {{- else }} + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + scli() { valkey-cli $TLS_ARGS -h "$SENTINEL_HOST" -p "$SENTINEL_PORT" "$@"; } + {{- end }} + + role="$(vcli -p "$VALKEY_PORT" info replication 2>/dev/null | sed -n 's/^role://p' | tr -d '\r')" + [ "$role" = "master" ] || exit 0 + + slaves="$(vcli -p "$VALKEY_PORT" info replication 2>/dev/null | sed -n 's/^connected_slaves://p' | tr -d '\r')" + case "${slaves:-0}" in '' | *[!0-9]*) slaves=0 ;; esac + if [ "$slaves" -lt 1 ]; then + echo "active master has no connected replicas; nothing to promote" + exit 0 + fi + + echo "active master shutting down; requesting Sentinel failover" + i=0 + while [ "$i" -lt {{ int .Values.sentinel.gracefulFailover.maxAttempts }} ]; do + scli sentinel failover "$MASTER_SET" >/dev/null 2>&1 || true + sleep 3 + m="$(scli sentinel get-master-addr-by-name "$MASTER_SET" 2>/dev/null | awk 'NR == 1 { print $1 }')" + if [ -n "$m" ] && [ "$m" != "$SELF_FQDN" ] && [ "$m" != "$SELF_IP" ]; then + echo "failover completed: new master is $m" + exit 0 + fi + i=$((i + 1)) + done + echo "failover not confirmed within budget; continuing shutdown" + exit 0 + sentinel-entrypoint.sh: | + #!/bin/sh + set -eu + + VALKEY_PORT={{ .Values.service.ports.valkey }} + SENTINEL_PORT={{ .Values.service.ports.sentinel }} + MASTER_SET="{{ .Values.sentinel.masterSet }}" + SEED_MASTER="{{ include "valkey.nodePodFqdn" . }}" + PEERS="{{ include "valkey.nodePeerFqdns" . }}" + TLS_ARGS="{{ include "valkey.cliTlsArgs" . }}" + CONF=/data/sentinel.conf + BASE=/etc/valkey-base/sentinel.conf + + # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list + {{- if .Values.auth.enabled }} + vcli() { VALKEYCLI_AUTH="$VALKEY_PASSWORD" valkey-cli $TLS_ARGS "$@"; } + {{- else }} + vcli() { valkey-cli $TLS_ARGS "$@"; } + {{- end }} + + find_current_master() { + for peer in $PEERS; do + info="$(vcli -h "$peer" -p "$VALKEY_PORT" info replication 2>/dev/null || true)" + [ -z "$info" ] && continue + role="$(printf '%s\n' "$info" | sed -n 's/^role://p' | tr -d '\r')" + if [ "$role" = "master" ]; then + echo "$peer" + return 0 + fi + done + return 1 + } + + if [ ! -f "$CONF" ]; then + # First boot of this Sentinel replica. The static seed (node-0) is only a + # cold-start hint: if a failover already happened and this pod lost its + # emptyDir, monitoring node-0 again would advertise a stale master until + # hello-channel gossip converges. Probe the data plane for the real master. + master="$SEED_MASTER" + i=0 + until vcli -h "$master" -p "$VALKEY_PORT" ping >/dev/null 2>&1; do + i=$((i + 1)) + if m="$(find_current_master)"; then + if [ "$m" != "$master" ]; then + echo "seed $master unavailable; using discovered master $m" + master="$m" + continue + fi + fi + if [ "$i" -ge 60 ]; then + echo "no reachable master after bounded wait; monitoring seed $master anyway" + break + fi + echo "waiting for master $master:$VALKEY_PORT ($i/60)" + sleep 5 + done + cp "$BASE" "$CONF" + if [ "$master" != "$SEED_MASTER" ]; then + sed -i "s|^sentinel monitor $MASTER_SET .*|sentinel monitor $MASTER_SET $master $VALKEY_PORT {{ int .Values.sentinel.quorum }}|" "$CONF" + fi + else + persisted_monitor="$(grep "^sentinel monitor $MASTER_SET " "$CONF" | tail -n 1 || true)" + append_sentinel_base() { + if [ -n "$persisted_monitor" ]; then + awk -v monitor="$persisted_monitor" ' + /^sentinel monitor {{ .Values.sentinel.masterSet }} / { print monitor; next } + { print } + ' "$BASE" >> "$CONF" + else + cat "$BASE" >> "$CONF" + fi + } + if grep -q '^# HelmForge managed sentinel config begin$' "$CONF"; then + sed -i '/^# HelmForge managed sentinel config begin$/,/^# HelmForge managed sentinel config end$/d' "$CONF" + else + sed -i \ + -e '/^port /d' \ + -e '/^tls-port /d' \ + -e '/^tls-cert-file /d' \ + -e '/^tls-key-file /d' \ + -e '/^tls-ca-cert-file /d' \ + -e '/^tls-auth-clients /d' \ + -e '/^tls-replication /d' \ + -e '/^dir /d' \ + -e '/^sentinel resolve-hostnames /d' \ + -e '/^sentinel announce-hostnames /d' \ + -e "/^sentinel monitor $MASTER_SET /d" \ + -e "/^sentinel down-after-milliseconds $MASTER_SET /d" \ + -e "/^sentinel failover-timeout $MASTER_SET /d" \ + -e "/^sentinel parallel-syncs $MASTER_SET /d" \ + "$CONF" + fi + append_sentinel_base + fi + + # Stable per-pod identity: without an announced hostname, every emptyDir + # restart re-registers this Sentinel under a new IP+runid, and the dead entry + # is never garbage-collected, silently inflating the majority required for + # failover authorization. A stable address lets peers replace the old entry. + sed -i -e '/^sentinel announce-ip /d' -e '/^sentinel announce-port /d' "$CONF" + { + echo "sentinel announce-ip $(hostname -f)" + echo "sentinel announce-port $SENTINEL_PORT" + } >> "$CONF" + {{- if .Values.auth.enabled }} + esc="$(printf '%s' "$VALKEY_PASSWORD" | sed 's/[\\"]/\\&/g')" + sed -i "/^sentinel auth-pass $MASTER_SET /d" "$CONF" + printf 'sentinel auth-pass %s "%s"\n' "$MASTER_SET" "$esc" >> "$CONF" + {{- end }} + sed -i -e '/^requirepass /d' -e '/^sentinel sentinel-pass /d' "$CONF" + {{- if and .Values.auth.enabled .Values.auth.sentinel }} + printf 'requirepass "%s"\n' "$esc" >> "$CONF" + printf 'sentinel sentinel-pass "%s"\n' "$esc" >> "$CONF" + {{- end }} + exec valkey-sentinel "$CONF" {{- end }} {{- if eq .Values.architecture "cluster" }} valkey-cluster.conf: | diff --git a/charts/valkey/templates/sentinel-node-statefulset.yaml b/charts/valkey/templates/sentinel-node-statefulset.yaml index 47aa91e0..04903992 100644 --- a/charts/valkey/templates/sentinel-node-statefulset.yaml +++ b/charts/valkey/templates/sentinel-node-statefulset.yaml @@ -40,64 +40,10 @@ spec: - name: wait-for-master image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - command: ["sh", "-ec"] - args: - - | - discover_master_from_peers() { - discovered="" - self_fqdn="$(hostname -f)" - for peer in {{ include "valkey.nodePeerFqdns" . }}; do - [ "$peer" = "$self_fqdn" ] && continue - info="$(valkey-cli {{ include "valkey.cliTlsArgs" . }} -h "$peer" -p {{ .Values.service.ports.valkey }} {{- if .Values.auth.enabled }} -a "$VALKEY_PASSWORD" --no-auth-warning {{- end }} info replication 2>/dev/null || true)" - [ -z "$info" ] && continue - role="$(echo "$info" | sed -n 's/^role://p' | tr -d '\r' | awk '{ print $1 }')" - case "$role" in - master) - discovered="$peer" - break - ;; - slave|replica) - mh="$(echo "$info" | sed -n 's/^master_host://p' | tr -d '\r' | awk '{ print $1 }')" - if [ -n "$mh" ]; then - discovered="$mh" - break - fi - ;; - esac - done - echo "$discovered" - } - sentinel_master="" - for attempt in $(seq 1 30); do - sentinel_master="$(valkey-cli {{ include "valkey.cliTlsArgs" . }}{{- if and .Values.auth.enabled .Values.auth.sentinel }} -a "$VALKEY_PASSWORD" --no-auth-warning {{- end }} -h {{ include "valkey.sentinelServiceFqdn" . | quote }} -p {{ .Values.service.ports.sentinel }} sentinel get-master-addr-by-name {{ .Values.sentinel.masterSet }} 2>/dev/null | awk 'NR == 1 { print $1 }')" - [ -n "$sentinel_master" ] && break - echo "waiting for Sentinel master discovery ($attempt/30)" - sleep 2 - done - if [ -z "$sentinel_master" ]; then - sentinel_master="$(discover_master_from_peers)" - if [ -n "$sentinel_master" ]; then - echo "discovered master ${sentinel_master} from peer nodes" - elif [ -f /data/.helmforge-sentinel-node-bootstrapped ]; then - echo "Sentinel and peers unavailable for already bootstrapped node; main container will fail closed if the role cannot be confirmed" - exit 0 - else - sentinel_master={{ include "valkey.nodePodFqdn" . | quote }} - fi - fi - self_fqdn="$(hostname -f)" - self_ip="$(hostname -i | awk '{ print $1 }')" - if [ "$sentinel_master" = "$self_fqdn" ] || [ "$sentinel_master" = "$self_ip" ]; then - echo "this pod is the master target; skipping init wait" - exit 0 - fi - until valkey-cli {{ include "valkey.cliTlsArgs" . }} -h "$sentinel_master" -p {{ .Values.service.ports.valkey }} {{- if .Values.auth.enabled }} -a "$VALKEY_PASSWORD" --no-auth-warning {{- end }} ping >/dev/null 2>&1; do - echo "waiting for master ${sentinel_master}:{{ .Values.service.ports.valkey }}" - sleep 5 - done + command: ["sh", "/scripts/node-entrypoint.sh", "--wait-only"] {{- if .Values.auth.enabled }} env: - - name: VALKEY_PASSWORD + - name: VALKEYCLI_AUTH valueFrom: secretKeyRef: name: {{ include "valkey.secretName" . }} @@ -106,8 +52,9 @@ spec: securityContext: {{- toYaml .Values.securityContext | nindent 12 }} volumeMounts: - - name: data - mountPath: /data + - name: scripts + mountPath: /scripts + readOnly: true {{- if .Values.tls.enabled }} - name: tls mountPath: /tls @@ -117,76 +64,13 @@ spec: - name: valkey image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - command: ["sh", "-ec"] - args: - - | - discover_master_from_peers() { - discovered="" - self_fqdn="$(hostname -f)" - for peer in {{ include "valkey.nodePeerFqdns" . }}; do - [ "$peer" = "$self_fqdn" ] && continue - info="$(valkey-cli {{ include "valkey.cliTlsArgs" . }} -h "$peer" -p {{ .Values.service.ports.valkey }} {{- if .Values.auth.enabled }} -a "$VALKEY_PASSWORD" --no-auth-warning {{- end }} info replication 2>/dev/null || true)" - [ -z "$info" ] && continue - role="$(echo "$info" | sed -n 's/^role://p' | tr -d '\r' | awk '{ print $1 }')" - case "$role" in - master) - discovered="$peer" - break - ;; - slave|replica) - mh="$(echo "$info" | sed -n 's/^master_host://p' | tr -d '\r' | awk '{ print $1 }')" - if [ -n "$mh" ]; then - discovered="$mh" - break - fi - ;; - esac - done - echo "$discovered" - } - set -- /etc/valkey/valkey.conf - bootstrap_marker="/data/.helmforge-sentinel-node-bootstrapped" - sentinel_master="" - for attempt in $(seq 1 30); do - sentinel_master="$(valkey-cli {{ include "valkey.cliTlsArgs" . }}{{- if and .Values.auth.enabled .Values.auth.sentinel }} -a "$VALKEY_PASSWORD" --no-auth-warning {{- end }} -h {{ include "valkey.sentinelServiceFqdn" . | quote }} -p {{ .Values.service.ports.sentinel }} sentinel get-master-addr-by-name {{ .Values.sentinel.masterSet }} 2>/dev/null | awk 'NR == 1 { print $1 }')" - [ -n "$sentinel_master" ] && break - echo "waiting for Sentinel master discovery ($attempt/30)" - sleep 2 - done - self_fqdn="$(hostname -f)" - self_ip="$(hostname -i | awk '{ print $1 }')" - if [ -z "$sentinel_master" ]; then - sentinel_master="$(discover_master_from_peers)" - if [ -n "$sentinel_master" ]; then - echo "discovered master ${sentinel_master} from peer nodes" - fi - fi - if [ -n "$sentinel_master" ]; then - if [ "$sentinel_master" != "$self_fqdn" ] && [ "$sentinel_master" != "$self_ip" ]; then - echo "starting as replica of master ${sentinel_master}:{{ .Values.service.ports.valkey }}" - set -- "$@" --replicaof "$sentinel_master" {{ .Values.service.ports.valkey }} - else - echo "this pod is the current master target; starting without replicaof" - fi - elif [ -f "$bootstrap_marker" ]; then - echo "master discovery failed for an already bootstrapped node; refusing to start as an unconfirmed master" - exit 1 - else - case "$(hostname)" in - *-0) - echo "Sentinel and peers unavailable during first bootstrap; starting as seed master" - ;; - *) - echo "Sentinel and peers unavailable during first bootstrap; seeding from {{ include "valkey.nodePodFqdn" . }}:{{ .Values.service.ports.valkey }}" - set -- "$@" --replicaof {{ include "valkey.nodePodFqdn" . | quote }} {{ .Values.service.ports.valkey }} - ;; - esac - fi - touch "$bootstrap_marker" - {{- if .Values.auth.enabled }} - set -- "$@" --masterauth "$VALKEY_PASSWORD" --requirepass "$VALKEY_PASSWORD" - {{- end }} - exec valkey-server "$@" + command: ["sh", "/scripts/node-entrypoint.sh"] + {{- if .Values.sentinel.gracefulFailover.enabled }} + lifecycle: + preStop: + exec: + command: ["sh", "/scripts/node-prestop.sh"] + {{- end }} ports: - name: valkey containerPort: {{ .Values.service.ports.valkey }} @@ -197,6 +81,11 @@ spec: secretKeyRef: name: {{ include "valkey.secretName" . }} key: {{ .Values.auth.existingSecretPasswordKey }} + - name: VALKEYCLI_AUTH + valueFrom: + secretKeyRef: + name: {{ include "valkey.secretName" . }} + key: {{ .Values.auth.existingSecretPasswordKey }} {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} @@ -206,21 +95,21 @@ spec: command: - sh - -ec - - {{ include "valkey.probeCommand" . | quote }} + - {{ include "valkey.nodeProbeCommand" . | quote }} {{- toYaml .Values.livenessProbe | nindent 12 }} readinessProbe: exec: command: - sh - -ec - - {{ include "valkey.probeCommand" . | quote }} + - {{ include "valkey.nodeProbeCommand" . | quote }} {{- toYaml .Values.readinessProbe | nindent 12 }} startupProbe: exec: command: - sh - -ec - - {{ include "valkey.probeCommand" . | quote }} + - {{ include "valkey.nodeProbeCommand" . | quote }} {{- toYaml .Values.startupProbe | nindent 12 }} {{- with .Values.node.resources }} resources: @@ -232,6 +121,9 @@ spec: - name: config mountPath: /etc/valkey/valkey.conf subPath: valkey-node.conf + - name: scripts + mountPath: /scripts + readOnly: true - name: data mountPath: /data {{- if .Values.tls.enabled }} @@ -263,6 +155,15 @@ spec: - name: config configMap: name: {{ include "valkey.configMapName" . }} + - name: scripts + configMap: + name: {{ include "valkey.configMapName" . }} + defaultMode: 0555 + items: + - key: node-entrypoint.sh + path: node-entrypoint.sh + - key: node-prestop.sh + path: node-prestop.sh {{- if not .Values.node.persistence.enabled }} - name: data emptyDir: {} diff --git a/charts/valkey/templates/sentinel-statefulset.yaml b/charts/valkey/templates/sentinel-statefulset.yaml index 9e0fdfa0..33a1438b 100644 --- a/charts/valkey/templates/sentinel-statefulset.yaml +++ b/charts/valkey/templates/sentinel-statefulset.yaml @@ -10,6 +10,9 @@ metadata: spec: serviceName: {{ include "valkey.sentinelServiceName" . }} replicas: {{ .Values.sentinel.replicaCount }} + {{- with .Values.sentinel.podManagementPolicy }} + podManagementPolicy: {{ . }} + {{- end }} selector: matchLabels: {{- include "valkey.sentinelLabels" . | nindent 6 }} @@ -34,90 +37,49 @@ spec: - name: sentinel image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} - command: ["sh", "-ec"] - args: - - | - if [ ! -f /data/sentinel.conf ]; then - until valkey-cli $VALKEY_TLS_ARGS -h "$VALKEY_PRIMARY_HOST" -p "$VALKEY_PRIMARY_PORT" {{- if .Values.auth.enabled }} -a "$VALKEY_PASSWORD" --no-auth-warning {{- end }} ping >/dev/null 2>&1; do - echo "waiting for primary $VALKEY_PRIMARY_HOST:$VALKEY_PRIMARY_PORT" - sleep 5 - done - cp /etc/valkey-base/sentinel.conf /data/sentinel.conf - else - persisted_monitor="$(grep '^sentinel monitor {{ .Values.sentinel.masterSet }} ' /data/sentinel.conf | tail -n 1 || true)" - append_sentinel_base() { - if [ -n "$persisted_monitor" ]; then - awk -v monitor="$persisted_monitor" ' - /^sentinel monitor {{ .Values.sentinel.masterSet }} / { print monitor; next } - { print } - ' /etc/valkey-base/sentinel.conf >> /data/sentinel.conf - else - cat /etc/valkey-base/sentinel.conf >> /data/sentinel.conf - fi - } - if grep -q '^# HelmForge managed sentinel config begin$' /data/sentinel.conf; then - sed -i '/^# HelmForge managed sentinel config begin$/,/^# HelmForge managed sentinel config end$/d' /data/sentinel.conf - else - sed -i \ - -e '/^port /d' \ - -e '/^tls-port /d' \ - -e '/^tls-cert-file /d' \ - -e '/^tls-key-file /d' \ - -e '/^tls-ca-cert-file /d' \ - -e '/^tls-auth-clients /d' \ - -e '/^tls-replication /d' \ - -e '/^dir /d' \ - -e '/^sentinel resolve-hostnames /d' \ - -e '/^sentinel announce-hostnames /d' \ - -e '/^sentinel monitor {{ .Values.sentinel.masterSet }} /d' \ - -e '/^sentinel down-after-milliseconds {{ .Values.sentinel.masterSet }} /d' \ - -e '/^sentinel failover-timeout {{ .Values.sentinel.masterSet }} /d' \ - -e '/^sentinel parallel-syncs {{ .Values.sentinel.masterSet }} /d' \ - /data/sentinel.conf - fi - append_sentinel_base - fi - {{- if .Values.auth.enabled }} - sed -i '/^sentinel auth-pass {{ .Values.sentinel.masterSet }} /d' /data/sentinel.conf - echo "sentinel auth-pass {{ .Values.sentinel.masterSet }} ${VALKEY_PASSWORD}" >> /data/sentinel.conf - {{- end }} - sed -i -e '/^requirepass /d' -e '/^sentinel sentinel-pass /d' /data/sentinel.conf - {{- if and .Values.auth.enabled .Values.auth.sentinel }} - echo "requirepass ${VALKEY_PASSWORD}" >> /data/sentinel.conf - echo "sentinel sentinel-pass ${VALKEY_PASSWORD}" >> /data/sentinel.conf - {{- end }} - exec valkey-sentinel /data/sentinel.conf + command: ["sh", "/scripts/sentinel-entrypoint.sh"] ports: - name: sentinel containerPort: {{ .Values.service.ports.sentinel }} - {{- if .Values.auth.enabled }} env: - - name: VALKEY_PRIMARY_HOST - value: {{ include "valkey.nodePodFqdn" . | quote }} - - name: VALKEY_PRIMARY_PORT - value: {{ .Values.service.ports.valkey | quote }} - - name: VALKEY_TLS_ARGS - value: {{ include "valkey.cliTlsArgs" . | quote }} + {{- if .Values.auth.enabled }} - name: VALKEY_PASSWORD valueFrom: secretKeyRef: name: {{ include "valkey.secretName" . }} key: {{ .Values.auth.existingSecretPasswordKey }} - {{- with .Values.extraEnv }} - {{- toYaml . | nindent 12 }} {{- end }} - {{- else }} - env: - - name: VALKEY_PRIMARY_HOST - value: {{ include "valkey.nodePodFqdn" . | quote }} - - name: VALKEY_PRIMARY_PORT - value: {{ .Values.service.ports.valkey | quote }} - - name: VALKEY_TLS_ARGS - value: {{ include "valkey.cliTlsArgs" . | quote }} + {{- if and .Values.auth.enabled .Values.auth.sentinel }} + - name: VALKEYCLI_AUTH + valueFrom: + secretKeyRef: + name: {{ include "valkey.secretName" . }} + key: {{ .Values.auth.existingSecretPasswordKey }} + {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} - {{- end }} + livenessProbe: + exec: + command: + - sh + - -ec + - {{ include "valkey.sentinelProbeCommand" . | quote }} + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + exec: + command: + - sh + - -ec + - {{ include "valkey.sentinelReadyCommand" . | quote }} + {{- toYaml .Values.readinessProbe | nindent 12 }} + startupProbe: + exec: + command: + - sh + - -ec + - {{ include "valkey.sentinelProbeCommand" . | quote }} + {{- toYaml .Values.startupProbe | nindent 12 }} {{- with .Values.sentinel.resources }} resources: {{- toYaml . | nindent 12 }} @@ -128,6 +90,9 @@ spec: - name: config mountPath: /etc/valkey-base/sentinel.conf subPath: sentinel.conf + - name: scripts + mountPath: /scripts + readOnly: true - name: data mountPath: /data {{- if .Values.tls.enabled }} @@ -142,6 +107,13 @@ spec: - name: config configMap: name: {{ include "valkey.configMapName" . }} + - name: scripts + configMap: + name: {{ include "valkey.configMapName" . }} + defaultMode: 0555 + items: + - key: sentinel-entrypoint.sh + path: sentinel-entrypoint.sh {{- if not .Values.sentinel.persistence.enabled }} - name: data emptyDir: {} diff --git a/charts/valkey/values.schema.json b/charts/valkey/values.schema.json index fffbc20d..c581c71b 100644 --- a/charts/valkey/values.schema.json +++ b/charts/valkey/values.schema.json @@ -249,6 +249,39 @@ "type": "integer", "description": "Number of parallel syncs during failover" }, + "podManagementPolicy": { + "type": "string", + "enum": ["", "OrderedReady", "Parallel"], + "description": "StatefulSet podManagementPolicy for Sentinel pods (immutable on existing StatefulSets; empty keeps the API default)" + }, + "gracefulFailover": { + "type": "object", + "description": "Trigger a Sentinel failover from a preStop hook when the active master shuts down", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxAttempts": { + "type": "integer", + "minimum": 1, + "description": "Failover confirmation attempts (~3s each); keep total below terminationGracePeriodSeconds" + } + } + }, + "startupFailoverGuard": { + "type": "object", + "description": "On a fresh data dir, force a failover instead of resuming as an empty master while peers still hold data", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxAttempts": { + "type": "integer", + "minimum": 1, + "description": "Failover confirmation attempts (~3s each) before falling back to starting as master" + } + } + }, "persistence": { "type": "object", "description": "Persistence settings for sentinel", diff --git a/charts/valkey/values.yaml b/charts/valkey/values.yaml index d0ade357..3beb608b 100644 --- a/charts/valkey/values.yaml +++ b/charts/valkey/values.yaml @@ -150,6 +150,10 @@ node: sentinel: # -- Number of Sentinel pods replicaCount: 3 + # -- StatefulSet podManagementPolicy for Sentinel pods. "Parallel" is recommended + # for new installs (Sentinels are symmetric peers); left empty for upgrade + # compatibility because this field is immutable on existing StatefulSets. + podManagementPolicy: "" # -- Sentinel master set name used by Sentinel clients masterSet: mymaster # -- Sentinel quorum required for failover decisions @@ -160,6 +164,16 @@ sentinel: failoverTimeout: 180000 # -- Number of replicas that can reconfigure in parallel during failover parallelSyncs: 1 + gracefulFailover: + # -- Trigger a Sentinel failover from a preStop hook when the active master shuts down (rollouts, drains, deletes) + enabled: true + # -- Failover confirmation attempts (~3s each). Keep total below terminationGracePeriodSeconds + maxAttempts: 12 + startupFailoverGuard: + # -- On a fresh data dir, refuse to resume as master while peers still hold data; force a failover first (prevents replicas resyncing from an empty dataset) + enabled: true + # -- Failover confirmation attempts (~3s each) before falling back to starting as master + maxAttempts: 20 persistence: # -- Enable persistent storage for Sentinel pods enabled: false From f0763b251d15dd7f986f520f43c3255060c73bde Mon Sep 17 00:00:00 2001 From: Mathieu REHO Date: Fri, 3 Jul 2026 19:29:54 +0200 Subject: [PATCH 2/5] test(valkey): updated tests for sentinel failover hardening --- charts/valkey/tests/cluster_domain_test.yaml | 24 ++- charts/valkey/tests/ha_extension_test.yaml | 146 +++++++++---------- charts/valkey/tests/sentinel_node_test.yaml | 74 +++++++--- 3 files changed, 132 insertions(+), 112 deletions(-) diff --git a/charts/valkey/tests/cluster_domain_test.yaml b/charts/valkey/tests/cluster_domain_test.yaml index 7023cbd2..3ca5155a 100644 --- a/charts/valkey/tests/cluster_domain_test.yaml +++ b/charts/valkey/tests/cluster_domain_test.yaml @@ -41,32 +41,30 @@ tests: pattern: "svc\\.cluster\\.local" - it: should use custom cluster domain in sentinel node bootstrap args - template: sentinel-node-statefulset.yaml + template: configmap.yaml set: architecture: sentinel clusterDomain: corp.internal asserts: - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: 'seeding from test-valkey-node-0\.test-valkey-headless\.valkey-ns\.svc\.corp\.internal' + path: data["node-entrypoint.sh"] + pattern: 'SEED_MASTER="test-valkey-node-0\.test-valkey-headless\.valkey-ns\.svc\.corp\.internal"' - notMatchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "svc\\.cluster\\.local" + path: data["node-entrypoint.sh"] + pattern: 'svc\.cluster\.local' - it: should wait for custom seed master FQDN before starting sentinel - template: sentinel-statefulset.yaml + template: configmap.yaml set: architecture: sentinel clusterDomain: corp.internal asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: VALKEY_PRIMARY_HOST - value: test-valkey-node-0.test-valkey-headless.valkey-ns.svc.corp.internal - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "until valkey-cli \\$VALKEY_TLS_ARGS -h \"\\$VALKEY_PRIMARY_HOST\"" + path: data["sentinel-entrypoint.sh"] + pattern: 'SEED_MASTER="test-valkey-node-0\.test-valkey-headless\.valkey-ns\.svc\.corp\.internal"' + - matchRegex: + path: data["sentinel-entrypoint.sh"] + pattern: 'until vcli -h "\$master" -p "\$VALKEY_PORT" ping' - it: should use custom cluster domain in cluster announce hostname template: cluster-statefulset.yaml diff --git a/charts/valkey/tests/ha_extension_test.yaml b/charts/valkey/tests/ha_extension_test.yaml index 27dce1b5..35cc371c 100644 --- a/charts/valkey/tests/ha_extension_test.yaml +++ b/charts/valkey/tests/ha_extension_test.yaml @@ -85,45 +85,45 @@ tests: sentinel.persistence.enabled: true asserts: - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "exec valkey-sentinel /data/sentinel.conf" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'exec valkey-sentinel "\$CONF"' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "if \\[ ! -f /data/sentinel.conf \\]; then" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'if \[ ! -f "\$CONF" \]; then' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "if \\[ ! -f /data/sentinel.conf \\]; then\\n.*until valkey-cli" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'if \[ ! -f "\$CONF" \]; then[\s\S]*?until vcli' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "sed -i '/\\^sentinel auth-pass mymaster /d' /data/sentinel.conf" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'sed -i "/\^sentinel auth-pass \$MASTER_SET /d"' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "HelmForge managed sentinel config begin" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'HelmForge managed sentinel config begin' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "sentinel down-after-milliseconds mymaster /d" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'sentinel down-after-milliseconds \$MASTER_SET /d' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "persisted_monitor=\"\\$\\(grep '\\^sentinel monitor mymaster ' /data/sentinel.conf" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'persisted_monitor="\$\(grep "\^sentinel monitor \$MASTER_SET " "\$CONF"' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "append_sentinel_base\\(\\)" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'append_sentinel_base\(\)' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "awk -v monitor=\"\\$persisted_monitor\"" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: 'awk -v monitor="\$persisted_monitor"' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "\\^sentinel monitor mymaster / \\{ print monitor; next \\}" - template: sentinel-statefulset.yaml + path: data["sentinel-entrypoint.sh"] + pattern: '\^sentinel monitor mymaster / \{ print monitor; next \}' + template: configmap.yaml - contains: path: spec.template.spec.containers[0].volumeMounts content: @@ -185,47 +185,43 @@ tests: architecture: sentinel asserts: - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: "sentinel get-master-addr-by-name mymaster" - template: sentinel-node-statefulset.yaml - - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: "Sentinel and peers unavailable for already bootstrapped node; main container will fail closed" - template: sentinel-node-statefulset.yaml - - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: "info replication" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'sentinel get-master-addr-by-name "\$MASTER_SET"' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "sentinel get-master-addr-by-name mymaster" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'refusing to start as an unconfirmed master' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "info replication" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'info replication' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "set -- \"\\$@\" --replicaof \"\\$sentinel_master\"" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'replicaof \$master \$VALKEY_PORT' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "refusing to start as an unconfirmed master" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'this pod is the current master target; starting without replicaof' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "this pod is the current master target; starting without replicaof" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'helmforge-sentinel-node-bootstrapped' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "helmforge-sentinel-node-bootstrapped" + path: data["node-entrypoint.sh"] + pattern: 'deferring to main container' + template: configmap.yaml + - contains: + path: spec.template.spec.initContainers[0].command + content: /scripts/node-entrypoint.sh template: sentinel-node-statefulset.yaml - - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: "this pod is the master target; skipping init wait" + - contains: + path: spec.template.spec.containers[0].command + content: /scripts/node-entrypoint.sh template: sentinel-node-statefulset.yaml - contains: - path: spec.template.spec.initContainers[0].volumeMounts + path: spec.template.spec.containers[0].volumeMounts content: name: data mountPath: /data @@ -236,26 +232,26 @@ tests: architecture: sentinel asserts: - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "case \"\\$\\(hostname\\)\" in" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'case "\$\(hostname\)" in' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "Sentinel and peers unavailable during first bootstrap; starting as seed master" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'Sentinel and peers unavailable during first bootstrap; starting as seed master' + template: configmap.yaml - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "seeding from test-valkey-node-0\\.test-valkey-headless\\.valkey-ns\\.svc\\.cluster\\.local" - template: sentinel-node-statefulset.yaml + path: data["node-entrypoint.sh"] + pattern: 'SEED_MASTER="test-valkey-node-0\.test-valkey-headless\.valkey-ns\.svc\.cluster\.local"' + template: configmap.yaml - it: should monitor the seed master node in sentinel pods set: architecture: sentinel asserts: - - equal: - path: spec.template.spec.containers[0].env[0].value - value: test-valkey-node-0.test-valkey-headless.valkey-ns.svc.cluster.local - template: sentinel-statefulset.yaml + - matchRegex: + path: data["sentinel.conf"] + pattern: 'sentinel monitor mymaster test-valkey-node-0\.test-valkey-headless\.valkey-ns\.svc\.cluster\.local 6379' + template: configmap.yaml - it: should keep fixed replicaof in replication mode set: diff --git a/charts/valkey/tests/sentinel_node_test.yaml b/charts/valkey/tests/sentinel_node_test.yaml index a19d24cb..b70a5ada 100644 --- a/charts/valkey/tests/sentinel_node_test.yaml +++ b/charts/valkey/tests/sentinel_node_test.yaml @@ -121,16 +121,25 @@ tests: emptyDir: {} - it: should probe peer nodes for master discovery - template: sentinel-node-statefulset.yaml set: architecture: sentinel asserts: - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: discover_master_from_peers + path: data["node-entrypoint.sh"] + pattern: 'peer_master\(\)' + template: configmap.yaml - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: discover_master_from_peers + path: data["node-entrypoint.sh"] + pattern: 'discover_master\(\)' + template: configmap.yaml + - contains: + path: spec.template.spec.containers[0].command + content: /scripts/node-entrypoint.sh + template: sentinel-node-statefulset.yaml + - contains: + path: spec.template.spec.initContainers[0].command + content: /scripts/node-entrypoint.sh + template: sentinel-node-statefulset.yaml - it: should set securityContext on the metrics sidecar template: sentinel-node-statefulset.yaml @@ -182,14 +191,17 @@ tests: value: sentinel - it: should use the custom Sentinel master set for auth-pass - template: sentinel-statefulset.yaml + template: configmap.yaml set: architecture: sentinel sentinel.masterSet: legacy-master asserts: - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: "sentinel auth-pass legacy-master \\$\\{VALKEY_PASSWORD\\}" + path: data["sentinel-entrypoint.sh"] + pattern: 'MASTER_SET="legacy-master"' + - matchRegex: + path: data["sentinel-entrypoint.sh"] + pattern: 'sentinel auth-pass %s' - it: injects sentinel requirepass and sentinel-pass when auth.sentinel=true set: @@ -197,28 +209,27 @@ tests: auth: enabled: true sentinel: true - template: sentinel-statefulset.yaml + template: configmap.yaml asserts: - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: 'echo "requirepass \$\{VALKEY_PASSWORD\}"' + path: data["sentinel-entrypoint.sh"] + pattern: "printf 'requirepass \"%s\"" - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: 'echo "sentinel sentinel-pass \$\{VALKEY_PASSWORD\}"' - + path: data["sentinel-entrypoint.sh"] + pattern: "printf 'sentinel sentinel-pass \"%s\"" - it: omits sentinel auth when auth.sentinel=false but keeps the strip set: architecture: sentinel auth: enabled: true sentinel: false - template: sentinel-statefulset.yaml + template: configmap.yaml asserts: - notMatchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: 'echo "requirepass' + path: data["sentinel-entrypoint.sh"] + pattern: "printf 'requirepass" - matchRegex: - path: spec.template.spec.containers[0].args[0] + path: data["sentinel-entrypoint.sh"] pattern: "sed -i -e '/\\^requirepass /d'" - it: both node containers authenticate to Sentinel when auth.sentinel=true @@ -227,11 +238,26 @@ tests: auth: enabled: true sentinel: true - template: sentinel-node-statefulset.yaml asserts: - matchRegex: - path: spec.template.spec.initContainers[0].args[0] - pattern: '-a "\$VALKEY_PASSWORD"[^\n]*sentinel get-master-addr-by-name' - - matchRegex: - path: spec.template.spec.containers[0].args[0] - pattern: '-a "\$VALKEY_PASSWORD"[^\n]*sentinel get-master-addr-by-name' + path: data["node-entrypoint.sh"] + pattern: 'scli sentinel get-master-addr-by-name' + template: configmap.yaml + - contains: + path: spec.template.spec.initContainers[0].env + content: + name: VALKEYCLI_AUTH + valueFrom: + secretKeyRef: + name: test-valkey-auth + key: valkey-password + template: sentinel-node-statefulset.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: VALKEYCLI_AUTH + valueFrom: + secretKeyRef: + name: test-valkey-auth + key: valkey-password + template: sentinel-node-statefulset.yaml \ No newline at end of file From fcda7d0f445ba09b2810182f277d7f20b2909091 Mon Sep 17 00:00:00 2001 From: Mathieu REHO Date: Sat, 4 Jul 2026 11:35:17 +0200 Subject: [PATCH 3/5] fix(valkey): added publishNotReadyAddresses for sentinel master bootstrap --- charts/valkey/templates/service.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/charts/valkey/templates/service.yaml b/charts/valkey/templates/service.yaml index 364a9828..c49daa24 100644 --- a/charts/valkey/templates/service.yaml +++ b/charts/valkey/templates/service.yaml @@ -159,6 +159,9 @@ metadata: {{- include "valkey.renderServiceAnnotations" (dict "root" . "annotations" .Values.service.sentinelAnnotations) | nindent 2 }} spec: type: ClusterIP + # Publish per-pod DNS records for Sentinels that are not yet Ready, so peers + # can resolve each other during bootstrap before any master is known. + publishNotReadyAddresses: true {{- with .Values.service.ipFamilyPolicy }} ipFamilyPolicy: {{ . }} {{- end }} From e78717b2746a90d4ab56a951815fb2e5fa84eb5b Mon Sep 17 00:00:00 2001 From: Mathieu REHO Date: Sat, 4 Jul 2026 11:35:46 +0200 Subject: [PATCH 4/5] fix(valkey): keep the Sentinel wait below the startup budget --- charts/valkey/templates/configmap.yaml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/charts/valkey/templates/configmap.yaml b/charts/valkey/templates/configmap.yaml index 3fbcd0a5..0e2d5c24 100644 --- a/charts/valkey/templates/configmap.yaml +++ b/charts/valkey/templates/configmap.yaml @@ -312,9 +312,9 @@ data: # shellcheck disable=SC2086 # TLS_ARGS is an intentional flag list {{- if .Values.auth.enabled }} - vcli() { VALKEYCLI_AUTH="$VALKEY_PASSWORD" valkey-cli $TLS_ARGS "$@"; } + vcli() { VALKEYCLI_AUTH="$VALKEY_PASSWORD" timeout 5 valkey-cli $TLS_ARGS "$@"; } {{- else }} - vcli() { valkey-cli $TLS_ARGS "$@"; } + vcli() { timeout 5 valkey-cli $TLS_ARGS "$@"; } {{- end }} find_current_master() { @@ -346,12 +346,12 @@ data: continue fi fi - if [ "$i" -ge 60 ]; then + if [ "$i" -ge 6 ]; then echo "no reachable master after bounded wait; monitoring seed $master anyway" break fi - echo "waiting for master $master:$VALKEY_PORT ($i/60)" - sleep 5 + echo "waiting for master $master:$VALKEY_PORT ($i/6)" + sleep 3 done cp "$BASE" "$CONF" if [ "$master" != "$SEED_MASTER" ]; then @@ -411,6 +411,17 @@ data: printf 'requirepass "%s"\n' "$esc" >> "$CONF" printf 'sentinel sentinel-pass "%s"\n' "$esc" >> "$CONF" {{- end }} + + # Optional hardening: pin a stable Sentinel identity (runid). Sentinel only + # generates a random myid when none is set, so a deterministic one derived + # from the pod's ordinal name and this release keeps the id stable across + # emptyDir restarts without a PVC, so peers update the existing entry instead + # of accumulating dead ones. Inputs are DNS-independent (short hostname from + # the kernel, namespace/name baked at render time). + if ! grep -q '^sentinel myid ' "$CONF"; then + MYID="$(printf '%s' '{{ .Release.Namespace }}/{{ include "valkey.fullname" . }}/'"$(hostname)" | sha1sum | awk '{ print $1 }')" + echo "sentinel myid $MYID" >> "$CONF" + fi exec valkey-sentinel "$CONF" {{- end }} {{- if eq .Values.architecture "cluster" }} From c62670cf952b0c480c55485a6bdd0d7d6e694df9 Mon Sep 17 00:00:00 2001 From: Mathieu REHO Date: Sat, 4 Jul 2026 12:02:12 +0200 Subject: [PATCH 5/5] fix(valkey): revert sentinel publish-not-ready bootstrap change --- charts/valkey/templates/service.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/charts/valkey/templates/service.yaml b/charts/valkey/templates/service.yaml index c49daa24..364a9828 100644 --- a/charts/valkey/templates/service.yaml +++ b/charts/valkey/templates/service.yaml @@ -159,9 +159,6 @@ metadata: {{- include "valkey.renderServiceAnnotations" (dict "root" . "annotations" .Values.service.sentinelAnnotations) | nindent 2 }} spec: type: ClusterIP - # Publish per-pod DNS records for Sentinels that are not yet Ready, so peers - # can resolve each other during bootstrap before any master is known. - publishNotReadyAddresses: true {{- with .Values.service.ipFamilyPolicy }} ipFamilyPolicy: {{ . }} {{- end }}