@@ -5,100 +5,201 @@ import (
55 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
66)
77
8- // CurrentStep returns the Reason of the Rotating condition if it is True,
9- // otherwise the empty string. The empty string indicates the CR is not in
10- // an in-flight sub-step (idle, completed, refused, or condition absent).
11- func (cr * CredentialRotation ) CurrentStep () string {
12- cond := apimeta .FindStatusCondition (cr .Status .Conditions , ConditionRotating )
13- if cond == nil || cond .Status != metav1 .ConditionTrue {
14- return ""
8+ // RotationStep represents the internal workflow step of a rotation cycle.
9+ // It is derived on the fly from the combination of conditions and the
10+ // rotationGeneration / discardGeneration comparisons; it is NOT stored on
11+ // the CR. Persisting a phase enum violates the Kubernetes API
12+ // conventions, which is why the workflow is represented as a small set
13+ // of orthogonal Conditions instead.
14+ type RotationStep string
15+
16+ const (
17+ // StepIdle means no work is in flight and no new spec change has
18+ // been issued. The CR is either freshly created with conditions not
19+ // yet initialised, or the most recent cycle has fully completed.
20+ StepIdle RotationStep = "Idle"
21+
22+ // StepApplyingRetain means ClusterManager is executing or about to
23+ // execute ALTER USER ... RETAIN CURRENT PASSWORD on all instances.
24+ // Detected when newRotation is true and DualPassword is False
25+ // (RETAIN has not yet succeeded for this cycle).
26+ StepApplyingRetain RotationStep = "ApplyingRetain"
27+
28+ // StepDistributingPassword means the Reconciler should distribute
29+ // pending passwords to per-namespace Secrets and trigger the
30+ // rolling restart. Detected when newRotation is true and
31+ // DualPassword is True (RETAIN succeeded; the Reconciler now needs
32+ // to publish the new password).
33+ StepDistributingPassword RotationStep = "DistributingPassword"
34+
35+ // StepAwaitingDiscard means the rotation phase is complete and the
36+ // CR is waiting for the operator to bump discardGeneration.
37+ StepAwaitingDiscard RotationStep = "AwaitingDiscard"
38+
39+ // StepApplyingDiscard means the discard phase is in flight.
40+ // Subsumes the post-distribute StatefulSet rollout wait:
41+ // ClusterManager checks the StatefulSet rollout state itself and
42+ // defers DISCARD until the rollout has completed.
43+ StepApplyingDiscard RotationStep = "ApplyingDiscard"
44+
45+ // StepFinalizing means DISCARD is complete and the Reconciler
46+ // should promote the pending passwords to current in the source
47+ // Secret. Detected when newDiscard is true and DualPassword has
48+ // flipped back to False.
49+ StepFinalizing RotationStep = "Finalizing"
50+
51+ // StepRotationRefused means the rotation could not start (e.g.
52+ // MySQLCluster has 0 replicas). Nothing has been mutated yet, so
53+ // the CR is still effectively idle for the purposes of accepting a
54+ // new rotationGeneration.
55+ StepRotationRefused RotationStep = "RotationRefused"
56+
57+ // StepRotationBlocked means the rotation phase started but cannot
58+ // progress (e.g. MySQLCluster scaled to 0 mid-RETAIN, after pending
59+ // passwords were written to the source Secret).
60+ StepRotationBlocked RotationStep = "RotationBlocked"
61+
62+ // StepDiscardRefused means the discard phase could not start (e.g.
63+ // MySQLCluster scaled to 0 between AwaitingDiscard and the first
64+ // DISCARD). Dual passwords are still held; manual recovery is
65+ // required if the operator wants to abandon the cycle.
66+ StepDiscardRefused RotationStep = "DiscardRefused"
67+
68+ // StepDiscardBlocked means the discard phase started but cannot
69+ // progress (e.g. MySQLCluster scaled to 0 mid-DISCARD). Partial
70+ // DISCARD may have completed; manual recovery is required.
71+ StepDiscardBlocked RotationStep = "DiscardBlocked"
72+
73+ // StepStalePending means the source Secret is in an inconsistent
74+ // state; manual recovery is required.
75+ StepStalePending RotationStep = "StalePending"
76+ )
77+
78+ // Step derives the current internal workflow step from spec, status, and
79+ // conditions. The result is purely a function of the CR's persisted
80+ // state, so callers can rely on it being stable across reconciles when
81+ // nothing has changed.
82+ func (cr * CredentialRotation ) Step () RotationStep {
83+ // Stale states are stuck and take priority — neither generation
84+ // comparison nor sub-step conditions are meaningful while the
85+ // source Secret is inconsistent.
86+ if ConditionFalseWithReason (cr , ConditionRotationReady , ReasonStale ) ||
87+ ConditionFalseWithReason (cr , ConditionDiscardReady , ReasonStale ) {
88+ return StepStalePending
1589 }
16- return cond .Reason
17- }
1890
19- // RotatingReason returns the current Reason of the Rotating condition
20- // regardless of its Status (or the empty string if the condition is absent).
21- // Use this when you need to distinguish terminal Status=False reasons such
22- // as Completed or RotationRefused from the absence of the condition; for
23- // "is the CR in an active sub-step?" prefer CurrentStep.
24- func (cr * CredentialRotation ) RotatingReason () string {
25- cond := apimeta .FindStatusCondition (cr .Status .Conditions , ConditionRotating )
26- if cond == nil {
27- return ""
91+ // Conditions have never been written → the controller hasn't
92+ // reconciled yet. Treat as idle so the very first reconcile can
93+ // initialise the cycle via handleStartRotation.
94+ if apimeta .FindStatusCondition (cr .Status .Conditions , ConditionRotationReady ) == nil {
95+ return StepIdle
2896 }
29- return cond .Reason
30- }
3197
32- // IsIdle reports whether the CR is in a state that permits starting a new
33- // rotation cycle. Idle means the Rotating condition is absent, or has
34- // Status=False with one of the terminal idle reasons.
35- func (cr * CredentialRotation ) IsIdle () bool {
36- cond := apimeta .FindStatusCondition (cr .Status .Conditions , ConditionRotating )
37- if cond == nil {
38- return true
98+ newRotation := cr .Spec .RotationGeneration > cr .Status .ObservedRotationGeneration
99+ newDiscard := cr .Spec .DiscardGeneration > cr .Status .ObservedDiscardGeneration
100+ dualPassword := apimeta .IsStatusConditionTrue (cr .Status .Conditions , ConditionDualPassword )
101+
102+ if newRotation {
103+ if ConditionFalseWithReason (cr , ConditionRotationReady , ReasonRefused ) {
104+ return StepRotationRefused
105+ }
106+ if ConditionFalseWithReason (cr , ConditionRotationReady , ReasonBlocked ) {
107+ return StepRotationBlocked
108+ }
109+ if ! dualPassword {
110+ // RETAIN has not yet succeeded for this cycle.
111+ return StepApplyingRetain
112+ }
113+ // RETAIN succeeded; pending passwords need to be distributed.
114+ return StepDistributingPassword
39115 }
40- if cond .Status != metav1 .ConditionFalse {
41- return false
116+
117+ if newDiscard {
118+ if ConditionFalseWithReason (cr , ConditionDiscardReady , ReasonRefused ) {
119+ return StepDiscardRefused
120+ }
121+ if ConditionFalseWithReason (cr , ConditionDiscardReady , ReasonBlocked ) {
122+ return StepDiscardBlocked
123+ }
124+ if dualPassword {
125+ // DISCARD has not yet succeeded; ClusterManager will run
126+ // it once the post-distribute rollout has settled.
127+ return StepApplyingDiscard
128+ }
129+ // DISCARD succeeded (DualPassword flipped back to False);
130+ // the Reconciler now promotes the pending passwords.
131+ return StepFinalizing
132+ }
133+
134+ // Generations match. We are either fully idle, or sitting in the
135+ // awaiting-discard steady state (rotation phase done, dual passwords
136+ // still held).
137+ if dualPassword {
138+ return StepAwaitingDiscard
42139 }
43- switch cond .Reason {
44- case ReasonNotStarted , ReasonCompleted , ReasonRotationRefused :
140+ return StepIdle
141+ }
142+
143+ // IsIdle reports whether the CR is in a state that permits the operator
144+ // to start a new rotation cycle by bumping spec.rotationGeneration. This
145+ // is true when the previous cycle has fully completed (rotation +
146+ // discard), or when the previous rotation request was refused without
147+ // any mutations, or when no cycle has been reconciled yet.
148+ func (cr * CredentialRotation ) IsIdle () bool {
149+ switch cr .Step () {
150+ case StepIdle , StepRotationRefused :
45151 return true
46152 default :
47153 return false
48154 }
49155}
50156
51- // IsDeletable reports whether the CR may be deleted without abandoning an
52- // actively progressing rotation. The CR is deletable when idle, or when
53- // stuck in a state that the documented recovery procedure resolves by
54- // deleting the CR (RotationBlocked or StalePending).
157+ // IsAwaitingDiscard reports whether the CR is in the steady state that
158+ // follows a completed rotation phase, where the operator may bump
159+ // spec.discardGeneration to trigger the discard phase.
160+ func (cr * CredentialRotation ) IsAwaitingDiscard () bool {
161+ return cr .Step () == StepAwaitingDiscard
162+ }
163+
164+ // IsDeletable reports whether the CR may be deleted without abandoning
165+ // an actively progressing cycle. The CR is deletable when:
166+ // - idle (no mutations exist),
167+ // - the most recent rotation request was Refused without mutations,
168+ // - the cycle is stuck in a state that the documented recovery
169+ // procedure resolves by deleting the CR (Blocked / Stale, either
170+ // phase).
171+ //
172+ // AwaitingDiscard and DiscardRefused are NOT deletable: MySQL still
173+ // holds dual passwords, and a naïve deletion would leave behind state
174+ // that no controller can recover from. Operators must use the
175+ // documented recovery procedure (which scales the cluster down to
176+ // transition into Blocked first).
55177func (cr * CredentialRotation ) IsDeletable () bool {
56- if cr .IsIdle () {
57- return true
58- }
59- cond := apimeta .FindStatusCondition (cr .Status .Conditions , ConditionRotating )
60- if cond == nil || cond .Status != metav1 .ConditionTrue {
61- return false
62- }
63- switch cond .Reason {
64- case ReasonRotationBlocked , ReasonStalePending :
178+ switch cr .Step () {
179+ case StepIdle ,
180+ StepRotationRefused ,
181+ StepRotationBlocked ,
182+ StepDiscardBlocked ,
183+ StepStalePending :
65184 return true
66185 default :
67186 return false
68187 }
69188}
70189
71- // SetRotating sets or updates the Rotating condition.
72- func (cr * CredentialRotation ) SetRotating (status metav1.ConditionStatus , reason , message string ) {
73- apimeta .SetStatusCondition (& cr .Status .Conditions , metav1.Condition {
74- Type : ConditionRotating ,
75- Status : status ,
76- Reason : reason ,
77- Message : message ,
78- ObservedGeneration : cr .Generation ,
79- })
190+ // SetRotationReady sets or updates the RotationReady condition.
191+ func (cr * CredentialRotation ) SetRotationReady (status metav1.ConditionStatus , reason , message string ) {
192+ cr .setCondition (ConditionRotationReady , status , reason , message )
80193}
81194
82- // SetOldPasswordRetained sets or updates the OldPasswordRetained condition.
83- func (cr * CredentialRotation ) SetOldPasswordRetained (status metav1.ConditionStatus , reason , message string ) {
84- apimeta .SetStatusCondition (& cr .Status .Conditions , metav1.Condition {
85- Type : ConditionOldPasswordRetained ,
86- Status : status ,
87- Reason : reason ,
88- Message : message ,
89- ObservedGeneration : cr .Generation ,
90- })
195+ // SetDiscardReady sets or updates the DiscardReady condition.
196+ func (cr * CredentialRotation ) SetDiscardReady (status metav1.ConditionStatus , reason , message string ) {
197+ cr .setCondition (ConditionDiscardReady , status , reason , message )
91198}
92199
93- // SetReady sets or updates the Ready condition.
94- func (cr * CredentialRotation ) SetReady (status metav1.ConditionStatus , reason , message string ) {
95- apimeta .SetStatusCondition (& cr .Status .Conditions , metav1.Condition {
96- Type : ConditionReady ,
97- Status : status ,
98- Reason : reason ,
99- Message : message ,
100- ObservedGeneration : cr .Generation ,
101- })
200+ // SetDualPassword sets or updates the DualPassword condition.
201+ func (cr * CredentialRotation ) SetDualPassword (status metav1.ConditionStatus , reason , message string ) {
202+ cr .setCondition (ConditionDualPassword , status , reason , message )
102203}
103204
104205// StampObservedGeneration sets status.observedGeneration to the current
@@ -108,3 +209,25 @@ func (cr *CredentialRotation) SetReady(status metav1.ConditionStatus, reason, me
108209func (cr * CredentialRotation ) StampObservedGeneration () {
109210 cr .Status .ObservedGeneration = cr .Generation
110211}
212+
213+ func (cr * CredentialRotation ) setCondition (condType string , status metav1.ConditionStatus , reason , message string ) {
214+ apimeta .SetStatusCondition (& cr .Status .Conditions , metav1.Condition {
215+ Type : condType ,
216+ Status : status ,
217+ Reason : reason ,
218+ Message : message ,
219+ ObservedGeneration : cr .Generation ,
220+ })
221+ }
222+
223+ // ConditionFalseWithReason reports whether the named condition is present
224+ // with Status=False and the given Reason. The Stale / Refused / Blocked
225+ // reasons that Step() checks all live on Status=False, so this helper
226+ // captures that pattern.
227+ func ConditionFalseWithReason (cr * CredentialRotation , condType , reason string ) bool {
228+ cond := apimeta .FindStatusCondition (cr .Status .Conditions , condType )
229+ if cond == nil {
230+ return false
231+ }
232+ return cond .Status == metav1 .ConditionFalse && cond .Reason == reason
233+ }
0 commit comments