Skip to content

Commit 4a125b3

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. - Replace spec.discardOldPassword (bool) with spec.discardGeneration (int64) to eliminate edge-trigger semantics and the webhook transition contortions it required (false->true reject, forced reset on rotation bump). discardGeneration is monotonic and must satisfy 0 <= discardGeneration <= rotationGeneration; bumping it to match rotationGeneration triggers discard. Adds status.observedDiscardGeneration to mirror the existing rotation pattern, and updates the webhook to enforce monotonicity and the upper-bound invariant (phase guards remain: rotation bump requires \"\"/Completed, discard bump requires Rotated). kubectl-moco credential discard now patches discardGeneration to match rotationGeneration instead of toggling a bool. - Update the design doc to match the implementation (MySQLClusterReconciler code, DISCARD idempotency, stale-CR handling, MySQLCluster deletion, validation webhook, discardGeneration semantics, GitOps-vs-CLI guidance). - Add unit and envtest coverage for the new paths. Signed-off-by: shunki-fujita <shunki-fujita@cybozu.co.jp>
1 parent 600d50f commit 4a125b3

20 files changed

Lines changed: 918 additions & 262 deletions

api/v1beta2/credentialrotation_types.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ type CredentialRotationSpec struct {
1313
// +optional
1414
RotationGeneration int64 `json:"rotationGeneration,omitempty"`
1515

16-
// DiscardOldPassword triggers the discard phase.
17-
// Can only be set to true when Phase is Rotated.
18-
// Must be reset to false when incrementing rotationGeneration.
16+
// DiscardGeneration is a monotonically increasing counter that triggers
17+
// the discard phase. Must satisfy 0 <= discardGeneration <= rotationGeneration.
18+
// Bumping this value (typically to match rotationGeneration) signals the
19+
// controller to discard the retained old password from the previous
20+
// rotation. The bump is only honored when Phase is Rotated.
1921
// +optional
20-
DiscardOldPassword bool `json:"discardOldPassword,omitempty"`
22+
DiscardGeneration int64 `json:"discardGeneration,omitempty"`
2123
}
2224

2325
// CredentialRotationStatus defines the observed state of CredentialRotation.
@@ -34,6 +36,11 @@ type CredentialRotationStatus struct {
3436
// that completed successfully.
3537
// +optional
3638
ObservedRotationGeneration int64 `json:"observedRotationGeneration,omitempty"`
39+
40+
// ObservedDiscardGeneration is the last discardGeneration
41+
// that completed successfully.
42+
// +optional
43+
ObservedDiscardGeneration int64 `json:"observedDiscardGeneration,omitempty"`
3744
}
3845

3946
// RotationPhase represents the phase of a credential rotation.
@@ -52,8 +59,10 @@ const (
5259
// +kubebuilder:subresource:status
5360
// +kubebuilder:storageversion
5461
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
55-
// +kubebuilder:printcolumn:name="Generation",type="integer",JSONPath=".spec.rotationGeneration"
56-
// +kubebuilder:printcolumn:name="Observed",type="integer",JSONPath=".status.observedRotationGeneration"
62+
// +kubebuilder:printcolumn:name="RotationGen",type="integer",JSONPath=".spec.rotationGeneration"
63+
// +kubebuilder:printcolumn:name="ObservedRotation",type="integer",JSONPath=".status.observedRotationGeneration"
64+
// +kubebuilder:printcolumn:name="DiscardGen",type="integer",JSONPath=".spec.discardGeneration"
65+
// +kubebuilder:printcolumn:name="ObservedDiscard",type="integer",JSONPath=".status.observedDiscardGeneration"
5766
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
5867

5968
// CredentialRotation is the Schema for the credentialrotations API

api/v1beta2/credentialrotation_webhook.go

Lines changed: 89 additions & 27 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

@@ -55,10 +56,14 @@ func (a *credentialRotationAdmission) ValidateCreate(ctx context.Context, cr *Cr
5556
cr.Spec.RotationGeneration, "must be > 0"))
5657
}
5758

58-
// discardOldPassword must be false
59-
if cr.Spec.DiscardOldPassword {
60-
errs = append(errs, field.Invalid(field.NewPath("spec", "discardOldPassword"),
61-
cr.Spec.DiscardOldPassword, "must be false on creation"))
59+
// discardGeneration must be in [0, rotationGeneration]
60+
if cr.Spec.DiscardGeneration < 0 {
61+
errs = append(errs, field.Invalid(field.NewPath("spec", "discardGeneration"),
62+
cr.Spec.DiscardGeneration, "must be >= 0"))
63+
}
64+
if cr.Spec.DiscardGeneration > cr.Spec.RotationGeneration {
65+
errs = append(errs, field.Invalid(field.NewPath("spec", "discardGeneration"),
66+
cr.Spec.DiscardGeneration, "must be <= rotationGeneration"))
6267
}
6368

6469
if len(errs) > 0 {
@@ -76,23 +81,28 @@ func (a *credentialRotationAdmission) ValidateUpdate(ctx context.Context, oldCR,
7681
errs = append(errs, field.Invalid(field.NewPath("spec", "rotationGeneration"),
7782
newCR.Spec.RotationGeneration, "must be >= previous value (monotonically increasing)"))
7883
}
84+
// discardGeneration must be monotonically increasing
85+
if newCR.Spec.DiscardGeneration < oldCR.Spec.DiscardGeneration {
86+
errs = append(errs, field.Invalid(field.NewPath("spec", "discardGeneration"),
87+
newCR.Spec.DiscardGeneration, "must be >= previous value (monotonically increasing)"))
88+
}
89+
// discardGeneration must not exceed rotationGeneration
90+
if newCR.Spec.DiscardGeneration > newCR.Spec.RotationGeneration {
91+
errs = append(errs, field.Invalid(field.NewPath("spec", "discardGeneration"),
92+
newCR.Spec.DiscardGeneration, "must be <= rotationGeneration"))
93+
}
7994

80-
generationChanged := newCR.Spec.RotationGeneration > oldCR.Spec.RotationGeneration
95+
rotationIncreased := newCR.Spec.RotationGeneration > oldCR.Spec.RotationGeneration
96+
discardIncreased := newCR.Spec.DiscardGeneration > oldCR.Spec.DiscardGeneration
8197

82-
if generationChanged {
98+
if rotationIncreased {
8399
// rotationGeneration can only increase when Phase is "" or Completed
84100
phase := oldCR.Status.Phase
85101
if phase != "" && phase != RotationPhaseCompleted {
86102
errs = append(errs, field.Forbidden(field.NewPath("spec", "rotationGeneration"),
87103
"can only increment rotationGeneration when phase is empty or Completed"))
88104
}
89105

90-
// When rotationGeneration increases, discardOldPassword must be false
91-
if newCR.Spec.DiscardOldPassword {
92-
errs = append(errs, field.Invalid(field.NewPath("spec", "discardOldPassword"),
93-
newCR.Spec.DiscardOldPassword, "must be false when incrementing rotationGeneration"))
94-
}
95-
96106
// Check MySQLCluster replicas > 0
97107
cluster := &MySQLCluster{}
98108
if err := a.client.Get(ctx, types.NamespacedName{
@@ -109,19 +119,14 @@ func (a *credentialRotationAdmission) ValidateUpdate(ctx context.Context, oldCR,
109119
errs = append(errs, field.Invalid(field.NewPath("metadata", "name"), newCR.Name,
110120
"target MySQLCluster must have replicas > 0"))
111121
}
112-
} else {
113-
// When rotationGeneration is unchanged, discardOldPassword can only go false→true
114-
if oldCR.Spec.DiscardOldPassword && !newCR.Spec.DiscardOldPassword {
115-
errs = append(errs, field.Forbidden(field.NewPath("spec", "discardOldPassword"),
116-
"cannot change from true to false without incrementing rotationGeneration"))
117-
}
122+
}
118123

119-
// discardOldPassword=true requires Phase==Rotated
120-
if newCR.Spec.DiscardOldPassword && !oldCR.Spec.DiscardOldPassword {
121-
if oldCR.Status.Phase != RotationPhaseRotated {
122-
errs = append(errs, field.Forbidden(field.NewPath("spec", "discardOldPassword"),
123-
"can only set to true when phase is Rotated"))
124-
}
124+
if discardIncreased && !rotationIncreased {
125+
// discardGeneration can only increase when Phase is Rotated
126+
// (during the same rotation cycle in which discard is being requested).
127+
if oldCR.Status.Phase != RotationPhaseRotated {
128+
errs = append(errs, field.Forbidden(field.NewPath("spec", "discardGeneration"),
129+
"can only increment discardGeneration when phase is Rotated"))
125130
}
126131
}
127132

@@ -132,6 +137,63 @@ func (a *credentialRotationAdmission) ValidateUpdate(ctx context.Context, oldCR,
132137
return nil, nil
133138
}
134139

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

0 commit comments

Comments
 (0)