fix: cluster arch on ClusterIP access, and stranded password secret finalizer - #99
Merged
Merged
Conversation
ensureService switches on Access.ServiceType with cases for NodePort and LoadBalancer only. ServiceType is a closed enum defaulted to ClusterIP, so the default path matches no case and ensureValkeyPodService never runs. The per-pod service is therefore never created, and init_cluster.sh's `valkey-helper cluster expose` -- which requires a service named after the pod to resolve its announce address -- exits 1, leaving every node in Init:CrashLoopBackOff. The effect is that cluster arch only ever comes up when access.serviceType is NodePort or LoadBalancer. The default, in-cluster ClusterIP mode never starts. Failover already handles this correctly: its ensureService sends everything except NodePort-with-ports to ensureValkeyPodService, which is why failover+ClusterIP works. This aligns cluster with that behaviour. cleanUselessService's LoadBalancer/NodePort guard is dropped for the same reason: all three service types now have pod binded services, so scale-down must reclaim them for ClusterIP too. fetchAllPodBindedServices already filters to services carrying a pod-name selector, so headless and instance services are unaffected. Failover's equivalent has no such guard either. Verified on a live cluster: a 3-shard ClusterIP cluster crash-looped in init; with this change the same spec reaches Ready with cluster_state:ok, 16384 slots assigned, and each node announcing its pod IP -- which is what Access() intends for the non-NodePort path, where announceIp is pod.Status.PodIP. The e2e suite does not catch this: all three arch suites in e2e_test.go are commented "with ClusterIP service type" but construct their parameters with corev1.ServiceTypeNodePort, so only the working path is ever exercised.
The secret finalizer is added while reconciling the password secret, before Handler.Do has created the user on the instance, but the User's own finalizer is only added after Handler.Do succeeds. For an instance that never becomes ready the two never balance: Handler.Do returns "instance is not ready" and requeues, so the secret carries a finalizer while the User carries none. Deleting the namespace then strands the secret. The User has no finalizer, so it is removed immediately, and with it the only controller that would ever run the cleanup path that drops the secret's finalizer. The secret is never reclaimed and the namespace stays in Terminating forever. Reproduced with an instance whose pods can never schedule: every User was gone while failover-acl-<name>-operator-secret still held buf.red/user-finalizer, and the namespace only released after the finalizer was patched out by hand. The finalizer exists to keep the password readable until the user is dropped from the instance, so it is not needed until that user exists. Add it once the User reaches Ready, and only after the User itself is finalized -- that ordering is the invariant: a secret finalizer set while the User has none is unreclaimable, whereas a User finalizer with no secret finalizer is safe, as the cleanup path reclaims every secret it owns. An instance that never becomes ready now ends with no finalizers at all and is garbage collected normally. Also drop the ContainsFinalizer clause from the secret update condition. It guarded adding the finalizer; with that moved out, it would otherwise fire an update on every reconcile once the finalizer was present. Verified on a live cluster: an instance left unschedulable produces its acl secret with no finalizer, and deleting the namespace now completes instead of hanging. Note that secrets already stranded by an earlier operator still need the finalizer removed by hand -- nothing watches them once their User is gone.
All three arch suites were commented "with ClusterIP service type" but built
their parameters with corev1.ServiceTypeNodePort, so ClusterIP -- the mode an
instance gets by default when access.serviceType is unset -- was never covered.
Only the NodePort path was ever tested, which is how the cluster arch ClusterIP
regression fixed earlier in this series reached main unnoticed: it reproduces on
every version, but is invisible from NodePort.
Rather than swap NodePort for ClusterIP and move the blind spot, generate the
parameters over both access modes. The two reach an instance through different
service wiring -- NodePort allocates node ports and announces the node IP, while
ClusterIP announces the pod IP -- so a regression in one is not observable from
the other.
Instance names gain a short per-mode suffix (cip/np/lb). Each version is now
deployed once per mode, and the two runs would otherwise collide on the same
name in the same namespace. Relying on the trailing "delete valkey" spec to free
the name before the next context starts would work only while the suite runs
ordered and serially.
The generated matrix goes from 277 to 552 specs. This only affects the e2e
suite, which is workflow_dispatch only and already has SKIP_VERSIONS and
SKIP_{CLUSTER,FAILOVER,REPLICATION}_TESTS to trim a run; failover and
replication are skipped by default. The access mode is also a Ginkgo label, so
-ginkgo.label-filter='ClusterIP' selects one mode.
chideat
enabled auto-merge (squash)
July 14, 2026 13:39
chideat
disabled auto-merge
July 14, 2026 13:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent bugs found while running a version-coverage check against a build of this operator. Both are long-standing on
mainand both are verified against a live cluster.They're unrelated but share a discovery path and are each a one-file change, so they're bundled here — happy to split into two PRs if you'd prefer.
1. Cluster arch never starts when
access.serviceTypeis ClusterIPensureServiceswitches onAccess.ServiceTypewith cases forNodePortandLoadBalanceronly.ServiceTypeis a closed enum (+kubebuilder:validation:Enum=NodePort;LoadBalancer;ClusterIP) defaulted toClusterIP, so the default path matches no case andensureValkeyPodServicenever runs.The per-pod service is therefore never created.
init_cluster.shunconditionally runsvalkey-helper cluster expose, andAccess()requires a service named after the pod to resolve its announce address — so it exits 1 and every node sits inInit:CrashLoopBackOff:Net effect: cluster arch only ever comes up on NodePort or LoadBalancer. The default in-cluster ClusterIP mode has never worked. That looks unintended rather than unsupported —
Access()has an explicit non-NodePort path that setsannounceIp = pod.Status.PodIP.Failover already gets this right — its
ensureServicesends everything except NodePort-with-ports toensureValkeyPodService, which is why failover+ClusterIP works. This change aligns cluster with that behaviour.cleanUselessService's LoadBalancer/NodePort guard is dropped for the same reason: all three service types now have pod binded services, so scale-down must reclaim them for ClusterIP too.fetchAllPodBindedServicesalready filters to services carrying a pod-name selector, so headless and instance services are unaffected — and failover's equivalent has no such guard either.Why CI doesn't catch it: all three arch suites in
test/e2e/e2e_test.goare commented// Generate test parameters for all supported versions with ClusterIP service typebut then build their parameters withServiceType: corev1.ServiceTypeNodePort. Only the working path is ever exercised. Worth fixing separately — it would also give cluster arch real ClusterIP coverage.2. Password secret stranded, wedging namespace deletion
Asymmetric finalizer ordering in
user_controller.go:buf.red/user-finalizerat line 217, while merely reconciling the password secretHandler.DosucceedsFor an instance that never becomes ready these never balance.
Handler.Doreturnsinstance is not readyand requeues at line 240, so the secret carries a finalizer while the User carries none.Deleting the namespace then strands the secret: the User has no finalizer, so it's removed immediately — taking with it the only controller that would ever run the cleanup path that drops the secret's finalizer. Nothing watches the secret afterward, so the namespace stays in
Terminatingforever:The finalizer exists to keep the password readable until the user is dropped from the instance, so it isn't needed until that user exists. It's now added once the User reaches Ready, and only after the User itself is finalized.
That ordering is the invariant, and it's the load-bearing part of the fix: a secret finalizer set while the User has none is unreclaimable, whereas a User finalizer with no secret finalizer is safe — the cleanup path reclaims every secret it owns. So partial failure degrades to safe rather than wedged, and an instance that never becomes ready ends with no finalizers at all and is GC'd normally.
Also drops the
ContainsFinalizerclause from the secret update condition — it existed to trigger the finalizer add, and with that moved out it would otherwise fire anr.Updateon every reconcile once the finalizer was present.Testing
go build ./...,go vet, and the unit suite (incl. envtest-backed controller suites) pass on this branch.Both fixes verified live on a 4-node cluster:
cluster_state:ok, 16384 slots assigned, 3 nodes announcing their pod IPs, and the threedrc-*per-pod services created by the operator itself.finalizers=[](previously["buf.red/user-finalizer"]), and deleting the namespace now completes in ~60s instead of hanging indefinitely.Note
Secrets already stranded by an earlier operator still need the finalizer removed by hand — nothing watches them once their User is gone:
🤖 Generated with Claude Code