Skip to content

Commit 420a50b

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 420a50b

7 files changed

Lines changed: 62 additions & 23 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 {

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: 19 additions & 6 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,7 +124,14 @@ 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",
@@ -167,11 +171,20 @@ func (r *CredentialRotationReconciler) handleRetainedPhase(ctx context.Context,
167171
return ctrl.Result{}, fmt.Errorf("failed to get source secret: %w", err)
168172
}
169173

174+
// Verify pending passwords belong to this rotation cycle.
175+
if hasPending, err := password.HasPendingPasswords(sourceSecret, cr.Status.RotationID); err != nil {
176+
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "RotationPendingError",
177+
"Pending password state inconsistency: %v", err)
178+
return ctrl.Result{}, fmt.Errorf("failed to verify pending passwords: %w", err)
179+
} else if !hasPending {
180+
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "MissingRotationPending",
181+
"Pending passwords not found in source secret for rotationID %s", cr.Status.RotationID)
182+
return ctrl.Result{}, fmt.Errorf("pending passwords not found for rotationID %s", cr.Status.RotationID)
183+
}
184+
170185
// Distribute pending passwords to per-namespace user Secret
171186
pendingPasswd, err := password.NewMySQLPasswordFromPending(sourceSecret)
172187
if err != nil {
173-
r.Recorder.Eventf(cr, corev1.EventTypeWarning, "MissingRotationPending",
174-
"Pending passwords not found in source secret: %v", err)
175188
return ctrl.Result{}, fmt.Errorf("failed to read pending passwords: %w", err)
176189
}
177190

controllers/credentialrotation_controller_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -515,14 +515,14 @@ var _ = Describe("CredentialRotation reconciler", func() {
515515
err = k8sClient.Create(ctx, cr)
516516
Expect(err).NotTo(HaveOccurred())
517517

518-
// Phase should NOT advance past Rotating (reconciler emits warning and requeues).
518+
// Phase should remain empty (reconciler refuses and requeues without advancing).
519519
Consistently(func() mocov1beta2.RotationPhase {
520520
cr := &mocov1beta2.CredentialRotation{}
521521
if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "test"}, cr); err != nil {
522522
return ""
523523
}
524524
return cr.Status.Phase
525-
}).Should(BeElementOf(mocov1beta2.RotationPhase(""), mocov1beta2.RotationPhase("")))
525+
}).Should(Equal(mocov1beta2.RotationPhase("")))
526526
})
527527

528528
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/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)