Skip to content

Commit 531bef4

Browse files
committed
Harden CredentialRotation against stale CRs and partial retries
- Self-heal per-namespace user/my.cnf Secrets during active rotation by re-applying pending passwords in Rotated/Discarding/Discarded phases. Fall back to current passwords during the brief Discarded→Completed window where pending keys have been promoted. - Treat CR Get errors strictly: only NotFound/NoMatch are "no active rotation"; transient errors abort reconciliation instead of falling back to current passwords. - Refuse to adopt or act on a stale CredentialRotation (ownerRef UID does not match the live cluster). Apply this consistently in the CredentialRotation reconciler, ClusterManager, MySQLClusterReconciler, and the kubectl-moco CLI. - Validate CR delete: forbid mid-rotation deletes, but always allow garbage collection (owner NotFound, owner Terminating, stale UID). - Make DISCARD OLD PASSWORD per-user idempotent via HasDualPassword, matching the existing RETAIN behavior; required because MySQL rejects DISCARD when no retained password remains. - Reject discard while replicas=0 in handleStartDiscard and emit a RotationBlocked Warning Event when handleRotatingPhase is stalled by replicas=0. - Update the design doc to match the implementation (MySQLClusterReconciler code, DISCARD idempotency, stale-CR handling, MySQLCluster deletion, validation webhook). - Add unit and envtest coverage for the new paths. Signed-off-by: shunki-fujita <shunki-fujita@cybozu.co.jp>
1 parent 600d50f commit 531bef4

16 files changed

Lines changed: 639 additions & 137 deletions

api/v1beta2/credentialrotation_webhook.go

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package v1beta2
22

33
import (
44
"context"
5+
"fmt"
56

67
apierrors "k8s.io/apimachinery/pkg/api/errors"
78
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -22,7 +23,7 @@ type credentialRotationAdmission struct {
2223
client client.Reader
2324
}
2425

25-
//+kubebuilder:webhook:path=/validate-moco-cybozu-com-v1beta2-credentialrotation,mutating=false,failurePolicy=fail,sideEffects=None,matchPolicy=Equivalent,groups=moco.cybozu.com,resources=credentialrotations,verbs=create;update,versions=v1beta2,name=vcredentialrotation.kb.io,admissionReviewVersions=v1
26+
//+kubebuilder:webhook:path=/validate-moco-cybozu-com-v1beta2-credentialrotation,mutating=false,failurePolicy=fail,sideEffects=None,matchPolicy=Equivalent,groups=moco.cybozu.com,resources=credentialrotations,verbs=create;update;delete,versions=v1beta2,name=vcredentialrotation.kb.io,admissionReviewVersions=v1
2627

2728
var _ admission.Validator[*CredentialRotation] = &credentialRotationAdmission{}
2829

@@ -132,6 +133,63 @@ func (a *credentialRotationAdmission) ValidateUpdate(ctx context.Context, oldCR,
132133
return nil, nil
133134
}
134135

135-
func (a *credentialRotationAdmission) ValidateDelete(ctx context.Context, _ *CredentialRotation) (admission.Warnings, error) {
136-
return nil, nil
136+
// hasStaleClusterOwnerRef reports whether cr carries a MySQLCluster owner
137+
// reference that points at a UID different from cluster.UID, with no matching
138+
// reference. That signals the CR is left over from a deleted cluster that has
139+
// since been recreated under the same name.
140+
func hasStaleClusterOwnerRef(cr *CredentialRotation, cluster *MySQLCluster) bool {
141+
hasStale := false
142+
for _, ref := range cr.OwnerReferences {
143+
if ref.Kind != "MySQLCluster" {
144+
continue
145+
}
146+
if ref.UID == cluster.UID {
147+
return false
148+
}
149+
hasStale = true
150+
}
151+
return hasStale
152+
}
153+
154+
func (a *credentialRotationAdmission) ValidateDelete(ctx context.Context, cr *CredentialRotation) (admission.Warnings, error) {
155+
// Always allow deletion when the rotation is idle/completed.
156+
switch cr.Status.Phase {
157+
case "", RotationPhaseCompleted:
158+
return nil, nil
159+
}
160+
161+
// Allow garbage-collection deletes: if the owning MySQLCluster is gone,
162+
// the CR has nothing to act on and must be reclaimable. Blocking GC here
163+
// would orphan the CR and poison a future cluster recreated with the
164+
// same name (it would inherit a stale in-progress rotation).
165+
cluster := &MySQLCluster{}
166+
err := a.client.Get(ctx, types.NamespacedName{Namespace: cr.Namespace, Name: cr.Name}, cluster)
167+
if apierrors.IsNotFound(err) {
168+
return nil, nil
169+
}
170+
if err != nil {
171+
return nil, apierrors.NewInternalError(err)
172+
}
173+
// The cluster is being torn down. Owner references use blockOwnerDeletion,
174+
// so Kubernetes GC must be allowed to remove this CR; otherwise the
175+
// MySQLCluster would be stuck in Terminating until the rotation finishes.
176+
if cluster.DeletionTimestamp != nil {
177+
return nil, nil
178+
}
179+
// The cluster lookup matches by name only; if the live cluster has a
180+
// different UID than the CR's owner, the original cluster has been deleted
181+
// and replaced. The CR is stale and must be deletable so it does not
182+
// poison the new cluster with leftover rotation state.
183+
if hasStaleClusterOwnerRef(cr, cluster) {
184+
return nil, nil
185+
}
186+
187+
// Otherwise forbid: a deletion mid-rotation abandons the workflow,
188+
// leaving pending/dual passwords on instances with no automatic recovery.
189+
errs := field.ErrorList{
190+
field.Forbidden(field.NewPath("status", "phase"),
191+
fmt.Sprintf("cannot delete CredentialRotation while phase is %q; wait for the rotation to reach Completed", cr.Status.Phase)),
192+
}
193+
return nil, apierrors.NewInvalid(
194+
schema.GroupKind{Group: GroupVersion.Group, Kind: "CredentialRotation"}, cr.Name, errs)
137195
}

api/v1beta2/credentialrotation_webhook_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,162 @@ var _ = Describe("CredentialRotation Webhook", func() {
226226
Expect(err).To(HaveOccurred())
227227
})
228228
})
229+
230+
Context("ValidateDelete", func() {
231+
It("should allow deletion when phase is empty", func() {
232+
cluster := makeMySQLCluster()
233+
err := k8sClient.Create(ctx, cluster)
234+
Expect(err).NotTo(HaveOccurred())
235+
236+
cr := makeCredentialRotation("test", 1)
237+
err = k8sClient.Create(ctx, cr)
238+
Expect(err).NotTo(HaveOccurred())
239+
240+
err = k8sClient.Delete(ctx, cr)
241+
Expect(err).NotTo(HaveOccurred())
242+
})
243+
244+
It("should allow deletion when phase is Completed", func() {
245+
cluster := makeMySQLCluster()
246+
err := k8sClient.Create(ctx, cluster)
247+
Expect(err).NotTo(HaveOccurred())
248+
249+
cr := makeCredentialRotation("test", 1)
250+
err = k8sClient.Create(ctx, cr)
251+
Expect(err).NotTo(HaveOccurred())
252+
253+
cr.Status.Phase = mocov1beta2.RotationPhaseCompleted
254+
err = k8sClient.Status().Update(ctx, cr)
255+
Expect(err).NotTo(HaveOccurred())
256+
257+
err = k8sClient.Delete(ctx, cr)
258+
Expect(err).NotTo(HaveOccurred())
259+
})
260+
261+
It("should allow deletion when the owning MySQLCluster is gone (GC)", func() {
262+
cluster := makeMySQLCluster()
263+
err := k8sClient.Create(ctx, cluster)
264+
Expect(err).NotTo(HaveOccurred())
265+
266+
cr := makeCredentialRotation("test", 1)
267+
err = k8sClient.Create(ctx, cr)
268+
Expect(err).NotTo(HaveOccurred())
269+
270+
cr.Status.Phase = mocov1beta2.RotationPhaseRotating
271+
err = k8sClient.Status().Update(ctx, cr)
272+
Expect(err).NotTo(HaveOccurred())
273+
274+
// Delete the MySQLCluster first to simulate GC ordering after owner removal.
275+
err = k8sClient.Delete(ctx, cluster)
276+
Expect(err).NotTo(HaveOccurred())
277+
Eventually(func() bool {
278+
c := &mocov1beta2.MySQLCluster{}
279+
return k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), c) != nil
280+
}).Should(BeTrue())
281+
282+
err = k8sClient.Delete(ctx, cr)
283+
Expect(err).NotTo(HaveOccurred())
284+
})
285+
286+
It("should allow deletion when the owning cluster is terminating (GC unblock)", func() {
287+
cluster := makeMySQLCluster()
288+
cluster.Finalizers = []string{"moco.cybozu.com/test-block-delete"}
289+
err := k8sClient.Create(ctx, cluster)
290+
Expect(err).NotTo(HaveOccurred())
291+
292+
cr := makeCredentialRotation("test", 1)
293+
err = k8sClient.Create(ctx, cr)
294+
Expect(err).NotTo(HaveOccurred())
295+
296+
cr.Status.Phase = mocov1beta2.RotationPhaseRotating
297+
err = k8sClient.Status().Update(ctx, cr)
298+
Expect(err).NotTo(HaveOccurred())
299+
300+
// Delete the cluster — finalizer keeps it in Terminating state.
301+
err = k8sClient.Delete(ctx, cluster)
302+
Expect(err).NotTo(HaveOccurred())
303+
Eventually(func() bool {
304+
c := &mocov1beta2.MySQLCluster{}
305+
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), c); err != nil {
306+
return false
307+
}
308+
return c.DeletionTimestamp != nil
309+
}).Should(BeTrue())
310+
311+
// GC delete of the CR must be allowed so the cluster can finish terminating.
312+
err = k8sClient.Delete(ctx, cr)
313+
Expect(err).NotTo(HaveOccurred())
314+
315+
// Clean up: remove the finalizer so the cluster can be deleted.
316+
Eventually(func() error {
317+
c := &mocov1beta2.MySQLCluster{}
318+
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), c); err != nil {
319+
return err
320+
}
321+
c.Finalizers = nil
322+
return k8sClient.Update(ctx, c)
323+
}).Should(Succeed())
324+
})
325+
326+
It("should allow deletion when ownerReference points to a recreated cluster (different UID)", func() {
327+
cluster := makeMySQLCluster()
328+
err := k8sClient.Create(ctx, cluster)
329+
Expect(err).NotTo(HaveOccurred())
330+
331+
cr := makeCredentialRotation("test", 1)
332+
// Stale ownerRef pointing at a UID that no live cluster has.
333+
cr.OwnerReferences = []metav1.OwnerReference{{
334+
APIVersion: mocov1beta2.GroupVersion.String(),
335+
Kind: "MySQLCluster",
336+
Name: cluster.Name,
337+
UID: "stale-uid",
338+
Controller: new(true),
339+
}}
340+
err = k8sClient.Create(ctx, cr)
341+
Expect(err).NotTo(HaveOccurred())
342+
343+
cr.Status.Phase = mocov1beta2.RotationPhaseRotating
344+
err = k8sClient.Status().Update(ctx, cr)
345+
Expect(err).NotTo(HaveOccurred())
346+
347+
err = k8sClient.Delete(ctx, cr)
348+
Expect(err).NotTo(HaveOccurred())
349+
})
350+
351+
It("should reject deletion when phase is active", func() {
352+
for _, phase := range []mocov1beta2.RotationPhase{
353+
mocov1beta2.RotationPhaseRotating,
354+
mocov1beta2.RotationPhaseRetained,
355+
mocov1beta2.RotationPhaseRotated,
356+
mocov1beta2.RotationPhaseDiscarding,
357+
mocov1beta2.RotationPhaseDiscarded,
358+
} {
359+
cluster := makeMySQLCluster()
360+
err := k8sClient.Create(ctx, cluster)
361+
Expect(err).NotTo(HaveOccurred())
362+
363+
cr := makeCredentialRotation("test", 1)
364+
err = k8sClient.Create(ctx, cr)
365+
Expect(err).NotTo(HaveOccurred())
366+
367+
cr.Status.Phase = phase
368+
err = k8sClient.Status().Update(ctx, cr)
369+
Expect(err).NotTo(HaveOccurred())
370+
371+
err = k8sClient.Delete(ctx, cr)
372+
Expect(err).To(HaveOccurred(), "phase=%s should reject delete", phase)
373+
374+
// Force-cleanup via status reset for next iteration.
375+
err = k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), cr)
376+
Expect(err).NotTo(HaveOccurred())
377+
cr.Status.Phase = mocov1beta2.RotationPhaseCompleted
378+
err = k8sClient.Status().Update(ctx, cr)
379+
Expect(err).NotTo(HaveOccurred())
380+
err = k8sClient.Delete(ctx, cr)
381+
Expect(err).NotTo(HaveOccurred())
382+
err = deleteMySQLCluster()
383+
Expect(err).NotTo(HaveOccurred())
384+
}
385+
})
386+
})
229387
})

charts/moco/templates/generated/crds/moco_crds.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2761,7 +2761,7 @@ apiVersion: apiextensions.k8s.io/v1
27612761
kind: CustomResourceDefinition
27622762
metadata:
27632763
annotations:
2764-
controller-gen.kubebuilder.io/version: v0.19.0
2764+
controller-gen.kubebuilder.io/version: v0.20.1
27652765
helm.sh/resource-policy: keep
27662766
labels:
27672767
app.kubernetes.io/managed-by: '{{ .Release.Service }}'

charts/moco/templates/generated/generated.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ webhooks:
524524
operations:
525525
- CREATE
526526
- UPDATE
527+
- DELETE
527528
resources:
528529
- credentialrotations
529530
sideEffects: None

clustering/manager_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,12 @@ var _ = Describe("manager", func() {
11291129
ObjectMeta: metav1.ObjectMeta{
11301130
Name: "test",
11311131
Namespace: "test",
1132+
OwnerReferences: []metav1.OwnerReference{{
1133+
APIVersion: mocov1beta2.GroupVersion.String(),
1134+
Kind: "MySQLCluster",
1135+
Name: cluster.Name,
1136+
UID: cluster.UID,
1137+
}},
11321138
},
11331139
Spec: mocov1beta2.CredentialRotationSpec{
11341140
RotationGeneration: 1,
@@ -1195,11 +1201,25 @@ var _ = Describe("manager", func() {
11951201
// Set user auth plugin to something different to trigger migration.
11961202
of.setUserAuthPlugin("mysql_native_password")
11971203

1204+
// Mimic post-RETAIN state: every user holds a retained (dual) password
1205+
// on every instance. DISCARD OLD PASSWORD is now gated on this state.
1206+
for i := range 3 {
1207+
for _, user := range constants.MocoUsers {
1208+
of.setDualPassword(cluster.PodHostname(i), user, true)
1209+
}
1210+
}
1211+
11981212
// Create a CredentialRotation CR in Discarding phase.
11991213
cr := &mocov1beta2.CredentialRotation{
12001214
ObjectMeta: metav1.ObjectMeta{
12011215
Name: "test",
12021216
Namespace: "test",
1217+
OwnerReferences: []metav1.OwnerReference{{
1218+
APIVersion: mocov1beta2.GroupVersion.String(),
1219+
Kind: "MySQLCluster",
1220+
Name: cluster.Name,
1221+
UID: cluster.UID,
1222+
}},
12031223
},
12041224
Spec: mocov1beta2.CredentialRotationSpec{
12051225
RotationGeneration: 1,
@@ -1275,6 +1295,12 @@ var _ = Describe("manager", func() {
12751295
ObjectMeta: metav1.ObjectMeta{
12761296
Name: "test",
12771297
Namespace: "test",
1298+
OwnerReferences: []metav1.OwnerReference{{
1299+
APIVersion: mocov1beta2.GroupVersion.String(),
1300+
Kind: "MySQLCluster",
1301+
Name: cluster.Name,
1302+
UID: cluster.UID,
1303+
}},
12781304
},
12791305
Spec: mocov1beta2.CredentialRotationSpec{
12801306
RotationGeneration: 1,
@@ -1300,4 +1326,77 @@ var _ = Describe("manager", func() {
13001326
Expect(users).To(HaveLen(len(constants.MocoUsers) - 1))
13011327
Expect(users).NotTo(ContainElement(constants.AdminUser))
13021328
})
1329+
1330+
It("should skip DISCARD for users without retained password (partial retry)", func() {
1331+
testSetupResources(ctx, 1, "")
1332+
1333+
cm := NewClusterManager(1*time.Second, mgr, of, af, stdr.New(nil), "test")
1334+
defer cm.StopAll()
1335+
1336+
cluster, err := testGetCluster(ctx)
1337+
Expect(err).NotTo(HaveOccurred())
1338+
cm.Update(client.ObjectKeyFromObject(cluster), "test")
1339+
1340+
Eventually(func(g Gomega) {
1341+
cluster, err = testGetCluster(ctx)
1342+
g.Expect(err).NotTo(HaveOccurred())
1343+
condHealthy, err := testGetCondition(cluster, mocov1beta2.ConditionHealthy)
1344+
g.Expect(err).NotTo(HaveOccurred())
1345+
g.Expect(condHealthy.Status).To(Equal(metav1.ConditionTrue))
1346+
}).Should(Succeed())
1347+
1348+
controllerSecret := mysqlPassword.ToSecret()
1349+
controllerSecret.Namespace = "test"
1350+
controllerSecret.Name = cluster.ControllerSecretName()
1351+
rotationID := "test-rotation-discard-idempotent"
1352+
_, err = password.SetPendingPasswords(controllerSecret, rotationID)
1353+
Expect(err).NotTo(HaveOccurred())
1354+
err = k8sClient.Create(ctx, controllerSecret)
1355+
Expect(err).NotTo(HaveOccurred())
1356+
1357+
// Simulate a partial-progress retry: AdminUser already had DISCARD
1358+
// applied (no retained password remains), while the rest still hold a
1359+
// retained password from the prior RETAIN phase.
1360+
for _, user := range constants.MocoUsers {
1361+
if user == constants.AdminUser {
1362+
continue
1363+
}
1364+
of.setDualPassword(cluster.PodHostname(0), user, true)
1365+
}
1366+
1367+
cr := &mocov1beta2.CredentialRotation{
1368+
ObjectMeta: metav1.ObjectMeta{
1369+
Name: "test",
1370+
Namespace: "test",
1371+
OwnerReferences: []metav1.OwnerReference{{
1372+
APIVersion: mocov1beta2.GroupVersion.String(),
1373+
Kind: "MySQLCluster",
1374+
Name: cluster.Name,
1375+
UID: cluster.UID,
1376+
}},
1377+
},
1378+
Spec: mocov1beta2.CredentialRotationSpec{
1379+
RotationGeneration: 1,
1380+
DiscardOldPassword: true,
1381+
},
1382+
}
1383+
err = k8sClient.Create(ctx, cr)
1384+
Expect(err).NotTo(HaveOccurred())
1385+
cr.Status.Phase = mocov1beta2.RotationPhaseDiscarding
1386+
cr.Status.RotationID = rotationID
1387+
err = k8sClient.Status().Update(ctx, cr)
1388+
Expect(err).NotTo(HaveOccurred())
1389+
1390+
Eventually(func(g Gomega) {
1391+
cr := &mocov1beta2.CredentialRotation{}
1392+
err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "test"}, cr)
1393+
g.Expect(err).NotTo(HaveOccurred())
1394+
g.Expect(cr.Status.Phase).To(Equal(mocov1beta2.RotationPhaseDiscarded))
1395+
}).Should(Succeed())
1396+
1397+
// AdminUser should have been skipped; others should have been discarded.
1398+
users := of.getDiscardedUsers(cluster.PodHostname(0))
1399+
Expect(users).To(HaveLen(len(constants.MocoUsers) - 1))
1400+
Expect(users).NotTo(ContainElement(constants.AdminUser))
1401+
})
13031402
})

0 commit comments

Comments
 (0)