Skip to content

Commit f5870ec

Browse files
feat: v0.2 follow-ups — PSS namespace, durable+Replication gate, Sentinel ACL, HA test (#18)
Hardening and safety follow-ups on top of v0.2.0: - S2: the valkey-cluster chart can create the release Namespace with Pod Security Standards labels (namespace.create / namespace.podSecurityStandard), so the restricted-PSA-compatible pods are actually enforced. - AR1/EC1: the validating webhook now rejects profile=Durable on a Replication topology at create time (operator-arbitrated failover has a split-brain window on a network partition). Acknowledge to proceed via the annotation valkey.wellcake.io/accept-replication-durability-risk=true. Existing clusters are not newly rejected on update, so an operator upgrade never strands a running deployment. Covered by table tests + an update-path test; documented in docs/architecture.md. - S1: narrow the Sentinel ACL user from +@ALL to the minimal Sentinel command set (health/role, the __sentinel__:hello pub/sub, the failover transaction, CONFIG REWRITE, CLIENT/SCRIPT KILL); still no key access. Validated live: the narrowed user drove a full master->replica failover with zero NOPERM. - Op1: an envtest spec proving HA handover between leader-elected operator replicas — the standby does not steal an actively-renewed lease and takes over once the active leader steps down (real coordination.k8s.io Lease path). Verification-only (no code change): the Cluster restore-assembly (C2) was re-confirmed lossless on real Valkey nodes (sparse-slot RDBs gap-filled to state:ok, 16384 slots, 0 key loss).
1 parent 8ad6516 commit f5870ec

10 files changed

Lines changed: 259 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
55
follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- The `valkey-cluster` chart can create the release Namespace and label it with
11+
the Pod Security Standards (`namespace.create`, `namespace.podSecurityStandard`),
12+
so the restricted-PSA-compatible Valkey pods are actually enforced (S2).
13+
14+
### Changed
15+
- The validating webhook now **rejects** `profile: Durable` on a `Replication`
16+
topology at create time — that combination relies on operator-arbitrated
17+
failover, which has a split-brain window on a network partition (AR1/EC1).
18+
Acknowledge the risk to proceed by setting the annotation
19+
`valkey.wellcake.io/accept-replication-durability-risk: "true"`; for durable
20+
data prefer `Sentinel` or `Cluster`. Existing clusters are unaffected on update.
21+
- The Sentinel ACL user is narrowed from all commands to the minimal Sentinel
22+
command set (health/role checks, the `__sentinel__:hello` pub/sub, the failover
23+
transaction, `CONFIG REWRITE`, and `CLIENT`/`SCRIPT KILL`); it still carries no
24+
key access — least privilege for the user Sentinel authenticates as (S1).
25+
726
## [0.2.0]
827

928
Hardens auth handling and makes every operator-managed pod compatible with the
@@ -67,6 +86,7 @@ First public release of the operator. Highlights of the initial feature set:
6786
- CEL XValidation for immutable and conditional fields; config-hash-driven
6887
rolling restarts; version-gated Valkey 9.x resilience directives.
6988

89+
[Unreleased]: https://github.com/melancholictheory/wellcake/compare/v0.2.0...HEAD
7090
[0.2.0]: https://github.com/melancholictheory/wellcake/releases/tag/v0.2.0
7191
[0.1.1]: https://github.com/melancholictheory/wellcake/releases/tag/v0.1.1
7292
[0.1.0]: https://github.com/melancholictheory/wellcake/releases/tag/v0.1.0
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{{- if .Values.namespace.create }}
2+
apiVersion: v1
3+
kind: Namespace
4+
metadata:
5+
name: {{ .Release.Namespace }}
6+
labels:
7+
{{- include "valkey-cluster.labels" . | nindent 4 }}
8+
{{- with .Values.namespace.podSecurityStandard }}
9+
pod-security.kubernetes.io/enforce: {{ . }}
10+
pod-security.kubernetes.io/warn: {{ . }}
11+
pod-security.kubernetes.io/audit: {{ . }}
12+
{{- end }}
13+
{{- with .Values.namespace.labels }}
14+
{{- toYaml . | nindent 4 }}
15+
{{- end }}
16+
{{- end }}

charts/valkey-cluster/values.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@
55
nameOverride: ""
66
fullnameOverride: ""
77

8+
# Optionally have this chart create the release Namespace and label it with the
9+
# Pod Security Standards, so the restricted-PSA-compatible Valkey pods are
10+
# actually enforced. When create=true the chart OWNS the namespace — install
11+
# WITHOUT `helm --create-namespace`, into a namespace that does not yet exist,
12+
# otherwise Helm refuses to adopt a namespace it does not manage.
13+
namespace:
14+
create: false
15+
# enforce/warn/audit level: restricted | baseline | privileged.
16+
# Empty string → create the namespace without Pod Security labels.
17+
podSecurityStandard: restricted
18+
# Extra labels to merge onto the namespace.
19+
labels: {}
20+
821
# Topology: Standalone | Replication | Sentinel | Cluster
922
topology: Replication
1023

docs/architecture.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ The code has two independent strategies:
110110
In the `Cluster` topology there is no operator-driven failover — native Valkey
111111
gossip handles it.
112112

113+
Because the Replication strategy is operator-arbitrated (bounded by the reconcile
114+
interval and the operator's own liveness, with a split-brain window on a network
115+
partition), the validating webhook **rejects** a `Durable` profile on a
116+
`Replication` topology at create time (AR1/EC1). For durable data prefer
117+
`Sentinel` or `Cluster`, which arbitrate failover in the data plane. To create the
118+
combination anyway — acknowledging the risk — set the annotation
119+
`valkey.wellcake.io/accept-replication-durability-risk: "true"`. The gate applies
120+
only on create: an existing cluster keeps working (with a warning) across an
121+
operator upgrade.
122+
113123
## Proactive rolling restart (ADR 0004)
114124

115125
Opt-in via the `valkey.wellcake.io/proactive-rollout: "true"` annotation
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
Copyright 2026 The Wellcake Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"context"
21+
"time"
22+
23+
. "github.com/onsi/ginkgo/v2"
24+
. "github.com/onsi/gomega"
25+
26+
coordinationv1 "k8s.io/api/coordination/v1"
27+
"k8s.io/client-go/kubernetes/scheme"
28+
"k8s.io/utils/ptr"
29+
ctrl "sigs.k8s.io/controller-runtime"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
31+
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
32+
)
33+
34+
// Op1: prove HA handover between leader-elected operator replicas. Two managers
35+
// contend for the same lease against the envtest API server; the standby must
36+
// not steal an actively-renewed lease, and must take over promptly once the
37+
// active leader steps down. This exercises the real coordination.k8s.io Lease
38+
// path the deployed operator uses (`--leader-elect`), not a mock.
39+
var _ = Describe("Leader election handover (Op1)", func() {
40+
It("promotes a standby manager when the active leader steps down", func() {
41+
const leaseID = "op1-handover.wellcake.io"
42+
const leaseNS = "default"
43+
44+
newMgr := func() ctrl.Manager {
45+
m, err := ctrl.NewManager(cfg, ctrl.Options{
46+
Scheme: scheme.Scheme,
47+
LeaderElection: true,
48+
LeaderElectionID: leaseID,
49+
LeaderElectionNamespace: leaseNS,
50+
LeaderElectionReleaseOnCancel: true,
51+
// Short timings so the test converges in seconds; the invariant
52+
// (single holder, clean handover) is independent of the values.
53+
LeaseDuration: ptr.To(6 * time.Second),
54+
RenewDeadline: ptr.To(4 * time.Second),
55+
RetryPeriod: ptr.To(1 * time.Second),
56+
Metrics: metricsserver.Options{BindAddress: "0"},
57+
HealthProbeBindAddress: "0",
58+
})
59+
Expect(err).NotTo(HaveOccurred())
60+
return m
61+
}
62+
63+
// holder returns the current lease holder identity ("" if none).
64+
holder := func() string {
65+
var lease coordinationv1.Lease
66+
if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: leaseNS, Name: leaseID}, &lease); err != nil {
67+
return ""
68+
}
69+
if lease.Spec.HolderIdentity == nil {
70+
return ""
71+
}
72+
return *lease.Spec.HolderIdentity
73+
}
74+
75+
By("starting leader A")
76+
mgrA := newMgr()
77+
ctxA, cancelA := context.WithCancel(ctx)
78+
go func() {
79+
defer GinkgoRecover()
80+
_ = mgrA.Start(ctxA)
81+
}()
82+
83+
Eventually(holder, 30*time.Second, 250*time.Millisecond).ShouldNot(BeEmpty())
84+
leaderA := holder()
85+
By("leader A holds the lease: " + leaderA)
86+
87+
By("starting standby B — it must not steal an actively-renewed lease")
88+
mgrB := newMgr()
89+
ctxB, cancelB := context.WithCancel(ctx)
90+
defer cancelB()
91+
go func() {
92+
defer GinkgoRecover()
93+
_ = mgrB.Start(ctxB)
94+
}()
95+
Consistently(holder, 5*time.Second, 1*time.Second).Should(Equal(leaderA))
96+
97+
By("leader A steps down (graceful cancel releases the lease)")
98+
cancelA()
99+
100+
By("standby B takes over with a different, non-empty identity")
101+
Eventually(holder, 30*time.Second, 250*time.Millisecond).
102+
ShouldNot(Or(BeEmpty(), Equal(leaderA)))
103+
104+
cancelB()
105+
})
106+
})

internal/controller/resources.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -585,18 +585,16 @@ func renderInitScript(vc *cachev1beta1.ValkeyCluster) string {
585585
// default user stays nopass, matching auth-disabled intent. The file must
586586
// exist regardless — Valkey refuses to start if aclfile points at nothing.
587587
// For Sentinel topology, seed a dedicated least-data-exposure ACL user that
588-
// Sentinel uses to reach the master (sentinel auth-user). It gets all
589-
// commands and all pub/sub channels (Sentinel needs INFO/REPLICAOF/CONFIG
590-
// REWRITE/CLIENT KILL/SCRIPT KILL plus the __sentinel__:hello channel) but
591-
// NO key glob — so it cannot read or write your data, unlike the default
592-
// user. (S1 hardening; tightening to the minimal per-command set is a
593-
// follow-up that needs e2e failover validation.)
588+
// Sentinel uses to reach the master (sentinel auth-user). It gets the minimal
589+
// per-command set Sentinel needs (sentinelACLCommands) plus all pub/sub
590+
// channels (&*, for __sentinel__:hello) but NO key glob — so it cannot read
591+
// or write your data, unlike the default user. (S1 hardening.)
594592
sentinelSeed, sentinelReseed := "", ""
595593
if vc.Spec.Topology == cachev1beta1.TopologySentinel {
596-
sentinelSeed = fmt.Sprintf(" echo \"user %s on #$PW_HASH &* +@all\" >> %s/users.acl\n",
597-
sentinelACLUser, dataMountPath)
598-
sentinelReseed = fmt.Sprintf(" echo \"user %s on #$PW_HASH &* +@all\" >> %s/users.acl.new\n",
599-
sentinelACLUser, dataMountPath)
594+
sentinelSeed = fmt.Sprintf(" echo \"user %s on #$PW_HASH &* %s\" >> %s/users.acl\n",
595+
sentinelACLUser, sentinelACLCommands, dataMountPath)
596+
sentinelReseed = fmt.Sprintf(" echo \"user %s on #$PW_HASH &* %s\" >> %s/users.acl.new\n",
597+
sentinelACLUser, sentinelACLCommands, dataMountPath)
600598
}
601599
// users.acl seeding. Valkey applies `requirepass` first, then loads the
602600
// aclfile, and the aclfile is authoritative — so the operator-managed users

internal/controller/resources_test.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -321,22 +321,32 @@ func TestRenderInitScriptSeedsDefaultUserACL(t *testing.T) {
321321
}
322322

323323
func TestRenderInitScriptSeedsSentinelACLUser(t *testing.T) {
324-
// Sentinel topology with auth seeds a dedicated sentinel-user (all commands,
325-
// all channels, NO key glob → no data access) that Sentinel uses to reach
326-
// the master via `sentinel auth-user`.
324+
// Sentinel topology with auth seeds a dedicated sentinel-user with the
325+
// minimal per-command set + all channels, NO key glob → no data access, used
326+
// to reach the master via `sentinel auth-user`.
327327
vc := minimalCR()
328328
vc.Spec.Topology = cachev1beta1.TopologySentinel
329329
vc.Spec.Sentinel = &cachev1beta1.SentinelSpec{Replicas: 3}
330330
vc.Spec.Auth = &cachev1beta1.AuthSpec{Enabled: true}
331331
script := renderInitScript(vc)
332332

333-
if !strings.Contains(script, "user sentinel-user on #$PW_HASH &* +@all") {
334-
t.Errorf("Sentinel init script must seed the sentinel ACL user\n%s", script)
333+
if !strings.Contains(script, "user sentinel-user on #$PW_HASH &* "+sentinelACLCommands) {
334+
t.Errorf("Sentinel init script must seed the sentinel ACL user with the minimal command set\n%s", script)
335+
}
336+
// The narrowed user must NOT carry +@all (all commands) any more.
337+
if strings.Contains(script, "user sentinel-user on #$PW_HASH &* +@all") {
338+
t.Errorf("sentinel-user must use the minimal command set, not +@all\n%s", script)
335339
}
336340
// No key glob (~) for the sentinel user — it must not read/write data.
337-
if strings.Contains(script, "user sentinel-user on #$PW_HASH ~* &* +@all") {
341+
if strings.Contains(script, "user sentinel-user on #$PW_HASH ~*") {
338342
t.Errorf("sentinel-user must not have key access (~*)\n%s", script)
339343
}
344+
// Spot-check a couple of essential commands are present.
345+
for _, cmd := range []string{"+slaveof", "+info", "+subscribe", "+config|rewrite", "+client|kill"} {
346+
if !strings.Contains(script, cmd) {
347+
t.Errorf("sentinel-user ACL missing required command %q\n%s", cmd, script)
348+
}
349+
}
340350
}
341351

342352
func TestRenderInitScriptNoPasswordLeavesACLEmpty(t *testing.T) {
@@ -1419,7 +1429,7 @@ func TestRenderInitScriptReseedsDefaultUserOnPasswordChange(t *testing.T) {
14191429
}
14201430
// Sentinel must re-seed its dedicated ACL user too; it also carries the password.
14211431
sen := renderInitScript(sentinelCR())
1422-
if !strings.Contains(sen, `echo "user sentinel-user on #$PW_HASH &* +@all" >> `+dataMountPath+"/users.acl.new") {
1432+
if !strings.Contains(sen, `echo "user sentinel-user on #$PW_HASH &* `+sentinelACLCommands+`" >> `+dataMountPath+"/users.acl.new") {
14231433
t.Errorf("sentinel init must re-seed the sentinel ACL user on a password change\n%s", sen)
14241434
}
14251435
}

internal/controller/sentinel.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ const (
3030
// renderInitScript) that Sentinel authenticates as when reaching the
3131
// monitored master — least data exposure vs the default user.
3232
sentinelACLUser = "sentinel-user"
33+
// sentinelACLCommands is the minimal command set this user needs to monitor
34+
// and fail over the data nodes — the canonical Redis/Valkey Sentinel ACL
35+
// recommendation: health/role checks (ping/info/role), the
36+
// __sentinel__:hello pub/sub (subscribe/publish, paired with the &* channel
37+
// glob), the failover transaction (multi/exec + slaveof a.k.a. REPLICAOF),
38+
// config|rewrite to persist the new topology, and client|kill / script|kill
39+
// to interrupt clients and a running script mid-failover. No key glob — the
40+
// user can never read or write your data.
41+
sentinelACLCommands = "+multi +slaveof +ping +exec +subscribe " +
42+
"+config|rewrite +role +publish +info +client|setname +client|kill +script|kill"
3343
)
3444

3545
// reconcileSentinel brings up Replication primitives plus a separate

internal/webhook/v1beta1/validators_test.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,19 +132,30 @@ func TestValkeyClusterValidator(t *testing.T) {
132132
wantErr: true,
133133
},
134134
{
135-
name: "Durable + Replication -> warn (operator-arbitrated failover)",
135+
name: "Durable + Replication on create -> rejected (gate, risk not acknowledged)",
136136
mutate: func(vc *cachev1beta1.ValkeyCluster) {
137137
vc.Spec.Profile = cachev1beta1.ProfileDurable
138138
vc.Spec.Topology = cachev1beta1.TopologyReplication
139139
},
140-
wantWarns: true,
140+
wantErr: true,
141141
},
142142
{
143-
name: "Durable + empty topology (defaults to Replication) -> warn",
143+
name: "Durable + empty topology (defaults to Replication) on create -> rejected",
144144
mutate: func(vc *cachev1beta1.ValkeyCluster) {
145145
vc.Spec.Profile = cachev1beta1.ProfileDurable
146146
vc.Spec.Topology = ""
147147
},
148+
wantErr: true,
149+
},
150+
{
151+
name: "Durable + Replication with risk acknowledged -> warn, no error",
152+
mutate: func(vc *cachev1beta1.ValkeyCluster) {
153+
vc.Spec.Profile = cachev1beta1.ProfileDurable
154+
vc.Spec.Topology = cachev1beta1.TopologyReplication
155+
vc.Annotations = map[string]string{
156+
acceptReplicationDurabilityRiskAnnotation: "true",
157+
}
158+
},
148159
wantWarns: true,
149160
},
150161
{
@@ -204,6 +215,27 @@ func TestValkeyClusterValidator(t *testing.T) {
204215
}
205216
}
206217

218+
// On update the durable+Replication gate must not newly reject a cluster that
219+
// predates it — an operator upgrade must never strand a running deployment. The
220+
// combination still surfaces the split-brain warning.
221+
func TestValkeyClusterValidatorUpdateDoesNotGateDurableReplication(t *testing.T) {
222+
vc := &cachev1beta1.ValkeyCluster{
223+
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "c"},
224+
Spec: cachev1beta1.ValkeyClusterSpec{
225+
Profile: cachev1beta1.ProfileDurable,
226+
Topology: cachev1beta1.TopologyReplication,
227+
},
228+
}
229+
v := &ValkeyClusterCustomValidator{Client: newFakeClient()}
230+
warns, err := v.ValidateUpdate(context.Background(), vc, vc)
231+
if err != nil {
232+
t.Fatalf("ValidateUpdate err = %v, want nil (update must not gate the durable+Replication risk)", err)
233+
}
234+
if len(warns) == 0 {
235+
t.Fatalf("ValidateUpdate warnings = %v, want a split-brain warning", warns)
236+
}
237+
}
238+
207239
func TestValkeyClusterDefaulter(t *testing.T) {
208240
d := &ValkeyClusterCustomDefaulter{}
209241

0 commit comments

Comments
 (0)