Skip to content

Commit 0d008d5

Browse files
lmicciniclaude
andcommitted
Prevent unnecessary RabbitMQ rolling restarts during scaling and cert rotation
Three changes to align with the upstream rabbitmq-cluster-operator and prevent quorum queue corruption caused by unnecessary rolling restarts: 1. Remove TLS secret data and replica count from config hash Stop hashing TLS secret content (tlsSecretData, caSecretData) and the replica count into the pod-template annotation. The secret names are still hashed so that switching to a different cert triggers a restart, but content changes (cert-manager re-issuance, CA chain updates, rotation) no longer cause rolling restarts. TLS certificates are mounted via projected volumes that kubelet updates in-place, and RabbitMQ 4.x reloads them from disk automatically. During initial deployment the playbook scales RabbitMQ from 1 to 3 replicas, which adds new DNS SANs to the Certificate CR and triggers cert-manager to re-issue. When secret data was hashed, this caused a rolling restart before quorum queue replicas had synced, leading to Raft log divergence and a permanent crash loop (ra_server assertion "PLIdx < LastApplied"). 2. Make PreStop hook replica-count-independent The PreStop command previously varied based on spec.replicas (skipping quorum checks for single-replica clusters). This meant scaling from 1 to 3 changed the pod template, triggering a restart of existing pods. Now the quorum check is conditional at runtime: it queries rabbit_nodes:list_running() and only runs await_online_quorum_plus_one when more than one node is active. Per-command timeouts are derived from TerminationGracePeriodSeconds using a proportional split (T/6 for quorum and mirror checks, T/2 for drain) so that drain always gets a chance to run within the grace period. With the default 60s grace period this yields 10s/10s/30s. 3. Set explicit UpdateStrategy on the StatefulSet Always set RollingUpdate with Partition=0, matching the upstream cluster-operator. This prevents stale partition values (from migration or manual debugging) from silently blocking pod updates. 4. Add unit tests for PreStop hook and UpdateStrategy Cover default and custom TerminationGracePeriodSeconds values, and validate RollingUpdate with Partition=0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 517307d commit 0d008d5

4 files changed

Lines changed: 103 additions & 38 deletions

File tree

internal/controller/rabbitmq/rabbitmq_controller.go

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -388,45 +388,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
388388
}
389389

390390
// Calculate hash for config tracking.
391-
// Include TLS secret content so that certificate rotation (e.g. by
392-
// cert-manager) triggers a rolling restart of the StatefulSet pods.
391+
// TLS cert content is NOT included: certs are mounted via projected
392+
// volumes that kubelet updates in-place, and RabbitMQ 4.x reloads
393+
// them automatically. Hashing cert data here would trigger a
394+
// rolling restart on every cert-manager update, which can corrupt
395+
// quorum queues when replicas haven't fully synced yet.
393396
hashInput := map[string]interface{}{
394397
"ipv6Enabled": IPv6Enabled,
395398
"fipsEnabled": fipsEnabled,
396399
"tlsSecret": instance.Spec.TLS.SecretName,
400+
"caSecret": instance.Spec.TLS.CaSecretName,
397401
"additionalConfig": instance.Spec.Rabbitmq.AdditionalConfig,
398402
"advancedConfig": instance.Spec.Rabbitmq.AdvancedConfig,
399403
"envConfig": instance.Spec.Rabbitmq.EnvConfig,
400404
"erlangInetConfig": instance.Spec.Rabbitmq.ErlangInetConfig,
401405
"additionalPlugins": instance.Spec.Rabbitmq.AdditionalPlugins,
402406
"containerImage": instance.Spec.ContainerImage,
403-
"replicas": instance.Spec.Replicas,
404-
}
405-
if instance.Spec.TLS.SecretName != "" {
406-
tlsSecret := &corev1.Secret{}
407-
if err := r.Get(ctx, types.NamespacedName{
408-
Name: instance.Spec.TLS.SecretName,
409-
Namespace: instance.Namespace,
410-
}, tlsSecret); err != nil {
411-
if !k8s_errors.IsNotFound(err) {
412-
return ctrl.Result{}, fmt.Errorf("failed to get TLS secret %s: %w", instance.Spec.TLS.SecretName, err)
413-
}
414-
} else {
415-
hashInput["tlsSecretData"] = tlsSecret.Data
416-
}
417-
if instance.Spec.TLS.CaSecretName != "" && instance.Spec.TLS.CaSecretName != instance.Spec.TLS.SecretName {
418-
caSecret := &corev1.Secret{}
419-
if err := r.Get(ctx, types.NamespacedName{
420-
Name: instance.Spec.TLS.CaSecretName,
421-
Namespace: instance.Namespace,
422-
}, caSecret); err != nil {
423-
if !k8s_errors.IsNotFound(err) {
424-
return ctrl.Result{}, fmt.Errorf("failed to get CA secret %s: %w", instance.Spec.TLS.CaSecretName, err)
425-
}
426-
} else {
427-
hashInput["caSecretData"] = caSecret.Data
428-
}
429-
}
430407
}
431408
configMapHash, err := util.ObjectHash(hashInput)
432409
if err != nil {

internal/rabbitmq/statefulset.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ func StatefulSet(
143143
ServiceName: fmt.Sprintf("%s-nodes", r.Name),
144144
Replicas: replicas,
145145
PodManagementPolicy: appsv1.ParallelPodManagement,
146+
UpdateStrategy: appsv1.StatefulSetUpdateStrategy{
147+
RollingUpdate: &appsv1.RollingUpdateStatefulSetStrategy{
148+
Partition: ptr.To(int32(0)),
149+
},
150+
Type: appsv1.RollingUpdateStatefulSetStrategyType,
151+
},
146152
Selector: &metav1.LabelSelector{
147153
MatchLabels: matchls,
148154
},
@@ -392,10 +398,19 @@ func buildReadinessProbe(r *rabbitmqv1.RabbitMq) *corev1.Probe {
392398
// before draining. For single-replica clusters, quorum checks are
393399
// skipped since there are no other nodes to maintain quorum.
394400
func buildLifecycle(r *rabbitmqv1.RabbitMq) *corev1.Lifecycle {
395-
preStopCmd := `if [ ! -z "$(cat /etc/pod-info/skipPreStopChecks)" ]; then exit 0; fi; rabbitmq-upgrade await_online_quorum_plus_one -t 600 && rabbitmq-upgrade await_online_synchronized_mirror -t 600 || true && rabbitmq-upgrade drain -t 600`
396-
if r.Spec.Replicas != nil && *r.Spec.Replicas <= 1 {
397-
preStopCmd = `if [ ! -z "$(cat /etc/pod-info/skipPreStopChecks)" ]; then exit 0; fi; rabbitmq-upgrade drain -t 600`
401+
// Derive per-command timeouts from TerminationGracePeriodSeconds using a
402+
// proportional split (T/6 for quorum checks, T/2 for drain) so that drain
403+
// always gets a chance to run within the grace period.
404+
timeout := int64(60)
405+
if r.Spec.TerminationGracePeriodSeconds != nil {
406+
timeout = *r.Spec.TerminationGracePeriodSeconds
398407
}
408+
preStopCmd := fmt.Sprintf(
409+
`if [ ! -z "$(cat /etc/pod-info/skipPreStopChecks)" ]; then exit 0; fi; `+
410+
`if rabbitmqctl eval 'length(rabbit_nodes:list_running()) > 1.' 2>/dev/null | grep -q true; then `+
411+
`rabbitmq-upgrade await_online_quorum_plus_one -t %d && rabbitmq-upgrade await_online_synchronized_mirror -t %d || true; fi; `+
412+
`rabbitmq-upgrade drain -t %d`,
413+
timeout/6, timeout/6, timeout/2)
399414
return &corev1.Lifecycle{
400415
PreStop: &corev1.LifecycleHandler{
401416
Exec: &corev1.ExecAction{

internal/rabbitmq/statefulset_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ limitations under the License.
1515
package rabbitmq
1616

1717
import (
18+
"fmt"
1819
"strings"
1920
"testing"
2021

2122
rabbitmqv1 "github.com/openstack-k8s-operators/infra-operator/apis/rabbitmq/v1beta1"
23+
appsv1 "k8s.io/api/apps/v1"
2224
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2325
"k8s.io/utils/ptr"
2426
)
@@ -144,6 +146,75 @@ func TestBuildWipeDataInitContainer_DifferentVersions(t *testing.T) {
144146
}
145147
}
146148

149+
func TestStatefulSet_PreStopHook(t *testing.T) {
150+
tests := []struct {
151+
name string
152+
gracePeriod *int64
153+
wantQuorum int64
154+
wantMirror int64
155+
wantDrain int64
156+
}{
157+
{"default (nil) uses 60s", nil, 10, 10, 30},
158+
{"explicit 60s", ptr.To(int64(60)), 10, 10, 30},
159+
{"explicit 120s", ptr.To(int64(120)), 20, 20, 60},
160+
{"explicit 600s", ptr.To(int64(600)), 100, 100, 300},
161+
}
162+
163+
for _, tt := range tests {
164+
t.Run(tt.name, func(t *testing.T) {
165+
r := newTestRabbitMq("test-mq")
166+
r.Spec.TerminationGracePeriodSeconds = tt.gracePeriod
167+
sts := StatefulSet(r, "hash", nil, nil, "4.2", false, ProxyConfig{})
168+
169+
lifecycle := sts.Spec.Template.Spec.Containers[0].Lifecycle
170+
if lifecycle == nil || lifecycle.PreStop == nil || lifecycle.PreStop.Exec == nil {
171+
t.Fatal("PreStop exec hook must be set")
172+
}
173+
cmd := lifecycle.PreStop.Exec.Command
174+
if len(cmd) != 3 || cmd[0] != "/bin/bash" || cmd[1] != "-c" {
175+
t.Fatalf("unexpected command structure: %v", cmd)
176+
}
177+
script := cmd[2]
178+
179+
wantQuorum := fmt.Sprintf("await_online_quorum_plus_one -t %d", tt.wantQuorum)
180+
if !strings.Contains(script, wantQuorum) {
181+
t.Errorf("script missing %q:\n%s", wantQuorum, script)
182+
}
183+
wantMirror := fmt.Sprintf("await_online_synchronized_mirror -t %d", tt.wantMirror)
184+
if !strings.Contains(script, wantMirror) {
185+
t.Errorf("script missing %q:\n%s", wantMirror, script)
186+
}
187+
wantDrain := fmt.Sprintf("drain -t %d", tt.wantDrain)
188+
if !strings.Contains(script, wantDrain) {
189+
t.Errorf("script missing %q:\n%s", wantDrain, script)
190+
}
191+
if !strings.Contains(script, "skipPreStopChecks") {
192+
t.Error("script missing skipPreStopChecks guard")
193+
}
194+
if !strings.Contains(script, "rabbit_nodes:list_running()") {
195+
t.Error("script missing runtime node count check")
196+
}
197+
})
198+
}
199+
}
200+
201+
func TestStatefulSet_UpdateStrategy(t *testing.T) {
202+
r := newTestRabbitMq("test-mq")
203+
sts := StatefulSet(r, "hash", nil, nil, "4.2", false, ProxyConfig{})
204+
205+
if sts.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType {
206+
t.Errorf("UpdateStrategy.Type = %q, want %q",
207+
sts.Spec.UpdateStrategy.Type, appsv1.RollingUpdateStatefulSetStrategyType)
208+
}
209+
if sts.Spec.UpdateStrategy.RollingUpdate == nil {
210+
t.Fatal("UpdateStrategy.RollingUpdate must be set")
211+
}
212+
if *sts.Spec.UpdateStrategy.RollingUpdate.Partition != 0 {
213+
t.Errorf("Partition = %d, want 0",
214+
*sts.Spec.UpdateStrategy.RollingUpdate.Partition)
215+
}
216+
}
217+
147218
func TestStatefulSet_ReplicaCount(t *testing.T) {
148219
tests := []struct {
149220
name string

test/functional/rabbitmq_controller_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2240,7 +2240,7 @@ var _ = Describe("RabbitMQ Controller", func() {
22402240
})
22412241
})
22422242

2243-
When("TLS secret is rotated, config hash changes to trigger rolling restart", func() {
2243+
When("TLS secret data is rotated, config hash remains stable", func() {
22442244
var certSecret *corev1.Secret
22452245

22462246
BeforeEach(func() {
@@ -2254,7 +2254,7 @@ var _ = Describe("RabbitMQ Controller", func() {
22542254
DeferCleanup(th.DeleteInstance, rabbitmq)
22552255
})
22562256

2257-
It("should update the config-hash annotation on the StatefulSet when TLS secret changes", func() {
2257+
It("should NOT update the config-hash annotation when TLS secret data changes", func() {
22582258
SimulateRabbitMQClusterReady(rabbitmqName)
22592259

22602260
// Get initial config-hash from the StatefulSet
@@ -2274,12 +2274,14 @@ var _ = Describe("RabbitMQ Controller", func() {
22742274
g.Expect(th.K8sClient.Update(th.Ctx, secret)).Should(Succeed())
22752275
}, timeout, interval).Should(Succeed())
22762276

2277-
// Verify config-hash changed on the StatefulSet (triggers rolling restart)
2278-
Eventually(func(g Gomega) {
2277+
// Verify config-hash did NOT change — cert rotation is handled
2278+
// by projected volumes and RabbitMQ 4.x automatic reload, not
2279+
// by rolling restart. Hashing cert data caused quorum queue
2280+
// corruption during initial deployment.
2281+
Consistently(func(g Gomega) {
22792282
sts := GetRabbitMQStatefulSet(rabbitmqName)
22802283
newHash := sts.Spec.Template.Annotations["config-hash"]
2281-
g.Expect(newHash).NotTo(BeEmpty())
2282-
g.Expect(newHash).NotTo(Equal(initialHash), "config-hash should change when TLS secret is rotated")
2284+
g.Expect(newHash).To(Equal(initialHash), "config-hash should not change on TLS secret data rotation")
22832285
}, timeout, interval).Should(Succeed())
22842286
})
22852287
})

0 commit comments

Comments
 (0)