Skip to content

Commit 423fd02

Browse files
committed
Implement CredentialRotation CRD for system user password rotation
Add a dedicated CredentialRotation CRD and controller to rotate all 8 MOCO system user passwords without MySQL downtime, using MySQL's dual password feature (8.0.14+). Components: - CRD types with 6-phase lifecycle (Rotating → Retained → Rotated → Discarding → Discarded → Completed) - Validation webhook for create/update - CredentialRotationReconciler for K8s resource operations (Secret distribution, StatefulSet rolling restart, phase transitions) - ClusterManager handlers for MySQL operations (ALTER USER RETAIN, DISCARD OLD PASSWORD, auth plugin migration) - Pending password helpers with idempotent set/confirm operations - DB operations with sql_log_bin=0 to prevent cross-cluster propagation - kubectl moco credential rotate/discard subcommands - Guard in reconcileV1Secret to skip distribution during active rotation Signed-off-by: shunki-fujita <shunki-fujita@cybozu.co.jp>
1 parent 7419952 commit 423fd02

32 files changed

Lines changed: 3436 additions & 69 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package v1beta2
2+
3+
import (
4+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
5+
)
6+
7+
// CredentialRotationSpec defines the desired state of CredentialRotation.
8+
// The target MySQLCluster is identified by the CR's own name and namespace
9+
// (CredentialRotation name must equal MySQLCluster name).
10+
type CredentialRotationSpec struct {
11+
// RotationGeneration is a monotonically increasing counter.
12+
// Incrementing this value triggers a new rotation cycle.
13+
// +optional
14+
RotationGeneration int64 `json:"rotationGeneration,omitempty"`
15+
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.
19+
// +optional
20+
DiscardOldPassword bool `json:"discardOldPassword,omitempty"`
21+
}
22+
23+
// CredentialRotationStatus defines the observed state of CredentialRotation.
24+
type CredentialRotationStatus struct {
25+
// Phase is the current rotation phase.
26+
// +optional
27+
Phase RotationPhase `json:"phase,omitempty"`
28+
29+
// RotationID is the UUID for this rotation cycle.
30+
// +optional
31+
RotationID string `json:"rotationID,omitempty"`
32+
33+
// ObservedRotationGeneration is the last rotationGeneration
34+
// that completed successfully.
35+
// +optional
36+
ObservedRotationGeneration int64 `json:"observedRotationGeneration,omitempty"`
37+
}
38+
39+
// RotationPhase represents the phase of a credential rotation.
40+
type RotationPhase string
41+
42+
const (
43+
RotationPhaseRotating RotationPhase = "Rotating"
44+
RotationPhaseRetained RotationPhase = "Retained"
45+
RotationPhaseRotated RotationPhase = "Rotated"
46+
RotationPhaseDiscarding RotationPhase = "Discarding"
47+
RotationPhaseDiscarded RotationPhase = "Discarded"
48+
RotationPhaseCompleted RotationPhase = "Completed"
49+
)
50+
51+
// +kubebuilder:object:root=true
52+
// +kubebuilder:subresource:status
53+
// +kubebuilder:storageversion
54+
// +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"
57+
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
58+
59+
// CredentialRotation is the Schema for the credentialrotations API
60+
type CredentialRotation struct {
61+
metav1.TypeMeta `json:",inline"`
62+
metav1.ObjectMeta `json:"metadata,omitempty"`
63+
64+
Spec CredentialRotationSpec `json:"spec,omitempty"`
65+
Status CredentialRotationStatus `json:"status,omitempty"`
66+
}
67+
68+
//+kubebuilder:object:root=true
69+
70+
// CredentialRotationList contains a list of CredentialRotation
71+
type CredentialRotationList struct {
72+
metav1.TypeMeta `json:",inline"`
73+
metav1.ListMeta `json:"metadata,omitempty"`
74+
Items []CredentialRotation `json:"items"`
75+
}
76+
77+
func init() {
78+
SchemeBuilder.Register(&CredentialRotation{}, &CredentialRotationList{})
79+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package v1beta2
2+
3+
import (
4+
"context"
5+
6+
apierrors "k8s.io/apimachinery/pkg/api/errors"
7+
"k8s.io/apimachinery/pkg/runtime"
8+
"k8s.io/apimachinery/pkg/runtime/schema"
9+
"k8s.io/apimachinery/pkg/types"
10+
"k8s.io/apimachinery/pkg/util/validation/field"
11+
ctrl "sigs.k8s.io/controller-runtime"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
13+
"sigs.k8s.io/controller-runtime/pkg/webhook"
14+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
15+
)
16+
17+
func (r *CredentialRotation) SetupWebhookWithManager(mgr ctrl.Manager) error {
18+
return ctrl.NewWebhookManagedBy(mgr).
19+
For(r).
20+
WithValidator(&credentialRotationAdmission{client: mgr.GetAPIReader()}).
21+
Complete()
22+
}
23+
24+
type credentialRotationAdmission struct {
25+
client client.Reader
26+
}
27+
28+
//+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
29+
30+
var _ webhook.CustomValidator = &credentialRotationAdmission{}
31+
32+
func (a *credentialRotationAdmission) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
33+
cr := obj.(*CredentialRotation)
34+
35+
var errs field.ErrorList
36+
37+
// MySQLCluster with the same name must exist
38+
cluster := &MySQLCluster{}
39+
if err := a.client.Get(ctx, types.NamespacedName{
40+
Namespace: cr.Namespace,
41+
Name: cr.Name,
42+
}, cluster); err != nil {
43+
if apierrors.IsNotFound(err) {
44+
errs = append(errs, field.Invalid(field.NewPath("metadata", "name"), cr.Name,
45+
"MySQLCluster with the same name must exist in the same namespace"))
46+
} else {
47+
errs = append(errs, field.InternalError(field.NewPath("metadata", "name"), err))
48+
}
49+
} else {
50+
// Replicas must be > 0
51+
if cluster.Spec.Replicas <= 0 {
52+
errs = append(errs, field.Invalid(field.NewPath("metadata", "name"), cr.Name,
53+
"target MySQLCluster must have replicas > 0"))
54+
}
55+
}
56+
57+
// rotationGeneration must be > 0
58+
if cr.Spec.RotationGeneration <= 0 {
59+
errs = append(errs, field.Invalid(field.NewPath("spec", "rotationGeneration"),
60+
cr.Spec.RotationGeneration, "must be > 0"))
61+
}
62+
63+
// discardOldPassword must be false
64+
if cr.Spec.DiscardOldPassword {
65+
errs = append(errs, field.Invalid(field.NewPath("spec", "discardOldPassword"),
66+
cr.Spec.DiscardOldPassword, "must be false on creation"))
67+
}
68+
69+
if len(errs) > 0 {
70+
return nil, apierrors.NewInvalid(
71+
schema.GroupKind{Group: GroupVersion.Group, Kind: "CredentialRotation"}, cr.Name, errs)
72+
}
73+
return nil, nil
74+
}
75+
76+
func (a *credentialRotationAdmission) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
77+
oldCR := oldObj.(*CredentialRotation)
78+
newCR := newObj.(*CredentialRotation)
79+
80+
var errs field.ErrorList
81+
82+
// rotationGeneration must be monotonically increasing
83+
if newCR.Spec.RotationGeneration < oldCR.Spec.RotationGeneration {
84+
errs = append(errs, field.Invalid(field.NewPath("spec", "rotationGeneration"),
85+
newCR.Spec.RotationGeneration, "must be >= previous value (monotonically increasing)"))
86+
}
87+
88+
generationChanged := newCR.Spec.RotationGeneration > oldCR.Spec.RotationGeneration
89+
90+
if generationChanged {
91+
// rotationGeneration can only increase when Phase is "" or Completed
92+
phase := oldCR.Status.Phase
93+
if phase != "" && phase != RotationPhaseCompleted {
94+
errs = append(errs, field.Forbidden(field.NewPath("spec", "rotationGeneration"),
95+
"can only increment rotationGeneration when phase is empty or Completed"))
96+
}
97+
98+
// When rotationGeneration increases, discardOldPassword must be false
99+
if newCR.Spec.DiscardOldPassword {
100+
errs = append(errs, field.Invalid(field.NewPath("spec", "discardOldPassword"),
101+
newCR.Spec.DiscardOldPassword, "must be false when incrementing rotationGeneration"))
102+
}
103+
104+
// Check MySQLCluster replicas > 0
105+
cluster := &MySQLCluster{}
106+
if err := a.client.Get(ctx, types.NamespacedName{
107+
Namespace: newCR.Namespace,
108+
Name: newCR.Name,
109+
}, cluster); err != nil {
110+
if apierrors.IsNotFound(err) {
111+
errs = append(errs, field.Invalid(field.NewPath("metadata", "name"), newCR.Name,
112+
"MySQLCluster with the same name must exist"))
113+
} else {
114+
errs = append(errs, field.InternalError(field.NewPath("metadata", "name"), err))
115+
}
116+
} else if cluster.Spec.Replicas <= 0 {
117+
errs = append(errs, field.Invalid(field.NewPath("metadata", "name"), newCR.Name,
118+
"target MySQLCluster must have replicas > 0"))
119+
}
120+
} else {
121+
// When rotationGeneration is unchanged, discardOldPassword can only go false→true
122+
if oldCR.Spec.DiscardOldPassword && !newCR.Spec.DiscardOldPassword {
123+
errs = append(errs, field.Forbidden(field.NewPath("spec", "discardOldPassword"),
124+
"cannot change from true to false without incrementing rotationGeneration"))
125+
}
126+
127+
// discardOldPassword=true requires Phase==Rotated
128+
if newCR.Spec.DiscardOldPassword && !oldCR.Spec.DiscardOldPassword {
129+
if oldCR.Status.Phase != RotationPhaseRotated {
130+
errs = append(errs, field.Forbidden(field.NewPath("spec", "discardOldPassword"),
131+
"can only set to true when phase is Rotated"))
132+
}
133+
}
134+
}
135+
136+
if len(errs) > 0 {
137+
return nil, apierrors.NewInvalid(
138+
schema.GroupKind{Group: GroupVersion.Group, Kind: "CredentialRotation"}, newCR.Name, errs)
139+
}
140+
return nil, nil
141+
}
142+
143+
func (a *credentialRotationAdmission) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
144+
return nil, nil
145+
}

0 commit comments

Comments
 (0)