diff --git a/pkg/controller/perconaservermongodb/custom_users.go b/pkg/controller/perconaservermongodb/custom_users.go index 05f8eb81c4..422bc3d76c 100644 --- a/pkg/controller/perconaservermongodb/custom_users.go +++ b/pkg/controller/perconaservermongodb/custom_users.go @@ -21,6 +21,9 @@ import ( s "github.com/percona/percona-server-mongodb-operator/pkg/psmdb/secret" ) +// maxAnnotationNameLength is the maximum length for Kubernetes annotation key names (63 characters). +const maxAnnotationNameLength = 63 + func (r *ReconcilePerconaServerMongoDB) reconcileCustomUsers(ctx context.Context, cr *api.PerconaServerMongoDB) error { if cr.Spec.Users == nil && len(cr.Spec.Users) == 0 && cr.Spec.Roles == nil && len(cr.Spec.Roles) == 0 { return nil @@ -105,7 +108,7 @@ func handleUsers(ctx context.Context, cr *api.PerconaServerMongoDB, mongoCli mon continue } - annotationKey := fmt.Sprintf("percona.com/%s-%s-hash", cr.Name, user.Name) + annotationKey := buildAnnotationKey(cr.Name, user.Name) if userInfo == nil && !user.IsExternalDB() { err = createUser(ctx, client, mongoCli, &user, sec, annotationKey, userSecretPassKey) @@ -422,6 +425,21 @@ func createUser( return nil } +// buildAnnotationKey creates a Kubernetes annotation key for tracking user password hashes. +// Kubernetes annotation keys have a limit of 63 characters for the name part. +// Format: "percona.com/" where it must be less than or equal to 63 characters. +// We need to keep the "-hash" suffix (5 chars), so we have 58 chars for the prefix. +func buildAnnotationKey(crName, userName string) string { + annotationKeyBase := fmt.Sprintf("percona.com/%s-%s", crName, userName) + const hashSuffix = "-hash" + const maxPrefixLength = maxAnnotationNameLength - len(hashSuffix) + + if len(annotationKeyBase) > maxPrefixLength { + annotationKeyBase = annotationKeyBase[:maxPrefixLength] + } + return fmt.Sprintf("%s%s", annotationKeyBase, hashSuffix) +} + // getCustomUserSecret gets secret by name defined by `user.PasswordSecretRef.Name` or returns a secret // with newly generated password if name matches defaultName func getCustomUserSecret(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, user *api.User, passKey string) (*corev1.Secret, error) { diff --git a/pkg/controller/perconaservermongodb/custom_users_test.go b/pkg/controller/perconaservermongodb/custom_users_test.go index db055f7a42..6672ba8cc9 100644 --- a/pkg/controller/perconaservermongodb/custom_users_test.go +++ b/pkg/controller/perconaservermongodb/custom_users_test.go @@ -2,6 +2,7 @@ package perconaservermongodb import ( "context" + "strings" "testing" "github.com/pkg/errors" @@ -411,3 +412,91 @@ func TestGetCustomUserSecret(t *testing.T) { }) } } + +func TestBuildAnnotationKey(t *testing.T) { + tests := []struct { + name string + crName string + userName string + want string + wantLen int + maxLength int + }{ + { + name: "short names within limit", + crName: "my-cluster", + userName: "user1", + want: "percona.com/my-cluster-user1-hash", + wantLen: 33, + maxLength: maxAnnotationNameLength, + }, + { + name: "exactly at limit", + crName: "a", + userName: strings.Repeat("x", 44), // 1 + 5 + 44 + 13 (percona.com/-) = 63, will not be truncated + want: "percona.com/a-" + strings.Repeat("x", 44) + "-hash", + wantLen: maxAnnotationNameLength, + maxLength: maxAnnotationNameLength, + }, + { + name: "exceeds limit - truncates but keeps hash suffix", + crName: "very-long-cluster-name-that-exceeds", + userName: "very-long-user-name-that-also-exceeds", + want: "percona.com/very-long-cluster-name-that-exceeds-very-long--hash", + wantLen: maxAnnotationNameLength, + maxLength: maxAnnotationNameLength, + }, + { + name: "very long cluster name", + crName: strings.Repeat("a", 100), + userName: "user", + want: "percona.com/" + strings.Repeat("a", 46) + "-hash", + wantLen: maxAnnotationNameLength, + maxLength: maxAnnotationNameLength, + }, + { + name: "very long user name", + crName: "cluster", + userName: strings.Repeat("b", 100), + want: "percona.com/cluster-" + strings.Repeat("b", 38) + "-hash", + wantLen: maxAnnotationNameLength, + maxLength: maxAnnotationNameLength, + }, + { + name: "both names very long", + crName: strings.Repeat("c", 50), + userName: strings.Repeat("d", 50), + want: "percona.com/" + strings.Repeat("c", 46) + "-hash", + wantLen: maxAnnotationNameLength, + maxLength: maxAnnotationNameLength, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildAnnotationKey(tt.crName, tt.userName) + + // Extract the name part (after "percona.com/") + prefix := "percona.com/" + assert.True(t, strings.HasPrefix(got, prefix), "buildAnnotationKey() = %v, should start with %v", got, prefix) + + // Verify the annotation key name part is within Kubernetes limit + namePart := got[len(prefix):] + gotLen := len(got) + assert.True(t, len(namePart) <= tt.maxLength, "buildAnnotationKey() name part length = %v, should be <= %v. Got: %v", len(namePart), tt.maxLength, got) + + // Verify it ends with "-hash" + assert.True(t, strings.HasSuffix(got, "-hash"), "buildAnnotationKey() = %v, should end with '-hash'", got) + + // Verify exact match for non-truncated cases + if gotLen <= tt.maxLength && tt.want != "" { + assert.Equal(t, tt.want, got, "buildAnnotationKey() = %v, want %v", got, tt.want) + } + + // Verify length matches expected for all cases + if tt.wantLen > 0 { + assert.Equal(t, tt.wantLen, gotLen, "buildAnnotationKey() length = %v, want %v", gotLen, tt.wantLen) + } + }) + } +}