Skip to content

Commit bb28b21

Browse files
committed
Address review comments on CredentialRotation PR
- Remove SetOwnerReference in CLI (controller handles it; kubeClient has no Scheme() method) - Reuse existing ROTATION_ID from source Secret in handleStartRotation to avoid wedging on crash recovery between Secret update and status update - Fail closed in reconcileV1Secret guard: return error on transient CredentialRotation Get failures instead of proceeding with distribution - Fix test assertion: use Equal("") instead of BeElementOf with duplicate values Signed-off-by: shunki-fujita <shunki-fujita@cybozu.co.jp>
1 parent 5447a2d commit bb28b21

9 files changed

Lines changed: 174 additions & 67 deletions

File tree

clustering/password_rotation.go

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/cybozu-go/moco/pkg/password"
1111
corev1 "k8s.io/api/core/v1"
1212
apierrors "k8s.io/apimachinery/pkg/api/errors"
13+
"k8s.io/apimachinery/pkg/api/meta"
1314
"k8s.io/client-go/util/retry"
1415
"sigs.k8s.io/controller-runtime/pkg/client"
1516
)
@@ -23,7 +24,7 @@ func (p *managerProcess) handlePasswordRotation(ctx context.Context, ss *StatusS
2324
Namespace: p.name.Namespace,
2425
Name: p.name.Name,
2526
}, cr)
26-
if apierrors.IsNotFound(err) {
27+
if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) {
2728
return false, nil
2829
}
2930
if err != nil {
@@ -71,29 +72,42 @@ func (p *managerProcess) handleRotatingPhase(ctx context.Context, ss *StatusSet,
7172
return false, nil
7273
}
7374

74-
// Pre-check: verify no instance has stale dual passwords.
7575
currentPasswd, err := password.NewMySQLPasswordFromSecret(sourceSecret)
7676
if err != nil {
7777
return false, err
7878
}
79-
for idx := 0; idx < replicas; idx++ {
80-
op, err := p.dbf.New(ctx, cluster, currentPasswd, idx)
81-
if err != nil {
82-
return false, err
83-
}
84-
dualFound, dualUser, checkErr := checkInstanceDualPasswords(ctx, op, idx)
85-
op.Close()
86-
if checkErr != nil {
87-
return false, checkErr
79+
80+
// Pre-check: verify no instance has stale dual passwords from outside
81+
// this rotation cycle. Skip if RETAIN_STARTED marker is present (crash
82+
// recovery after partial RETAIN — per-user HasDualPassword in
83+
// rotateInstanceUsers provides idempotency).
84+
retainStarted := string(sourceSecret.Data[password.RetainStartedKey]) == cr.Status.RotationID
85+
if !retainStarted {
86+
for idx := 0; idx < replicas; idx++ {
87+
op, err := p.dbf.New(ctx, cluster, currentPasswd, idx)
88+
if err != nil {
89+
return false, err
90+
}
91+
dualFound, dualUser, checkErr := checkInstanceDualPasswords(ctx, op, idx)
92+
op.Close()
93+
if checkErr != nil {
94+
return false, checkErr
95+
}
96+
if dualFound {
97+
log.Info("waiting: instance has pre-existing dual password",
98+
"instance", idx, "user", dualUser)
99+
p.recorder.Eventf(cluster, corev1.EventTypeWarning, "DualPasswordExists",
100+
"Cannot proceed with RETAIN: instance %d user %s already has a dual password. "+
101+
"Follow the recovery procedure in docs/designdoc/credential_rotation_crd.md.",
102+
idx, dualUser)
103+
return false, nil
104+
}
88105
}
89-
if dualFound {
90-
log.Info("waiting: instance has pre-existing dual password",
91-
"instance", idx, "user", dualUser)
92-
p.recorder.Eventf(cluster, corev1.EventTypeWarning, "DualPasswordExists",
93-
"Cannot proceed with RETAIN: instance %d user %s already has a dual password. "+
94-
"Follow the recovery procedure in docs/designdoc/credential_rotation_crd.md.",
95-
idx, dualUser)
96-
return false, nil
106+
107+
// Mark that pre-check passed and RETAIN is about to start.
108+
sourceSecret.Data[password.RetainStartedKey] = []byte(cr.Status.RotationID)
109+
if err := p.client.Update(ctx, sourceSecret); err != nil {
110+
return false, fmt.Errorf("failed to set RETAIN_STARTED marker: %w", err)
97111
}
98112
}
99113

clustering/process.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/cybozu-go/moco/pkg/metrics"
1313
"github.com/go-logr/logr"
1414
"github.com/prometheus/client_golang/prometheus"
15+
corev1 "k8s.io/api/core/v1"
1516
"k8s.io/apimachinery/pkg/api/equality"
1617
"k8s.io/apimachinery/pkg/api/meta"
1718
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -200,8 +201,13 @@ func (p *managerProcess) do(ctx context.Context) (bool, error) {
200201
}
201202

202203
// Handle credential rotation if a CredentialRotation CR exists.
204+
// Errors are non-fatal: rotation failures must not block core clustering
205+
// operations (failover, switchover) that maintain availability.
203206
if redo, err := p.handlePasswordRotation(ctx, ss); err != nil {
204-
return false, fmt.Errorf("failed to handle password rotation: %w", err)
207+
log := logFromContext(ctx)
208+
log.Error(err, "password rotation error (will retry next cycle)")
209+
p.recorder.Eventf(ss.Cluster, corev1.EventTypeWarning, "PasswordRotationError",
210+
"Password rotation encountered an error: %v", err)
205211
} else if redo {
206212
return true, nil
207213
}

cmd/kubectl-moco/cmd/credential.go

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,39 @@ import (
55
"errors"
66
"fmt"
77

8+
"encoding/json"
9+
810
mocov1beta2 "github.com/cybozu-go/moco/api/v1beta2"
911
"github.com/cybozu-go/moco/pkg/constants"
1012
"github.com/spf13/cobra"
1113
apierrors "k8s.io/apimachinery/pkg/api/errors"
1214
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1315
"k8s.io/apimachinery/pkg/types"
14-
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
1517
)
1618

1719
var credentialConfig struct {
1820
user string
1921
format string
2022
}
2123

22-
// credentialCmd represents the credential parent command
24+
// credentialCmd represents the credential parent command.
25+
// When called without a subcommand (e.g. "credential CLUSTER_NAME"),
26+
// it falls back to "show" for backward compatibility.
2327
var credentialCmd = &cobra.Command{
24-
Use: "credential",
28+
Use: "credential [CLUSTER_NAME]",
2529
Short: "Manage MySQL credentials",
26-
Long: "Manage MySQL credentials for a MOCO cluster.",
30+
Long: "Manage MySQL credentials for a MOCO cluster. When called with a cluster name and no subcommand, shows the credential (backward compatible).",
31+
Args: cobra.MaximumNArgs(1),
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
if len(args) == 0 {
34+
return cmd.Help()
35+
}
36+
return credentialShow(cmd.Context(), args[0])
37+
},
38+
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
39+
return mysqlClusterCandidates(cmd.Context(), cmd, args, toComplete)
40+
},
2741
}
2842

2943
// credentialShowCmd shows the credential of a specified user
@@ -119,9 +133,7 @@ func credentialRotate(ctx context.Context, clusterName string) error {
119133
DiscardOldPassword: false,
120134
},
121135
}
122-
if err := controllerutil.SetOwnerReference(cluster, cr, kubeClient.Scheme()); err != nil {
123-
return fmt.Errorf("failed to set ownerReference: %w", err)
124-
}
136+
// ownerReference is set by the controller on first reconcile.
125137
if err := kubeClient.Create(ctx, cr); err != nil {
126138
return fmt.Errorf("failed to create CredentialRotation: %w", err)
127139
}
@@ -138,12 +150,20 @@ func credentialRotate(ctx context.Context, clusterName string) error {
138150
return fmt.Errorf("cannot rotate: rotation is in progress (phase=%s)", phase)
139151
}
140152

141-
cr.Spec.RotationGeneration++
142-
cr.Spec.DiscardOldPassword = false
143-
if err := kubeClient.Update(ctx, cr); err != nil {
144-
return fmt.Errorf("failed to update CredentialRotation: %w", err)
153+
newGen := cr.Spec.RotationGeneration + 1
154+
patch, err := json.Marshal(map[string]interface{}{
155+
"spec": map[string]interface{}{
156+
"rotationGeneration": newGen,
157+
"discardOldPassword": false,
158+
},
159+
})
160+
if err != nil {
161+
return fmt.Errorf("failed to marshal patch: %w", err)
162+
}
163+
if err := kubeClient.Patch(ctx, cr, client.RawPatch(types.MergePatchType, patch)); err != nil {
164+
return fmt.Errorf("failed to patch CredentialRotation: %w", err)
145165
}
146-
fmt.Printf("Updated CredentialRotation %s/%s with rotationGeneration=%d\n", namespace, clusterName, cr.Spec.RotationGeneration)
166+
fmt.Printf("Updated CredentialRotation %s/%s with rotationGeneration=%d\n", namespace, clusterName, newGen)
147167
return nil
148168
}
149169

@@ -160,27 +180,38 @@ func credentialDiscard(ctx context.Context, clusterName string) error {
160180
return fmt.Errorf("cannot discard: phase must be Rotated, currently %q", cr.Status.Phase)
161181
}
162182

163-
cr.Spec.DiscardOldPassword = true
164-
if err := kubeClient.Update(ctx, cr); err != nil {
165-
return fmt.Errorf("failed to update CredentialRotation: %w", err)
183+
patch, err := json.Marshal(map[string]interface{}{
184+
"spec": map[string]interface{}{
185+
"discardOldPassword": true,
186+
},
187+
})
188+
if err != nil {
189+
return fmt.Errorf("failed to marshal patch: %w", err)
190+
}
191+
if err := kubeClient.Patch(ctx, cr, client.RawPatch(types.MergePatchType, patch)); err != nil {
192+
return fmt.Errorf("failed to patch CredentialRotation: %w", err)
166193
}
167194
fmt.Printf("Set discardOldPassword=true on CredentialRotation %s/%s\n", namespace, clusterName)
168195
return nil
169196
}
170197

171198
func init() {
172-
// Show subcommand flags
173-
fs := credentialShowCmd.Flags()
199+
// Flags on the parent command for backward compatibility
200+
// ("kubectl moco credential -u moco-admin CLUSTER_NAME").
201+
fs := credentialCmd.Flags()
174202
fs.StringVarP(&credentialConfig.user, "mysql-user", "u", constants.ReadOnlyUser, "User for login to mysql")
175203
fs.StringVar(&credentialConfig.format, "format", "plain", "The format of output [`plain` or `mycnf`]")
176204

177-
_ = credentialShowCmd.RegisterFlagCompletionFunc("mysql-user", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
205+
_ = credentialCmd.RegisterFlagCompletionFunc("mysql-user", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
178206
return []string{constants.ReadOnlyUser, constants.WritableUser, constants.AdminUser}, cobra.ShellCompDirectiveDefault
179207
})
180-
_ = credentialShowCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
208+
_ = credentialCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
181209
return []string{"plain", "mycnf"}, cobra.ShellCompDirectiveDefault
182210
})
183211

212+
// "show" subcommand shares the same flags via the parent.
213+
credentialShowCmd.Flags().AddFlagSet(credentialCmd.Flags())
214+
184215
credentialCmd.AddCommand(credentialShowCmd)
185216
credentialCmd.AddCommand(credentialRotateCmd)
186217
credentialCmd.AddCommand(credentialDiscardCmd)

controllers/credentialrotation_controller.go

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,6 @@ func (r *CredentialRotationReconciler) handleStartRotation(ctx context.Context,
114114
return ctrl.Result{RequeueAfter: credRotationRequeueInterval}, nil
115115
}
116116

117-
// Generate rotationID
118-
rotationID := uuid.New().String()
119-
120117
// Get the source Secret
121118
sourceSecret := &corev1.Secret{}
122119
secretName := cluster.ControllerSecretName()
@@ -127,12 +124,20 @@ func (r *CredentialRotationReconciler) handleStartRotation(ctx context.Context,
127124
return ctrl.Result{}, fmt.Errorf("failed to get source secret: %w", err)
128125
}
129126

130-
// Generate pending passwords
127+
// Reuse existing rotationID if the Secret already has complete pending
128+
// passwords (crash recovery: Secret was updated but status was not).
129+
rotationID := password.GetRotationID(sourceSecret)
130+
if rotationID == "" {
131+
rotationID = uuid.New().String()
132+
}
133+
134+
// Generate pending passwords (idempotent if rotationID matches)
131135
_, err := password.SetPendingPasswords(sourceSecret, rotationID)
132136
if err != nil {
133137
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "RotationPendingError",
134-
"Failed to set pending passwords: %v", err)
135-
return ctrl.Result{}, fmt.Errorf("failed to set pending passwords: %w", err)
138+
"Failed to set pending passwords: %v. Manual cleanup required: "+
139+
"see docs/designdoc/credential_rotation_crd.md Recovery Procedures", err)
140+
return ctrl.Result{RequeueAfter: credRotationRequeueInterval}, nil
136141
}
137142

138143
// Update the source Secret
@@ -167,11 +172,22 @@ func (r *CredentialRotationReconciler) handleRetainedPhase(ctx context.Context,
167172
return ctrl.Result{}, fmt.Errorf("failed to get source secret: %w", err)
168173
}
169174

175+
// Verify pending passwords belong to this rotation cycle.
176+
if hasPending, err := password.HasPendingPasswords(sourceSecret, cr.Status.RotationID); err != nil {
177+
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "RotationPendingError",
178+
"Pending password state inconsistency: %v. Manual cleanup required: "+
179+
"see docs/designdoc/credential_rotation_crd.md Recovery Procedures", err)
180+
return ctrl.Result{RequeueAfter: credRotationRequeueInterval}, nil
181+
} else if !hasPending {
182+
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "MissingRotationPending",
183+
"Pending passwords not found in source secret for rotationID %s. Manual cleanup required: "+
184+
"see docs/designdoc/credential_rotation_crd.md Recovery Procedures", cr.Status.RotationID)
185+
return ctrl.Result{RequeueAfter: credRotationRequeueInterval}, nil
186+
}
187+
170188
// Distribute pending passwords to per-namespace user Secret
171189
pendingPasswd, err := password.NewMySQLPasswordFromPending(sourceSecret)
172190
if err != nil {
173-
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "MissingRotationPending",
174-
"Pending passwords not found in source secret: %v", err)
175191
return ctrl.Result{}, fmt.Errorf("failed to read pending passwords: %w", err)
176192
}
177193

@@ -282,13 +298,27 @@ func (r *CredentialRotationReconciler) handleDiscardedPhase(ctx context.Context,
282298
return ctrl.Result{}, fmt.Errorf("failed to get source secret for confirm: %w", err)
283299
}
284300

285-
if err := password.ConfirmPendingPasswords(sourceSecret); err != nil {
286-
return ctrl.Result{}, fmt.Errorf("failed to confirm pending passwords: %w", err)
301+
// Confirm pending passwords (promote pending → current, remove pending keys).
302+
// ConfirmPendingPasswords is idempotent: if pending keys are already gone
303+
// (crash recovery after Secret update but before status update), it's a no-op.
304+
hasPending, pendingErr := password.HasPendingPasswords(sourceSecret, cr.Status.RotationID)
305+
if pendingErr != nil {
306+
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "RotationPendingError",
307+
"Pending password state inconsistency during confirm: %v. Manual cleanup required: "+
308+
"see docs/designdoc/credential_rotation_crd.md Recovery Procedures", pendingErr)
309+
return ctrl.Result{RequeueAfter: credRotationRequeueInterval}, nil
287310
}
288311

289-
if err := r.Update(ctx, sourceSecret); err != nil {
290-
return ctrl.Result{}, fmt.Errorf("failed to update source secret after confirm: %w", err)
312+
if hasPending {
313+
if err := password.ConfirmPendingPasswords(sourceSecret); err != nil {
314+
return ctrl.Result{}, fmt.Errorf("failed to confirm pending passwords: %w", err)
315+
}
316+
if err := r.Update(ctx, sourceSecret); err != nil {
317+
return ctrl.Result{}, fmt.Errorf("failed to update source secret after confirm: %w", err)
318+
}
291319
}
320+
// If !hasPending && pendingErr == nil: pending keys already promoted
321+
// (crash recovery). Proceed to set Completed.
292322

293323
// Update status to Completed
294324
cr.Status.Phase = mocov1beta2.RotationPhaseCompleted

controllers/credentialrotation_controller_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ var _ = Describe("CredentialRotation reconciler", func() {
327327
return err
328328
}
329329
sts.Status.ObservedGeneration = sts.Generation
330+
sts.Status.Replicas = *sts.Spec.Replicas
330331
sts.Status.CurrentRevision = "rev-1"
331332
sts.Status.UpdateRevision = "rev-1"
332333
sts.Status.UpdatedReplicas = *sts.Spec.Replicas
@@ -436,6 +437,7 @@ var _ = Describe("CredentialRotation reconciler", func() {
436437
return err
437438
}
438439
sts.Status.ObservedGeneration = sts.Generation
440+
sts.Status.Replicas = *sts.Spec.Replicas
439441
sts.Status.CurrentRevision = "rev-1"
440442
sts.Status.UpdateRevision = "rev-1"
441443
sts.Status.UpdatedReplicas = *sts.Spec.Replicas
@@ -515,14 +517,14 @@ var _ = Describe("CredentialRotation reconciler", func() {
515517
err = k8sClient.Create(ctx, cr)
516518
Expect(err).NotTo(HaveOccurred())
517519

518-
// Phase should NOT advance past Rotating (reconciler emits warning and requeues).
520+
// Phase should remain empty (reconciler refuses and requeues without advancing).
519521
Consistently(func() mocov1beta2.RotationPhase {
520522
cr := &mocov1beta2.CredentialRotation{}
521523
if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "test"}, cr); err != nil {
522524
return ""
523525
}
524526
return cr.Status.Phase
525-
}).Should(BeElementOf(mocov1beta2.RotationPhase(""), mocov1beta2.RotationPhase("")))
527+
}).Should(Equal(mocov1beta2.RotationPhase("")))
526528
})
527529

528530
It("should not advance past Rotated without discardOldPassword", func() {

controllers/mysqlcluster_controller.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ func (r *MySQLClusterReconciler) reconcileV1Secret(ctx context.Context, req ctrl
348348

349349
// During credential rotation, the CredentialRotationReconciler owns
350350
// secret distribution. Skip to prevent overwriting pending passwords.
351+
// On any Get error (NotFound, CRD missing, cache not ready), assume no
352+
// active rotation and proceed with distribution. This is safe because
353+
// if a rotation is truly active, the CredentialRotationReconciler will
354+
// re-distribute the correct passwords.
351355
var cr mocov1beta2.CredentialRotation
352356
if err := r.Get(ctx, client.ObjectKey{
353357
Namespace: cluster.Namespace,

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ require (
3939
sigs.k8s.io/controller-runtime v0.22.0
4040
)
4141

42+
require github.com/google/uuid v1.6.0
43+
4244
require (
4345
cel.dev/expr v0.24.0 // indirect
4446
cloud.google.com/go v0.116.0 // indirect
@@ -97,7 +99,6 @@ require (
9799
github.com/google/gnostic-models v0.7.0 // indirect
98100
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect
99101
github.com/google/s2a-go v0.1.9 // indirect
100-
github.com/google/uuid v1.6.0 // indirect
101102
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
102103
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
103104
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect

0 commit comments

Comments
 (0)