Skip to content

Commit f75f60c

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 f75f60c

9 files changed

Lines changed: 94 additions & 35 deletions

File tree

clustering/password_rotation.go

Lines changed: 2 additions & 1 deletion
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 {

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: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,30 @@ import (
1111
apierrors "k8s.io/apimachinery/pkg/api/errors"
1212
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1313
"k8s.io/apimachinery/pkg/types"
14-
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
1514
)
1615

1716
var credentialConfig struct {
1817
user string
1918
format string
2019
}
2120

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

2940
// credentialShowCmd shows the credential of a specified user
@@ -119,9 +130,7 @@ func credentialRotate(ctx context.Context, clusterName string) error {
119130
DiscardOldPassword: false,
120131
},
121132
}
122-
if err := controllerutil.SetOwnerReference(cluster, cr, kubeClient.Scheme()); err != nil {
123-
return fmt.Errorf("failed to set ownerReference: %w", err)
124-
}
133+
// ownerReference is set by the controller on first reconcile.
125134
if err := kubeClient.Create(ctx, cr); err != nil {
126135
return fmt.Errorf("failed to create CredentialRotation: %w", err)
127136
}
@@ -169,18 +178,22 @@ func credentialDiscard(ctx context.Context, clusterName string) error {
169178
}
170179

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

177-
_ = credentialShowCmd.RegisterFlagCompletionFunc("mysql-user", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
187+
_ = credentialCmd.RegisterFlagCompletionFunc("mysql-user", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
178188
return []string{constants.ReadOnlyUser, constants.WritableUser, constants.AdminUser}, cobra.ShellCompDirectiveDefault
179189
})
180-
_ = credentialShowCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
190+
_ = credentialCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
181191
return []string{"plain", "mycnf"}, cobra.ShellCompDirectiveDefault
182192
})
183193

194+
// "show" subcommand shares the same flags via the parent.
195+
credentialShowCmd.Flags().AddFlagSet(credentialCmd.Flags())
196+
184197
credentialCmd.AddCommand(credentialShowCmd)
185198
credentialCmd.AddCommand(credentialRotateCmd)
186199
credentialCmd.AddCommand(credentialDiscardCmd)

controllers/credentialrotation_controller.go

Lines changed: 24 additions & 8 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

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ 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+
// Fail closed on transient errors to avoid distributing stale passwords.
351352
var cr mocov1beta2.CredentialRotation
352353
if err := r.Get(ctx, client.ObjectKey{
353354
Namespace: cluster.Namespace,
@@ -358,6 +359,8 @@ func (r *MySQLClusterReconciler) reconcileV1Secret(ctx context.Context, req ctrl
358359
log.Info("credential rotation in progress, skipping secret distribution", "phase", phase)
359360
return nil
360361
}
362+
} else if !apierrors.IsNotFound(err) && !meta.IsNoMatchError(err) {
363+
return fmt.Errorf("failed to check CredentialRotation status: %w", err)
361364
}
362365

363366
if err := r.reconcileUserSecret(ctx, req, cluster, secret); err != nil {

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

pkg/dbop/password.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package dbop
22

33
import (
44
"context"
5+
"database/sql"
6+
"database/sql/driver"
57
"fmt"
68
"regexp"
79
"strings"
@@ -36,6 +38,19 @@ func validateMocoUser(user string) error {
3638
return nil
3739
}
3840

41+
// closeDirtyConn restores sql_log_bin=1 on a connection. If the restore fails,
42+
// the connection is marked bad so it is discarded from the pool instead of being
43+
// reused with sql_log_bin=0.
44+
func closeDirtyConn(ctx context.Context, conn *sql.Conn) {
45+
if _, err := conn.ExecContext(ctx, "SET sql_log_bin=1"); err != nil {
46+
// Mark the connection as bad so the pool discards it.
47+
_ = conn.Raw(func(driverConn interface{}) error {
48+
return driver.ErrBadConn
49+
})
50+
}
51+
conn.Close()
52+
}
53+
3954
// GetAuthPlugin returns the default authentication plugin for the first factor
4055
// by parsing @@global.authentication_policy (introduced in MySQL 8.0.27).
4156
//
@@ -106,13 +121,11 @@ func (o *operator) RotateUserPassword(ctx context.Context, user, newPassword str
106121
if err != nil {
107122
return fmt.Errorf("failed to acquire connection for rotate: %w", err)
108123
}
109-
defer conn.Close()
124+
defer closeDirtyConn(ctx, conn)
110125

111126
if _, err := conn.ExecContext(ctx, "SET sql_log_bin=0"); err != nil {
112127
return fmt.Errorf("failed to set sql_log_bin=0: %w", err)
113128
}
114-
// Restore sql_log_bin before the connection is returned to the pool.
115-
defer conn.ExecContext(ctx, "SET sql_log_bin=1") //nolint:errcheck
116129
query := fmt.Sprintf("ALTER USER '%s'@'%%' IDENTIFIED BY ? RETAIN CURRENT PASSWORD", user)
117130
if _, err := conn.ExecContext(ctx, query, newPassword); err != nil {
118131
return fmt.Errorf("failed to rotate password for %s: %w", user, err)
@@ -146,13 +159,11 @@ func (o *operator) MigrateUserAuthPlugin(ctx context.Context, user, password, au
146159
if err != nil {
147160
return fmt.Errorf("failed to acquire connection for auth plugin migration: %w", err)
148161
}
149-
defer conn.Close()
162+
defer closeDirtyConn(ctx, conn)
150163

151164
if _, err := conn.ExecContext(ctx, "SET sql_log_bin=0"); err != nil {
152165
return fmt.Errorf("failed to set sql_log_bin=0: %w", err)
153166
}
154-
// Restore sql_log_bin before the connection is returned to the pool.
155-
defer conn.ExecContext(ctx, "SET sql_log_bin=1") //nolint:errcheck
156167
query := fmt.Sprintf("ALTER USER '%s'@'%%' IDENTIFIED WITH %s BY ?", user, authPlugin)
157168
if _, err := conn.ExecContext(ctx, query, password); err != nil {
158169
return fmt.Errorf("failed to migrate auth plugin for %s: %w", user, err)
@@ -205,13 +216,11 @@ func (o *operator) DiscardOldPassword(ctx context.Context, user string) error {
205216
if err != nil {
206217
return fmt.Errorf("failed to acquire connection for discard: %w", err)
207218
}
208-
defer conn.Close()
219+
defer closeDirtyConn(ctx, conn)
209220

210221
if _, err := conn.ExecContext(ctx, "SET sql_log_bin=0"); err != nil {
211222
return fmt.Errorf("failed to set sql_log_bin=0: %w", err)
212223
}
213-
// Restore sql_log_bin before the connection is returned to the pool.
214-
defer conn.ExecContext(ctx, "SET sql_log_bin=1") //nolint:errcheck
215224
query := fmt.Sprintf("ALTER USER '%s'@'%%' DISCARD OLD PASSWORD", user)
216225
if _, err := conn.ExecContext(ctx, query); err != nil {
217226
return fmt.Errorf("failed to discard old password for %s: %w", user, err)

pkg/password/rotation.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ var userToPendingKey = map[string]string{
6363
constants.WritableUser: WritablePasswordPendingKey,
6464
}
6565

66+
// GetRotationID returns the stored ROTATION_ID from the secret, or "" if absent.
67+
func GetRotationID(secret *corev1.Secret) string {
68+
if secret.Data == nil {
69+
return ""
70+
}
71+
return string(secret.Data[RotationIDKey])
72+
}
73+
6674
// HasPendingPasswords validates the pending state of a source secret.
6775
//
6876
// Returns:
@@ -217,11 +225,11 @@ func ConfirmPendingPasswords(secret *corev1.Secret) error {
217225
// Returns error if pending keys are not all present.
218226
func PendingKeyMap(secret *corev1.Secret) (map[string]string, error) {
219227
if secret.Data == nil {
220-
return nil, fmt.Errorf("secret has no data")
228+
return nil, fmt.Errorf("secret %s/%s has no data", secret.Namespace, secret.Name)
221229
}
222230
for _, key := range allPendingKeys {
223231
if _, ok := secret.Data[key]; !ok {
224-
return nil, fmt.Errorf("pending key %s not found in secret", key)
232+
return nil, fmt.Errorf("pending key %s not found in secret %s/%s", key, secret.Namespace, secret.Name)
225233
}
226234
}
227235

0 commit comments

Comments
 (0)