Skip to content

Commit eea952b

Browse files
committed
Reshape CredentialRotation Conditions to API conventions
Replace the three Conditions (Rotating, OldPasswordRetained, Ready) with the convention-compliant trio RotationReady, DiscardReady, and DualPassword. The old Rotating condition was a present-tense type whose Reason encoded a workflow state machine, and identifiers such as NotStarted / Completed carried different meanings across Rotating and Ready — all explicitly flagged in the PR review as API convention violations. Each new Condition is an independent past-tense / adjective / noun observation: - RotationReady tracks observedRotationGeneration == spec.rotationGeneration. - DiscardReady tracks observedDiscardGeneration == spec.discardGeneration. - DualPassword tracks MySQL's dual-password state. Named to parallel Kubernetes conditions such as MemoryPressure, where True describes the situation rather than health — avoiding the past-tense surface-form mismatch that made the earlier OldPasswordRetained=True read as "retain completed" instead of "retain currently in effect." The workflow step is no longer stored on the CR. A new helper CredentialRotation.Step() derives it on the fly from the three Conditions plus the generation comparisons, so callers (Reconciler dispatch, ClusterManager dispatch, webhook IsIdle/IsAwaitingDiscard, kubectl-moco CLI) only see a single function. observedRotationGeneration is now promoted at the end of the rotation phase, so RotationReady can flip True independently of DiscardReady — a kubectl wait on RotationReady no longer blocks until discard completes. The post-distribute StatefulSet rollout wait moves into ClusterManager's DISCARD handler, eliminating the need for a separate RolloutSettled Condition. To keep the Reconciler-side DiscardReady transition observable, ClusterManager blocks on DiscardReady=False/Pending before touching MySQL — the Reconciler always wins the initial state-set and emits the DiscardStarted Event. Refused, Blocked, and Stale errors are surfaced via the Reason field of RotationReady / DiscardReady (single meaning per identifier across both Ready conditions). The dispatch handles recovery from any of Initial / Refused / Blocked uniformly by detecting "not yet DiscardReady=False/Pending". The design doc is rewritten to match: it explains why three Conditions satisfy the API conventions, walks through the derived Step matrix, documents the Reconciler/ClusterManager handshake on DiscardReady, and updates the crash-safety table for the new boundaries. Signed-off-by: shunki-fujita <shunki-fujita@cybozu.co.jp>
1 parent 30181d1 commit eea952b

14 files changed

Lines changed: 1049 additions & 874 deletions

api/v1beta2/credentialrotation_helpers.go

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

Comments
 (0)