Skip to content

Commit c12cae9

Browse files
committed
Redesign CredentialRotation Conditions as action-availability guards
Move RotationReady / DiscardReady from "generation tracking" (observedX == specX) to "action-availability guard" semantics: - RotationReady=True ⟺ Idle: operator may run `credential rotate`. - DiscardReady=True ⟺ AwaitingDiscard: operator may run `credential discard`. - DualPassword=True ⟺ MySQL holds a dual-password set (unchanged). The two Ready conditions are now mutually exclusive — at most one is True at a time — which removes the "both True" ambiguity that made the printer columns hard to read. Also rename OldPasswordRetained → DualPassword (noun form parallel to MemoryPressure; the past-participle "Retained" was easily misread as "retain completed"). Add a StepAwaitingRollout sub-step between DistributingPassword and AwaitingDiscard so DiscardReady=True flips only after the rolling restart has settled. The post-distribute rollout wait moves out of ClusterManager's DISCARD handler into the Reconciler, matching the "DiscardReady=True means discard is genuinely safe now" semantics. Step() trusts DualPassword as the authoritative physical-state signal in the steady-state branch so legacy CRs persisted by the previous controller (which could carry both Ready conditions True) still classify correctly after upgrade. Signed-off-by: shunki-fujita <shunki-fujita@cybozu.co.jp>
1 parent 30181d1 commit c12cae9

15 files changed

Lines changed: 1636 additions & 892 deletions

api/v1beta2/credentialrotation_helpers.go

Lines changed: 227 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
55210
func (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
108242
func (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

Comments
 (0)