Skip to content

fix: cluster arch on ClusterIP access, and stranded password secret finalizer - #99

Merged
chideat merged 3 commits into
mainfrom
fix/cluster-clusterip-pod-services
Jul 14, 2026
Merged

fix: cluster arch on ClusterIP access, and stranded password secret finalizer#99
chideat merged 3 commits into
mainfrom
fix/cluster-clusterip-pod-services

Conversation

@chideat

@chideat chideat commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Two independent bugs found while running a version-coverage check against a build of this operator. Both are long-standing on main and 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.serviceType is ClusterIP

ensureService switches on Access.ServiceType with cases for NodePort and LoadBalancer only. ServiceType is a closed enum (+kubebuilder:validation:Enum=NodePort;LoadBalancer;ClusterIP) defaulted to ClusterIP, so the default path matches no case and ensureValkeyPodService never runs.

The per-pod service is therefore never created. init_cluster.sh unconditionally runs valkey-helper cluster expose, and Access() requires a service named after the pod to resolve its announce address — so it exits 1 and every node sits in Init:CrashLoopBackOff:

Expose  get service failed  {"target": "ns/drc-<name>-0-0", "error": "services \"drc-<name>-0-0\" not found"}
Expose  expose node port failed  {"error": "services \"drc-<name>-0-0\" not found"}

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 sets announceIp = pod.Status.PodIP.

Failover already gets this right — its ensureService sends everything except NodePort-with-ports to ensureValkeyPodService, 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. fetchAllPodBindedServices already 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.go are commented // Generate test parameters for all supported versions with ClusterIP service type but then build their parameters with ServiceType: 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:

  • the secret gets buf.red/user-finalizer at line 217, while merely reconciling the password secret
  • the User gets its finalizer at line 259, only after Handler.Do succeeds

For an instance that never becomes ready these never balance. Handler.Do returns instance is not ready and 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 Terminating forever:

NamespaceContentRemaining:   Some resources are remaining: secrets. has 1 resource instances
NamespaceFinalizersRemaining: buf.red/user-finalizer in 1 resource instances

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 ContainsFinalizer clause from the secret update condition — it existed to trigger the finalizer add, and with that moved out it would otherwise fire an r.Update on 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:

  • ClusterIP cluster: a 3-shard instance crash-looped in init on the unfixed build; with this change the same spec reaches Ready in ~90s with cluster_state:ok, 16384 slots assigned, 3 nodes announcing their pod IPs, and the three drc-* per-pod services created by the operator itself.
  • Finalizer: an instance left permanently unschedulable produces its acl secret with finalizers=[] (previously ["buf.red/user-finalizer"]), and deleting the namespace now completes in ~60s instead of hanging indefinitely.
  • No regression: 7.2 / 8.0 / 8.1 / 9.0 / 9.1 all still reach Ready on failover arch.

Note

Secrets already stranded by an earlier operator still need the finalizer removed by hand — nothing watches them once their User is gone:

kubectl -n <ns> patch secret <name> --type=merge -p '{"metadata":{"finalizers":null}}'

🤖 Generated with Claude Code

chideat added 3 commits July 14, 2026 07:44
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
chideat enabled auto-merge (squash) July 14, 2026 13:39
@chideat
chideat disabled auto-merge July 14, 2026 13:40
@chideat
chideat merged commit fb678b7 into main Jul 14, 2026
7 checks passed
@chideat
chideat deleted the fix/cluster-clusterip-pod-services branch July 14, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant