@@ -5,100 +5,234 @@ 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 is the action-availability steady state for "rotate is
18+ // allowed": no work is in flight and no dual password is held.
19+ // Also covers a fresh CR with no conditions yet, and the transient
20+ // window between an operator's rotationGeneration bump and the next
21+ // reconcile (RotationReady=True is stale until handleStartRotation
22+ // seeds the cycle).
23+ StepIdle RotationStep = "Idle"
24+
25+ // StepApplyingRetain means ClusterManager is executing or about to
26+ // execute ALTER USER ... RETAIN CURRENT PASSWORD on all instances.
27+ // Detected when newRotation is true and DualPassword is False
28+ // (RETAIN has not yet succeeded for this cycle).
29+ StepApplyingRetain RotationStep = "ApplyingRetain"
30+
31+ // StepDistributingPassword means the Reconciler should distribute
32+ // pending passwords to per-namespace Secrets and trigger the
33+ // rolling restart. Detected when newRotation is true and
34+ // DualPassword is True (RETAIN succeeded; the Reconciler now needs
35+ // to publish the new password).
36+ StepDistributingPassword RotationStep = "DistributingPassword"
37+
38+ // StepAwaitingRollout sits between StepDistributingPassword and
39+ // StepAwaitingDiscard. The Reconciler has distributed the pending
40+ // passwords and triggered the rolling restart; MySQL holds the
41+ // dual-password set. The CR is waiting for the StatefulSet rollout
42+ // to settle before the verification window (StepAwaitingDiscard)
43+ // opens — DiscardReady stays False/Pending until every Pod is
44+ // running with the new password.
45+ StepAwaitingRollout RotationStep = "AwaitingRollout"
46+
47+ // StepAwaitingDiscard is the action-availability steady state for
48+ // "discard is allowed": the rotation phase finished, the
49+ // post-distribute rollout settled, MySQL is holding a dual-password
50+ // set, and the CR is waiting for the operator to bump
51+ // discardGeneration.
52+ StepAwaitingDiscard RotationStep = "AwaitingDiscard"
53+
54+ // StepApplyingDiscard means the discard phase is in flight.
55+ // Subsumes the post-distribute StatefulSet rollout wait:
56+ // ClusterManager checks the StatefulSet rollout state itself and
57+ // defers DISCARD until the rollout has completed.
58+ StepApplyingDiscard RotationStep = "ApplyingDiscard"
59+
60+ // StepFinalizing means DISCARD is complete and the Reconciler
61+ // should promote the pending passwords to current in the source
62+ // Secret. Detected when newDiscard is true and DualPassword has
63+ // flipped back to False.
64+ StepFinalizing RotationStep = "Finalizing"
65+
66+ // StepRotationRefused means the rotation could not start (e.g.
67+ // MySQLCluster has 0 replicas). Nothing has been mutated yet, so
68+ // the CR is still effectively idle for the purposes of accepting a
69+ // new rotationGeneration.
70+ StepRotationRefused RotationStep = "RotationRefused"
71+
72+ // StepRotationBlocked means the rotation phase started but cannot
73+ // progress (e.g. MySQLCluster scaled to 0 mid-RETAIN, after pending
74+ // passwords were written to the source Secret).
75+ StepRotationBlocked RotationStep = "RotationBlocked"
76+
77+ // StepDiscardRefused means the discard phase could not start (e.g.
78+ // MySQLCluster scaled to 0 between AwaitingDiscard and the first
79+ // DISCARD). Dual passwords are still held; manual recovery is
80+ // required if the operator wants to abandon the cycle.
81+ StepDiscardRefused RotationStep = "DiscardRefused"
82+
83+ // StepDiscardBlocked means the discard phase started but cannot
84+ // progress (e.g. MySQLCluster scaled to 0 mid-DISCARD). Partial
85+ // DISCARD may have completed; manual recovery is required.
86+ StepDiscardBlocked RotationStep = "DiscardBlocked"
87+
88+ // StepStalePending means the source Secret is in an inconsistent
89+ // state; manual recovery is required.
90+ StepStalePending RotationStep = "StalePending"
91+ )
92+
93+ // Step derives the current internal workflow step from spec, status, and
94+ // conditions. Pure function of persisted state. See the condition godocs
95+ // (ConditionRotationReady / ConditionDiscardReady / ConditionDualPassword)
96+ // for the semantics each branch projects onto.
97+ func (cr * CredentialRotation ) Step () RotationStep {
98+ // Stale states are stuck and take priority — neither generation
99+ // comparison nor sub-step conditions are meaningful while the
100+ // source Secret is inconsistent.
101+ if ConditionFalseWithReason (cr , ConditionRotationReady , ReasonStale ) ||
102+ ConditionFalseWithReason (cr , ConditionDiscardReady , ReasonStale ) {
103+ return StepStalePending
15104 }
16- return cond .Reason
17- }
18105
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 ""
106+ // Conditions have never been written → the controller hasn't
107+ // reconciled yet. Treat as idle so the very first reconcile can
108+ // initialise the cycle via handleStartRotation.
109+ if apimeta .FindStatusCondition (cr .Status .Conditions , ConditionRotationReady ) == nil {
110+ return StepIdle
28111 }
29- return cond .Reason
30- }
31112
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
113+ newRotation := cr .Spec .RotationGeneration > cr .Status .ObservedRotationGeneration
114+ newDiscard := cr .Spec .DiscardGeneration > cr .Status .ObservedDiscardGeneration
115+ dualPassword := apimeta .IsStatusConditionTrue (cr .Status .Conditions , ConditionDualPassword )
116+ rotationReady := apimeta .IsStatusConditionTrue (cr .Status .Conditions , ConditionRotationReady )
117+
118+ if newRotation {
119+ // Stale RotationReady=True from the previous cycle: route back
120+ // to Idle so handleStartRotation seeds the new cycle.
121+ if rotationReady {
122+ return StepIdle
123+ }
124+ if ConditionFalseWithReason (cr , ConditionRotationReady , ReasonRefused ) {
125+ return StepRotationRefused
126+ }
127+ if ConditionFalseWithReason (cr , ConditionRotationReady , ReasonBlocked ) {
128+ return StepRotationBlocked
129+ }
130+ if ! dualPassword {
131+ // RETAIN has not yet succeeded for this cycle.
132+ return StepApplyingRetain
133+ }
134+ // RETAIN succeeded; pending passwords need to be distributed.
135+ return StepDistributingPassword
39136 }
40- if cond .Status != metav1 .ConditionFalse {
41- return false
137+
138+ if newDiscard {
139+ if ConditionFalseWithReason (cr , ConditionDiscardReady , ReasonRefused ) {
140+ return StepDiscardRefused
141+ }
142+ if ConditionFalseWithReason (cr , ConditionDiscardReady , ReasonBlocked ) {
143+ return StepDiscardBlocked
144+ }
145+ // Stale DiscardReady=True is fine here — dualPassword still
146+ // being True is enough to route to ApplyingDiscard so
147+ // handleApplyingDiscard can flip the condition.
148+ if dualPassword {
149+ return StepApplyingDiscard
150+ }
151+ // DISCARD succeeded (DualPassword flipped back to False);
152+ // the Reconciler now promotes the pending passwords.
153+ return StepFinalizing
154+ }
155+
156+ // Generations match. DualPassword is the authoritative physical-state
157+ // signal — it cannot diverge from MySQL the way the Ready conditions
158+ // can if a CR was persisted by an older controller version.
159+ // DualPassword=False ⇒ Idle.
160+ // DualPassword=True ⇒ AwaitingRollout until the Reconciler flips
161+ // DiscardReady=True after the post-distribute
162+ // rollout settles, then AwaitingDiscard.
163+ // Trusting DualPassword also handles legacy CRs that carried both
164+ // Ready conditions as True simultaneously (the previous "generation
165+ // tracking" semantics), since those always also have DiscardReady=True
166+ // and so map cleanly to AwaitingDiscard.
167+ if dualPassword {
168+ if apimeta .IsStatusConditionTrue (cr .Status .Conditions , ConditionDiscardReady ) {
169+ return StepAwaitingDiscard
170+ }
171+ return StepAwaitingRollout
42172 }
43- switch cond .Reason {
44- case ReasonNotStarted , ReasonCompleted , ReasonRotationRefused :
173+ return StepIdle
174+ }
175+
176+ // IsIdle reports whether the CR is in a state that permits the operator
177+ // to start a new rotation cycle by bumping spec.rotationGeneration. This
178+ // is true when the previous cycle has fully completed (rotation +
179+ // discard), or when the previous rotation request was refused without
180+ // any mutations, or when no cycle has been reconciled yet.
181+ func (cr * CredentialRotation ) IsIdle () bool {
182+ switch cr .Step () {
183+ case StepIdle , StepRotationRefused :
45184 return true
46185 default :
47186 return false
48187 }
49188}
50189
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).
190+ // IsAwaitingDiscard reports whether the CR is in the steady state that
191+ // follows a completed rotation phase, where the operator may bump
192+ // spec.discardGeneration to trigger the discard phase.
193+ func (cr * CredentialRotation ) IsAwaitingDiscard () bool {
194+ return cr .Step () == StepAwaitingDiscard
195+ }
196+
197+ // IsDeletable reports whether the CR may be deleted without abandoning
198+ // an actively progressing cycle. The CR is deletable when:
199+ // - idle (no mutations exist),
200+ // - the most recent rotation request was Refused without mutations,
201+ // - the cycle is stuck in a state that the documented recovery
202+ // procedure resolves by deleting the CR (Blocked / Stale, either
203+ // phase).
204+ //
205+ // AwaitingDiscard and DiscardRefused are NOT deletable: MySQL still
206+ // holds dual passwords, and a naïve deletion would leave behind state
207+ // that no controller can recover from. Operators must use the
208+ // documented recovery procedure (which scales the cluster down to
209+ // transition into Blocked first).
55210func (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 :
211+ switch cr .Step () {
212+ case StepIdle ,
213+ StepRotationRefused ,
214+ StepRotationBlocked ,
215+ StepDiscardBlocked ,
216+ StepStalePending :
65217 return true
66218 default :
67219 return false
68220 }
69221}
70222
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- })
223+ // SetRotationReady sets or updates the RotationReady condition.
224+ func (cr * CredentialRotation ) SetRotationReady (status metav1.ConditionStatus , reason , message string ) {
225+ cr .setCondition (ConditionRotationReady , status , reason , message )
80226}
81227
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- })
228+ // SetDiscardReady sets or updates the DiscardReady condition.
229+ func (cr * CredentialRotation ) SetDiscardReady (status metav1.ConditionStatus , reason , message string ) {
230+ cr .setCondition (ConditionDiscardReady , status , reason , message )
91231}
92232
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- })
233+ // SetDualPassword sets or updates the DualPassword condition.
234+ func (cr * CredentialRotation ) SetDualPassword (status metav1.ConditionStatus , reason , message string ) {
235+ cr .setCondition (ConditionDualPassword , status , reason , message )
102236}
103237
104238// StampObservedGeneration sets status.observedGeneration to the current
@@ -108,3 +242,25 @@ func (cr *CredentialRotation) SetReady(status metav1.ConditionStatus, reason, me
108242func (cr * CredentialRotation ) StampObservedGeneration () {
109243 cr .Status .ObservedGeneration = cr .Generation
110244}
245+
246+ func (cr * CredentialRotation ) setCondition (condType string , status metav1.ConditionStatus , reason , message string ) {
247+ apimeta .SetStatusCondition (& cr .Status .Conditions , metav1.Condition {
248+ Type : condType ,
249+ Status : status ,
250+ Reason : reason ,
251+ Message : message ,
252+ ObservedGeneration : cr .Generation ,
253+ })
254+ }
255+
256+ // ConditionFalseWithReason reports whether the named condition is present
257+ // with Status=False and the given Reason. The Stale / Refused / Blocked
258+ // reasons that Step() checks all live on Status=False, so this helper
259+ // captures that pattern.
260+ func ConditionFalseWithReason (cr * CredentialRotation , condType , reason string ) bool {
261+ cond := apimeta .FindStatusCondition (cr .Status .Conditions , condType )
262+ if cond == nil {
263+ return false
264+ }
265+ return cond .Status == metav1 .ConditionFalse && cond .Reason == reason
266+ }
0 commit comments