From 74da1ce1702b15e9835c8918f66afab0fc198998 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Thu, 11 Jun 2026 12:04:49 +0300 Subject: [PATCH 01/11] K8SPSMDB-1537: connection string secrets https://perconadev.atlassian.net/browse/K8SPSMDB-1537 --- e2e-tests/custom-users-roles-sharded/run | 87 +++- pkg/apis/psmdb/v1/psmdb_types.go | 32 ++ .../perconaservermongodb/custom_users.go | 28 +- .../perconaservermongodb/secrets.go | 111 ++++-- .../perconaservermongodb/secrets_test.go | 247 ++++++++++++ .../perconaservermongodb/service.go | 17 +- pkg/controller/perconaservermongodb/status.go | 8 +- pkg/controller/perconaservermongodb/users.go | 25 +- pkg/naming/secret.go | 15 + pkg/naming/service.go | 14 + pkg/psmdb/client.go | 56 ++- pkg/psmdb/client_test.go | 374 ++++++++++++++++++ pkg/psmdb/mongo/mongo.go | 108 +++-- pkg/psmdb/service.go | 18 +- 14 files changed, 1042 insertions(+), 98 deletions(-) create mode 100644 pkg/controller/perconaservermongodb/secrets_test.go create mode 100644 pkg/naming/secret.go create mode 100644 pkg/psmdb/client_test.go diff --git a/e2e-tests/custom-users-roles-sharded/run b/e2e-tests/custom-users-roles-sharded/run index c0e1c63841..b292388d02 100755 --- a/e2e-tests/custom-users-roles-sharded/run +++ b/e2e-tests/custom-users-roles-sharded/run @@ -31,6 +31,70 @@ check_auth() { fi } +check_connection_string() { + local secret_name="$1" + local data_key="$2" + local connection_string + local client_container + local ping + local tls_args=() + + if ! connection_string=$(kubectl_bin get secret "$secret_name" -o "go-template={{index .data \"$data_key\"}}" | base64 -d); then + return 1 + fi + if [ -z "$connection_string" ]; then + return 1 + fi + if [[ "$connection_string" == *"tls=true"* || "$connection_string" == *"ssl=true"* || "$connection_string" == mongodb+srv://* ]]; then + tls_args+=( + --tls + --tlsCAFile /etc/mongodb-ssl/ca.crt + --tlsCertificateKeyFile /tmp/tls.pem + --tlsAllowInvalidHostnames + ) + fi + + client_container=$(kubectl_bin get pods --selector=name=psmdb-client -o 'jsonpath={.items[].metadata.name}') + ping=$(kubectl_bin exec "$client_container" -- \ + mongo "$connection_string" --quiet "${tls_args[@]}" \ + --eval 'db.runCommand({ ping: 1 }).ok' \ + | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:') + + if [ "$ping" != "1" ]; then + return 1 + fi +} + +check_connection_strings() { + local secret_name="$1" + shift + local key_prefix + local data_keys + local data_key + + if ! data_keys=$(kubectl_bin get secret "$secret_name" \ + -o 'go-template={{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}'); then + return 1 + fi + if [ -z "$data_keys" ]; then + return 1 + fi + + for key_prefix in "$@"; do + if ! grep -Fxq "${key_prefix}_connectionString" <<<"$data_keys"; then + return 1 + fi + if ! grep -Fxq "${key_prefix}_connectionStringSrv" <<<"$data_keys"; then + return 1 + fi + done + + while IFS= read -r data_key; do + [ -z "$data_key" ] && continue + check_connection_string "$secret_name" "$data_key" || return 1 + done <<<"$data_keys" +} + get_user_cmd() { local user="$1" @@ -78,9 +142,8 @@ create_infra "$namespace" mongosUri="userAdmin:userAdmin123456@$cluster-mongos.$namespace" -desc 'create secrets and start client' -kubectl_bin apply -f "${conf_dir}/client.yml" \ - -f "${conf_dir}/secrets.yml" \ +desc 'create secrets' +kubectl_bin apply -f "${conf_dir}/secrets.yml" \ -f "${test_dir}/conf/app-user-secrets.yml" @@ -103,6 +166,15 @@ wait_for_running $cluster-cfg 3 "false" wait_for_running $cluster-mongos 3 wait_cluster_consistency "${cluster}" +desc 'start client' +kubectl_bin apply -f "${conf_dir}/client_with_tls.yml" +kubectl_bin rollout status deployment/psmdb-client --timeout=360s + +desc 'check database admin connection strings' +retry 60 2 check_connection_strings "$cluster-databaseadmin-conn-str" \ + "databaseAdmin_rs0" \ + "databaseAdmin_cfg" + desc 'check if service and statefulset created with expected config' compare_kubectl statefulset/$cluster-rs0 compare_kubectl statefulset/$cluster-cfg @@ -114,11 +186,13 @@ userOne="user-one" userOnePass=$(getSecretData "user-one" "userOnePassKey") compare 'admin' "$(get_user_cmd \"user-one\")" "$mongosUri" "user-one" check_auth "$userOne:$userOnePass@$cluster-mongos.$namespace" +retry 60 2 check_connection_strings "$cluster-user-one-conn-str" generatedUserSecret="$cluster-custom-user-secret" generatedPass=$(kubectl_bin get secret $generatedUserSecret -o jsonpath="{.data.user-gen}" | base64 -d) compare 'admin' "$(get_user_cmd \"user-gen\")" "$mongosUri" "user-gen" check_auth "user-gen:$generatedPass@$cluster-mongos.$namespace" +retry 60 2 check_connection_strings "$cluster-user-gen-conn-str" # Only check if $external.user-external user exists, as the password is not known # since we don't have a external provider set in this test @@ -151,6 +225,7 @@ userTwoPass=$(getSecretData "user-two" "userTwoPassKey") # Both users should be in the DB, the operator should not delete the user removed from the CR check_auth "$userTwo:$userTwoPass@$cluster-mongos.$namespace" check_auth "$userOne:$userOnePass@$cluster-mongos.$namespace" +retry 60 2 check_connection_strings "$cluster-user-two-conn-str" desc 'check password change' userTwoNewPass="new-user-two-password" @@ -158,6 +233,7 @@ patch_secret "user-two" "userTwoPassKey" "$(echo -n "$userTwoNewPass" | base64)" sleep 20 check_auth "$userTwo:$userTwoNewPass@$cluster-mongos.$namespace" +retry 60 2 check_connection_strings "$cluster-user-two-conn-str" desc 'check user roles update from CR' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ @@ -218,6 +294,7 @@ compare 'admin' "$(get_user_cmd \"user-two\")" "$mongosUri" "user-two-update-rol # user-three and user-two should be in the DB check_auth "$userTwo:$userTwoNewPass@$cluster-mongos.$namespace" check_auth "user-three:$userTwoNewPass@$cluster-mongos.$namespace" +retry 60 2 check_connection_strings "$cluster-user-three-conn-str" desc 'check new user created after updated user db via CR' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ @@ -239,6 +316,7 @@ wait_for_running $cluster-rs0 3 compare 'newDb' "$(get_user_cmd \"user-three\")" "$mongosUri" "user-three-newDb-db" compare 'admin' "$(get_user_cmd \"user-three\")" "$mongosUri" "user-three-admin-db" +retry 60 2 check_connection_strings "$cluster-user-three-conn-str" desc 'check new user created with default db and secret password key' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ @@ -257,6 +335,7 @@ kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ wait_for_running $cluster-rs0 3 compare 'admin' "$(get_user_cmd \"user-four\")" "$mongosUri" "user-four" +retry 60 2 check_connection_strings "$cluster-user-four-conn-str" # ======================== Roles ======================== @@ -476,6 +555,8 @@ compare 'testAdmin1' "$(get_role_cmd \"role-four\" )" "$mongosUri" "role-four" compare 'testAdmin2' "$(get_role_cmd \"role-five\" )" "$mongosUri" "role-five" compare 'testAdmin' "$(get_user_cmd \"user-five\")" "$mongosUri" "user-five" compare 'testAdmin' "$(get_user_cmd \"user-six\")" "$mongosUri" "user-six" +retry 60 2 check_connection_strings "$cluster-user-five-conn-str" +retry 60 2 check_connection_strings "$cluster-user-six-conn-str" destroy $namespace diff --git a/pkg/apis/psmdb/v1/psmdb_types.go b/pkg/apis/psmdb/v1/psmdb_types.go index ffb3de9746..ddbbfa2b3b 100644 --- a/pkg/apis/psmdb/v1/psmdb_types.go +++ b/pkg/apis/psmdb/v1/psmdb_types.go @@ -1597,6 +1597,38 @@ const ( RoleBackup SystemUserRole = "backup" ) +func (role SystemUserRole) EnvKeyUsername() string { + switch role { + case RoleDatabaseAdmin: + return EnvMongoDBDatabaseAdminUser + case RoleClusterAdmin: + return EnvMongoDBClusterAdminUser + case RoleUserAdmin: + return EnvMongoDBUserAdminUser + case RoleClusterMonitor: + return EnvMongoDBClusterMonitorUser + case RoleBackup: + return EnvMongoDBBackupUser + } + return "" +} + +func (role SystemUserRole) EnvKeyPassword() string { + switch role { + case RoleDatabaseAdmin: + return EnvMongoDBDatabaseAdminPassword + case RoleClusterAdmin: + return EnvMongoDBClusterAdminPassword + case RoleUserAdmin: + return EnvMongoDBUserAdminPassword + case RoleClusterMonitor: + return EnvMongoDBClusterMonitorPassword + case RoleBackup: + return EnvMongoDBBackupPassword + } + return "" +} + func InternalUserSecretName(cr *PerconaServerMongoDB) string { return internalPrefix + cr.Name + userPostfix } diff --git a/pkg/controller/perconaservermongodb/custom_users.go b/pkg/controller/perconaservermongodb/custom_users.go index ba0d61b21a..d003836849 100644 --- a/pkg/controller/perconaservermongodb/custom_users.go +++ b/pkg/controller/perconaservermongodb/custom_users.go @@ -18,6 +18,8 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" + "github.com/percona/percona-server-mongodb-operator/pkg/naming" + "github.com/percona/percona-server-mongodb-operator/pkg/psmdb" "github.com/percona/percona-server-mongodb-operator/pkg/psmdb/mongo" s "github.com/percona/percona-server-mongodb-operator/pkg/psmdb/secret" ) @@ -109,6 +111,26 @@ func handleUsers(ctx context.Context, cr *api.PerconaServerMongoDB, mongoCli mon continue } + if !user.IsExternalDB() { + cred := psmdb.Credentials{ + Username: user.Name, + Password: string(sec.Data[userSecretPassKey]), + AuthSource: user.DB, + } + if err := ensureConnectionStringSecret( + ctx, + client, + cr, + naming.SecretCustomUserConnStrName(cr, &user), + user.Name, + cred, + sec, + !cr.Spec.Sharding.Enabled, + ); err != nil { + return errors.Wrapf(err, "ensure user conn string secret %s", user.Name) + } + } + annotationKey := buildAnnotationKey(cr, user.Name) if userInfo == nil && !user.IsExternalDB() { @@ -314,7 +336,8 @@ func updatePass( user *api.User, userInfo *mongo.User, secret *corev1.Secret, - annotationKey, passKey string) error { + annotationKey, passKey string, +) error { log := logf.FromContext(ctx) if userInfo == nil || user.IsExternalDB() { @@ -405,7 +428,8 @@ func createUser( mongoCli mongo.Client, user *api.User, secret *corev1.Secret, - annotationKey, passKey string) error { + annotationKey, passKey string, +) error { log := logf.FromContext(ctx) roles := make([]mongo.Role, 0) diff --git a/pkg/controller/perconaservermongodb/secrets.go b/pkg/controller/perconaservermongodb/secrets.go index 4f1c6f0c35..c7464f701d 100644 --- a/pkg/controller/perconaservermongodb/secrets.go +++ b/pkg/controller/perconaservermongodb/secrets.go @@ -3,6 +3,7 @@ package perconaservermongodb import ( "context" "fmt" + "strings" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" @@ -10,6 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" logf "sigs.k8s.io/controller-runtime/pkg/log" api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" @@ -26,35 +28,21 @@ func getUserSecret(ctx context.Context, cl client.Reader, cr *api.PerconaServerM } func getInternalCredentials(ctx context.Context, cl client.Reader, cr *api.PerconaServerMongoDB, role api.SystemUserRole) (psmdb.Credentials, error) { - return getCredentials(ctx, cl, cr, api.UserSecretName(cr), role) + usersSecret, err := getUserSecret(ctx, cl, cr, api.UserSecretName(cr)) + if err != nil { + return psmdb.Credentials{}, errors.Wrap(err, "failed to get user secret") + } + return getCredentials(&usersSecret, role) } -func getCredentials(ctx context.Context, cl client.Reader, cr *api.PerconaServerMongoDB, name string, role api.SystemUserRole) (psmdb.Credentials, error) { +func getCredentials(secret *corev1.Secret, role api.SystemUserRole) (psmdb.Credentials, error) { creds := psmdb.Credentials{} - usersSecret, err := getUserSecret(ctx, cl, cr, name) - if err != nil { - return creds, errors.Wrap(err, "failed to get user secret") - } - - switch role { - case api.RoleDatabaseAdmin: - creds.Username = string(usersSecret.Data[api.EnvMongoDBDatabaseAdminUser]) - creds.Password = string(usersSecret.Data[api.EnvMongoDBDatabaseAdminPassword]) - case api.RoleClusterAdmin: - creds.Username = string(usersSecret.Data[api.EnvMongoDBClusterAdminUser]) - creds.Password = string(usersSecret.Data[api.EnvMongoDBClusterAdminPassword]) - case api.RoleUserAdmin: - creds.Username = string(usersSecret.Data[api.EnvMongoDBUserAdminUser]) - creds.Password = string(usersSecret.Data[api.EnvMongoDBUserAdminPassword]) - case api.RoleClusterMonitor: - creds.Username = string(usersSecret.Data[api.EnvMongoDBClusterMonitorUser]) - creds.Password = string(usersSecret.Data[api.EnvMongoDBClusterMonitorPassword]) - case api.RoleBackup: - creds.Username = string(usersSecret.Data[api.EnvMongoDBBackupUser]) - creds.Password = string(usersSecret.Data[api.EnvMongoDBBackupPassword]) - default: - return creds, errors.Errorf("not implemented for role: %s", role) + envKeyUser, envKeyPass := role.EnvKeyUsername(), role.EnvKeyPassword() + if envKeyUser == "" || envKeyPass == "" { + return creds, errors.Errorf("invalid role %s", string(role)) } + creds.Username = string(secret.Data[envKeyUser]) + creds.Password = string(secret.Data[envKeyPass]) if creds.Username == "" || creds.Password == "" { return creds, errors.Errorf("can't find credentials for role %s", role) @@ -63,9 +51,80 @@ func getCredentials(ctx context.Context, cl client.Reader, cr *api.PerconaServer return creds, nil } +func ensureConnectionStringSecret( + ctx context.Context, + cl client.Client, + cr *api.PerconaServerMongoDB, + secretName, keyPrefix string, + cred psmdb.Credentials, + owner metav1.Object, + includeReplsets bool, +) error { + connStrSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: cr.Namespace, + }, + } + + _, err := controllerutil.CreateOrUpdate(ctx, cl, connStrSecret, func() error { + connStrSecret.Data = make(map[string][]byte) + if includeReplsets { + for _, rs := range cr.GetAllReplsets() { + cfg, err := psmdb.MongoConfig(ctx, cl, cr, rs, cred, false) + if err != nil { + return errors.Wrap(err, "mongo config") + } + + connStr := cfg.URI() + key := keyPrefix + "_" + rs.Name + connStrSecret.Data[key+"_connectionString"] = []byte(connStr) + connStrSecret.Data[key+"_connectionStringSrv"] = []byte(cfg.SRVURI(strings.Join([]string{ + naming.ServiceName(cr, rs), + cr.Namespace, + cr.Spec.ClusterServiceDNSSuffix, + }, "."))) + + if rs.Expose.Enabled { + cfg, err := psmdb.MongoConfig(ctx, cl, cr, rs, cred, true) + if err != nil { + return errors.Wrap(err, "mongo config") + } + if exposedConnStr := cfg.URI(); exposedConnStr != connStr { + connStrSecret.Data[key+"_connectionStringExposed"] = []byte(exposedConnStr) + } + } + } + } + + if cr.Spec.Sharding.Enabled { + servicePerPod := cr.Spec.Sharding.Mongos.Expose.ServicePerPod + mongosCfg, err := psmdb.MongosConfig(ctx, cl, cr, cred, true, servicePerPod) + if err != nil { + return errors.Wrap(err, "mongos config") + } + connStrSecret.Data[keyPrefix+"_mongos_connectionString"] = []byte(mongosCfg.URI()) + + if servicePerPod { + mongosCfg, err := psmdb.MongosConfig(ctx, cl, cr, cred, false, true) + if err != nil { + return errors.Wrap(err, "mongos config") + } + connStrSecret.Data[keyPrefix+"_mongos_connectionStringExposed"] = []byte(mongosCfg.URI()) + } + } + if err := controllerutil.SetOwnerReference(owner, connStrSecret, cl.Scheme()); err != nil { + return errors.Wrap(err, "set owner reference") + } + return nil + }) + return errors.Wrap(err, "create or update") +} + func (r *ReconcilePerconaServerMongoDB) reconcileUsersSecret(ctx context.Context, cr *api.PerconaServerMongoDB) error { secretObj := corev1.Secret{} - err := r.client.Get(ctx, + err := r.client.Get( + ctx, types.NamespacedName{ Namespace: cr.Namespace, Name: cr.Spec.Secrets.Users, diff --git a/pkg/controller/perconaservermongodb/secrets_test.go b/pkg/controller/perconaservermongodb/secrets_test.go new file mode 100644 index 0000000000..51b24536c9 --- /dev/null +++ b/pkg/controller/perconaservermongodb/secrets_test.go @@ -0,0 +1,247 @@ +package perconaservermongodb + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" + "github.com/percona/percona-server-mongodb-operator/pkg/naming" + "github.com/percona/percona-server-mongodb-operator/pkg/psmdb" +) + +func TestEnsureConnectionStringSecret(t *testing.T) { + tests := map[string]struct { + setup func(*api.PerconaServerMongoDB) []client.Object + includeReplsets bool + expected map[string][]byte + }{ + "replset": { + includeReplsets: true, + setup: func(cr *api.PerconaServerMongoDB) []client.Object { + rs := cr.Spec.Replsets[0] + return []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-user-conn-str", + Namespace: cr.Namespace, + }, + Data: map[string][]byte{"stale": []byte("value")}, + }, + fakeStatefulset(cr, rs, rs.Size, "", "mongod"), + fakePodsForRS(cr, rs)[0], + } + }, + expected: map[string][]byte{ + "app-user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), + "app-user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), + }, + }, + "exposed replset": { + includeReplsets: true, + setup: func(cr *api.PerconaServerMongoDB) []client.Object { + cr.Spec.ClusterServiceDNSMode = api.DNSModeExternal + rs := cr.Spec.Replsets[0] + rs.Expose.Enabled = true + pod := fakePodsForRS(cr, rs)[0] + return []client.Object{ + fakeStatefulset(cr, rs, rs.Size, "", "mongod"), + pod, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: pod.GetName(), + Namespace: cr.Namespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + ClusterIP: "10.0.0.10", + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: 27017}, + }, + }, + }, + } + }, + expected: map[string][]byte{ + "app-user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), + "app-user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), + "app-user_rs0_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.10:27017/?authSource=application&replicaSet=rs0"), + }, + }, + "mongos without replsets": { + setup: func(cr *api.PerconaServerMongoDB) []client.Object { + cr.Spec.Sharding = api.Sharding{ + Enabled: true, + Mongos: &api.MongosSpec{}, + } + return []client.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.MongosServiceName(cr), + Namespace: cr.Namespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "mongos", Port: 27017}, + }, + }, + }, + } + }, + expected: map[string][]byte{ + "app-user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-mongos.database.svc.cluster.local:27017/?authSource=application"), + }, + }, + "exposed mongos": { + setup: func(cr *api.PerconaServerMongoDB) []client.Object { + cr.Spec.Sharding = api.Sharding{ + Enabled: true, + Mongos: &api.MongosSpec{ + Size: 1, + Expose: api.MongosExpose{ + ServicePerPod: true, + Expose: api.Expose{ + ExposeType: corev1.ServiceTypeLoadBalancer, + }, + }, + }, + } + pod := fakePodsForMongos(cr)[0] + return []client.Object{ + pod, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: pod.GetName(), + Namespace: cr.Namespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + ClusterIP: "10.0.0.20", + Ports: []corev1.ServicePort{ + {Name: "mongos", Port: 27017}, + }, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {Hostname: "mongos.example.com"}, + }, + }, + }, + }, + } + }, + expected: map[string][]byte{ + "app-user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.20/?authSource=application"), + "app-user_mongos_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@mongos.example.com/?authSource=application"), + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + cr := connectionStringTestCluster() + owner := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-user-password", + Namespace: cr.Namespace, + UID: types.UID("owner-uid"), + }, + } + objects := append([]client.Object{owner}, tt.setup(cr)...) + r := buildFakeClient(objects...) + + err := ensureConnectionStringSecret( + t.Context(), + r.client, + cr, + "app-user-conn-str", + "app-user", + psmdb.Credentials{ + Username: "app-user", + Password: "p@ss/word", + AuthSource: "application", + }, + owner, + tt.includeReplsets, + ) + require.NoError(t, err) + + actual := new(corev1.Secret) + require.NoError(t, r.client.Get(t.Context(), types.NamespacedName{ + Name: "app-user-conn-str", + Namespace: cr.Namespace, + }, actual)) + assert.Equal(t, tt.expected, actual.Data) + require.Len(t, actual.OwnerReferences, 1) + assert.Equal(t, owner.UID, actual.OwnerReferences[0].UID) + assert.Equal(t, "Secret", actual.OwnerReferences[0].Kind) + }) + } +} + +func TestReconcileUsersCreatesConnectionStringSecretWhenCredentialsUnchanged(t *testing.T) { + cr := connectionStringTestCluster() + cr.Status.State = api.AppStateReady + cr.Spec.Secrets = &api.SecretsSpec{Users: "cluster-users"} + + users := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: cr.Spec.Secrets.Users, + Namespace: cr.Namespace, + UID: types.UID("users-secret-uid"), + }, + Data: map[string][]byte{ + api.EnvMongoDBDatabaseAdminUser: []byte("databaseAdmin"), + api.EnvMongoDBDatabaseAdminPassword: []byte("password"), + }, + } + internal := users.DeepCopy() + internal.Name = api.InternalUserSecretName(cr) + internal.UID = types.UID("internal-secret-uid") + internal.Data = getInternalSecretData(cr, users) + + rs := cr.Spec.Replsets[0] + r := buildFakeClient( + users, + internal, + fakeStatefulset(cr, rs, rs.Size, "", "mongod"), + fakePodsForRS(cr, rs)[0], + ) + + require.NoError(t, r.reconcileUsers(t.Context(), cr, cr.Spec.Replsets)) + + actual := new(corev1.Secret) + key := types.NamespacedName{ + Name: naming.SecretDatabaseAdminConnStrName(cr), + Namespace: cr.Namespace, + } + require.NoError(t, r.client.Get(t.Context(), key, actual)) + assert.Contains(t, actual.Data, "databaseAdmin_rs0_connectionString") +} + +func connectionStringTestCluster() *api.PerconaServerMongoDB { + return &api.PerconaServerMongoDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + Namespace: "database", + }, + Spec: api.PerconaServerMongoDBSpec{ + CRVersion: "1.20.0", + ClusterServiceDNSMode: api.DNSModeInternal, + ClusterServiceDNSSuffix: "svc.cluster.local", + TLS: &api.TLSSpec{Mode: api.TLSModeDisabled}, + Replsets: []*api.ReplsetSpec{ + { + Name: "rs0", + Size: 1, + }, + }, + }, + } +} diff --git a/pkg/controller/perconaservermongodb/service.go b/pkg/controller/perconaservermongodb/service.go index 26883f5688..b92f95c323 100644 --- a/pkg/controller/perconaservermongodb/service.go +++ b/pkg/controller/perconaservermongodb/service.go @@ -62,13 +62,13 @@ func (r *ReconcilePerconaServerMongoDB) reconcileMongosSvc(ctx context.Context, if cr.Spec.Sharding.Mongos.Expose.ServicePerPod { for i := 0; i < int(cr.Spec.Sharding.Mongos.Size); i++ { - err := r.createOrUpdateMongosSvc(ctx, cr, cr.Name+"-mongos-"+strconv.Itoa(i)) + err := r.createOrUpdateMongosSvc(ctx, cr, naming.MongosPerPodServiceName(cr, i)) if err != nil { return errors.Wrap(err, "create or update mongos service") } } } else { - err := r.createOrUpdateMongosSvc(ctx, cr, cr.Name+"-mongos") + err := r.createOrUpdateMongosSvc(ctx, cr, naming.MongosServiceName(cr)) if err != nil { return errors.Wrap(err, "create or update mongos service") } @@ -128,7 +128,8 @@ func (r *ReconcilePerconaServerMongoDB) exportServices(ctx context.Context, cr * ls := naming.ClusterLabels(cr) seList := mcs.ServiceExportList() - err := r.client.List(ctx, + err := r.client.List( + ctx, seList, &client.ListOptions{ Namespace: cr.Namespace, @@ -149,7 +150,8 @@ func (r *ReconcilePerconaServerMongoDB) exportServices(ctx context.Context, cr * } svcList := &corev1.ServiceList{} - err = r.client.List(ctx, + err = r.client.List( + ctx, svcList, &client.ListOptions{ Namespace: cr.Namespace, @@ -207,7 +209,8 @@ func (r *ReconcilePerconaServerMongoDB) removeOutdatedServices(ctx context.Conte // clear old services svcList := &corev1.ServiceList{} - err := r.client.List(ctx, + err := r.client.List( + ctx, svcList, &client.ListOptions{ Namespace: cr.Namespace, @@ -237,10 +240,10 @@ func (r *ReconcilePerconaServerMongoDB) removeOutdatedMongosSvc(ctx context.Cont svcNames := make(map[string]struct{}, cr.Spec.Sharding.Mongos.Size) if cr.Spec.Sharding.Mongos.Expose.ServicePerPod { for i := 0; i < int(cr.Spec.Sharding.Mongos.Size); i++ { - svcNames[cr.Name+"-mongos-"+strconv.Itoa(i)] = struct{}{} + svcNames[naming.MongosPerPodServiceName(cr, i)] = struct{}{} } } else { - svcNames[cr.Name+"-mongos"] = struct{}{} + svcNames[naming.MongosServiceName(cr)] = struct{}{} } svcList, err := psmdb.GetMongosServices(ctx, r.client, cr) diff --git a/pkg/controller/perconaservermongodb/status.go b/pkg/controller/perconaservermongodb/status.go index ae06c62e0e..84c06baf06 100644 --- a/pkg/controller/perconaservermongodb/status.go +++ b/pkg/controller/perconaservermongodb/status.go @@ -231,7 +231,8 @@ func (r *ReconcilePerconaServerMongoDB) updateStatus(ctx context.Context, cr *ap } if state != api.AppStateReady { - log.V(1).Info("Cluster is not ready", + log.V(1).Info( + "Cluster is not ready", "pbmStatus", pbmStatus, "upgradeInProgress", inProgress, "replsetsReady", replsetsReady, @@ -563,7 +564,7 @@ func (r *ReconcilePerconaServerMongoDB) pbmStatus(ctx context.Context, cr *api.P func (r *ReconcilePerconaServerMongoDB) connectionEndpoint(ctx context.Context, cr *api.PerconaServerMongoDB) (string, error) { if cr.Spec.Sharding.Enabled { - addrs, err := psmdb.GetMongosAddrs(ctx, r.client, cr, false) + addrs, err := psmdb.GetMongosAddrs(ctx, r.client, cr, false, cr.Spec.Sharding.Mongos.Expose.ServicePerPod) if err != nil { return "", errors.Wrap(err, "get mongos addresses") } @@ -573,7 +574,8 @@ func (r *ReconcilePerconaServerMongoDB) connectionEndpoint(ctx context.Context, if rs := cr.Spec.Replsets[0]; rs.Expose.Enabled && (rs.Expose.ExposeType == corev1.ServiceTypeLoadBalancer || rs.Expose.ExposeType == corev1.ServiceTypeClusterIP) { list := corev1.PodList{} - err := r.client.List(ctx, + err := r.client.List( + ctx, &list, &client.ListOptions{ Namespace: cr.Namespace, diff --git a/pkg/controller/perconaservermongodb/users.go b/pkg/controller/perconaservermongodb/users.go index 4b0722a9e1..203327d044 100644 --- a/pkg/controller/perconaservermongodb/users.go +++ b/pkg/controller/perconaservermongodb/users.go @@ -36,7 +36,8 @@ func getInternalSecretData(cr *api.PerconaServerMongoDB, secret *corev1.Secret) func (r *ReconcilePerconaServerMongoDB) reconcileUsers(ctx context.Context, cr *api.PerconaServerMongoDB, repls []*api.ReplsetSpec) error { sysUsersSecretObj := corev1.Secret{} - err := r.client.Get(ctx, + err := r.client.Get( + ctx, types.NamespacedName{ Namespace: cr.Namespace, Name: cr.Spec.Secrets.Users, @@ -52,7 +53,8 @@ func (r *ReconcilePerconaServerMongoDB) reconcileUsers(ctx context.Context, cr * secretName := api.InternalUserSecretName(cr) internalSysSecretObj := corev1.Secret{} - err = r.client.Get(ctx, + err = r.client.Get( + ctx, types.NamespacedName{ Namespace: cr.Namespace, Name: secretName, @@ -88,6 +90,23 @@ func (r *ReconcilePerconaServerMongoDB) reconcileUsers(ctx context.Context, cr * return nil } + cred, err := getCredentials(&sysUsersSecretObj, api.RoleDatabaseAdmin) + if err != nil { + return errors.Wrap(err, "get database admin credentials") + } + if err := ensureConnectionStringSecret( + ctx, + r.client, + cr, + naming.SecretDatabaseAdminConnStrName(cr), + string(api.RoleDatabaseAdmin), + cred, + &sysUsersSecretObj, + true, + ); err != nil { + return errors.Wrap(err, "ensure connection string secret") + } + dataChanged, err := sysUsersSecretDataChanged(cr, &sysUsersSecretObj, &internalSysSecretObj) if err != nil { return errors.Wrap(err, "check sys users data changes") @@ -354,7 +373,7 @@ func (u *systemUser) updateMongo(ctx context.Context, c mongo.Client) error { return errors.Wrapf(err, "update user %s -> %s", u.currName, u.name) } -func sysUsersSecretDataChanged(cr *api.PerconaServerMongoDB, usersSecret *corev1.Secret, internalSecret *corev1.Secret) (bool, error) { +func sysUsersSecretDataChanged(cr *api.PerconaServerMongoDB, usersSecret, internalSecret *corev1.Secret) (bool, error) { newData := getInternalSecretData(cr, usersSecret) newDataJSON, err := json.Marshal(newData) if err != nil { diff --git a/pkg/naming/secret.go b/pkg/naming/secret.go new file mode 100644 index 0000000000..715569b808 --- /dev/null +++ b/pkg/naming/secret.go @@ -0,0 +1,15 @@ +package naming + +import ( + "strings" + + api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" +) + +func SecretDatabaseAdminConnStrName(cr *api.PerconaServerMongoDB) string { + return strings.ToLower(cr.Name + "-" + string(api.RoleDatabaseAdmin) + "-conn-str") +} + +func SecretCustomUserConnStrName(cr *api.PerconaServerMongoDB, user *api.User) string { + return strings.ToLower(cr.Name + "-" + user.Name + "-conn-str") +} diff --git a/pkg/naming/service.go b/pkg/naming/service.go index 4e1af3b113..a8a7739599 100644 --- a/pkg/naming/service.go +++ b/pkg/naming/service.go @@ -1,6 +1,8 @@ package naming import ( + "strconv" + "k8s.io/utils/ptr" api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" @@ -18,3 +20,15 @@ func AppProtocol(cr *api.PerconaServerMongoDB) *string { } return nil } + +func ServiceName(cr *api.PerconaServerMongoDB, rs *api.ReplsetSpec) string { + return cr.Name + "-" + rs.Name +} + +func MongosServiceName(cr *api.PerconaServerMongoDB) string { + return cr.Name + "-mongos" +} + +func MongosPerPodServiceName(cr *api.PerconaServerMongoDB, idx int) string { + return cr.Name + "-mongos-" + strconv.Itoa(idx) +} diff --git a/pkg/psmdb/client.go b/pkg/psmdb/client.go index d22221d295..442e1df35f 100644 --- a/pkg/psmdb/client.go +++ b/pkg/psmdb/client.go @@ -12,12 +12,22 @@ import ( ) type Credentials struct { - Username string - Password string + Username string + Password string + AuthSource string } func MongoClient(ctx context.Context, k8sClient client.Client, cr *api.PerconaServerMongoDB, rs *api.ReplsetSpec, c Credentials) (mongo.Client, error) { - pods, err := GetRSPods(ctx, k8sClient, cr, rs.Name) + conf, err := MongoConfig(ctx, k8sClient, cr, rs, c, false) + if err != nil { + return nil, errors.Wrap(err, "mongo config") + } + + return mongo.Dial(ctx, conf) +} + +func MongoConfig(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, rs *api.ReplsetSpec, c Credentials, rsExposed bool) (*mongo.Config, error) { + pods, err := GetRSPods(ctx, cl, cr, rs.Name) if err != nil { return nil, errors.Wrapf(err, "get pods list for replset %s", rs.Name) } @@ -26,13 +36,13 @@ func MongoClient(ctx context.Context, k8sClient client.Client, cr *api.PerconaSe // If `rs.Size` is 0 or replicaset doesn't exist in the cr the list of pods will be empty. // If there is empty pod list we should use `GetOutdatedRSPods` which returns list of pods without truncating it. if len(pods.Items) == 0 { - pods, err = GetOutdatedRSPods(ctx, k8sClient, cr, rs.Name) + pods, err = GetOutdatedRSPods(ctx, cl, cr, rs.Name) if err != nil { return nil, errors.Wrapf(err, "get outdated pods list for replset %s", rs.Name) } } - rsAddrs, err := GetReplsetAddrs(ctx, k8sClient, cr, cr.Spec.ClusterServiceDNSMode, rs, false, pods.Items) + rsAddrs, err := GetReplsetAddrs(ctx, cl, cr, cr.Spec.ClusterServiceDNSMode, rs, rsExposed, pods.Items) if err != nil { return nil, errors.Wrap(err, "get replset addr") } @@ -48,10 +58,11 @@ func MongoClient(ctx context.Context, k8sClient client.Client, cr *api.PerconaSe Hosts: rsAddrs, Username: c.Username, Password: c.Password, + AuthSource: c.AuthSource, } if cr.TLSEnabled() { - tlsCfg, err := tls.Config(ctx, k8sClient, cr) + tlsCfg, err := tls.Config(ctx, cl, cr) if err != nil { return nil, errors.Wrap(err, "failed to get TLS config") } @@ -59,38 +70,47 @@ func MongoClient(ctx context.Context, k8sClient client.Client, cr *api.PerconaSe conf.TLSConf = &tlsCfg } - return mongo.Dial(ctx, conf) + return conf, nil } func MongosClient(ctx context.Context, k8sclient client.Client, cr *api.PerconaServerMongoDB, c Credentials) (mongo.Client, error) { - hosts, err := GetMongosAddrs(ctx, k8sclient, cr, true) + conf, err := MongosConfig(ctx, k8sclient, cr, c, true, cr.Spec.Sharding.Mongos.Expose.ServicePerPod) + if err != nil { + return nil, errors.Wrap(err, "get mongos config") + } + return mongo.Dial(ctx, conf) +} + +func MongosConfig(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, c Credentials, useInternalAddr, servicePerPod bool) (*mongo.Config, error) { + hosts, err := GetMongosAddrs(ctx, cl, cr, useInternalAddr, servicePerPod) if err != nil { return nil, errors.Wrap(err, "get mongos addrs") } conf := mongo.Config{ - Hosts: hosts, - Username: c.Username, - Password: c.Password, + Hosts: hosts, + Username: c.Username, + Password: c.Password, + AuthSource: c.AuthSource, } if cr.TLSEnabled() { - tlsCfg, err := tls.Config(ctx, k8sclient, cr) + tlsCfg, err := tls.Config(ctx, cl, cr) if err != nil { return nil, errors.Wrap(err, "failed to get TLS config") } conf.TLSConf = &tlsCfg } - - return mongo.Dial(ctx, &conf) + return &conf, nil } func StandaloneClient(ctx context.Context, k8sclient client.Client, cr *api.PerconaServerMongoDB, c Credentials, host string, tlsEnabled bool) (mongo.Client, error) { conf := mongo.Config{ - Hosts: []string{host}, - Username: c.Username, - Password: c.Password, - Direct: true, + Hosts: []string{host}, + Username: c.Username, + Password: c.Password, + AuthSource: c.AuthSource, + Direct: true, } if tlsEnabled { diff --git a/pkg/psmdb/client_test.go b/pkg/psmdb/client_test.go new file mode 100644 index 0000000000..ded8b3a9d7 --- /dev/null +++ b/pkg/psmdb/client_test.go @@ -0,0 +1,374 @@ +package psmdb + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + mcsv1alpha1 "sigs.k8s.io/mcs-api/pkg/apis/v1alpha1" + + api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" + "github.com/percona/percona-server-mongodb-operator/pkg/naming" +) + +func TestMongoConfigURIFromReplsetAddrs(t *testing.T) { + tests := map[string]struct { + dnsMode api.DNSMode + rsExposed bool + mcsEnabled bool + serviceImported bool + replsetSize int32 + expectedURI string + expectedSRVURI string + }{ + "service mesh": { + dnsMode: api.DNSModeServiceMesh, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "internal": { + dnsMode: api.DNSModeInternal, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "internal multiple hosts": { + dnsMode: api.DNSModeInternal, + replsetSize: 3, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017,cluster-rs0-1.cluster-rs0.database.svc.cluster.local:27017,cluster-rs0-2.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "internal MCS service not imported": { + dnsMode: api.DNSModeInternal, + rsExposed: true, + mcsEnabled: true, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "internal MCS service imported": { + dnsMode: api.DNSModeInternal, + rsExposed: true, + mcsEnabled: true, + serviceImported: true, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.database.svc.clusterset.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "external not exposed": { + dnsMode: api.DNSModeExternal, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "external exposed": { + dnsMode: api.DNSModeExternal, + rsExposed: true, + expectedURI: "mongodb://app-user:p%40ss%2Fword@10.0.0.10:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "external MCS service not imported": { + dnsMode: api.DNSModeExternal, + rsExposed: true, + mcsEnabled: true, + expectedURI: "mongodb://app-user:p%40ss%2Fword@10.0.0.10:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "external MCS service imported": { + dnsMode: api.DNSModeExternal, + rsExposed: true, + mcsEnabled: true, + serviceImported: true, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.database.svc.clusterset.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + "default": { + dnsMode: api.DNSMode("unsupported"), + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0", + expectedSRVURI: "mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + cr := clientTestCluster() + cr.Spec.ClusterServiceDNSMode = tt.dnsMode + cr.Spec.MultiCluster = api.MultiCluster{ + Enabled: tt.mcsEnabled, + DNSSuffix: "svc.clusterset.local", + } + rs := cr.Spec.Replsets[0] + if tt.replsetSize > 0 { + rs.Size = tt.replsetSize + } + + stsLabels := naming.RSLabels(cr, rs) + stsLabels[naming.LabelKubernetesComponent] = naming.ComponentMongod + objects := []client.Object{ + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-rs0", + Namespace: cr.Namespace, + Labels: stsLabels, + }, + }, + } + for i := range rs.Size { + pod := clientTestPod(cr, fmt.Sprintf("cluster-rs0-%d", i), naming.RSLabels(cr, rs)) + pod.Labels[naming.LabelKubernetesComponent] = naming.ComponentMongod + objects = append(objects, pod, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: pod.Name, + Namespace: cr.Namespace, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + ClusterIP: fmt.Sprintf("10.0.0.%d", 10+i), + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: 27017}, + }, + }, + }) + if tt.serviceImported { + objects = append(objects, &mcsv1alpha1.ServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: pod.Name, + Namespace: cr.Namespace, + }, + }) + } + } + + cl := fake.NewClientBuilder(). + WithScheme(clientTestScheme(t)). + WithObjects(objects...). + Build() + + cfg, err := MongoConfig( + t.Context(), + cl, + cr, + rs, + clientTestCredentials(), + tt.rsExposed, + ) + require.NoError(t, err) + assert.Equal(t, tt.expectedURI, cfg.URI()) + assert.Equal(t, tt.expectedSRVURI, cfg.SRVURI("cluster-rs0.database.svc.cluster.local")) + }) + } +} + +func TestMongoConfigReturnsServiceImportLookupError(t *testing.T) { + for _, dnsMode := range []api.DNSMode{api.DNSModeInternal, api.DNSModeExternal} { + t.Run(string(dnsMode), func(t *testing.T) { + cr := clientTestCluster() + cr.Spec.ClusterServiceDNSMode = dnsMode + cr.Spec.MultiCluster.Enabled = true + rs := cr.Spec.Replsets[0] + pod := clientTestPod(cr, "cluster-rs0-0", naming.RSLabels(cr, rs)) + pod.Labels[naming.LabelKubernetesComponent] = naming.ComponentMongod + + stsLabels := naming.RSLabels(cr, rs) + stsLabels[naming.LabelKubernetesComponent] = naming.ComponentMongod + cl := fake.NewClientBuilder(). + WithScheme(clientTestScheme(t)). + WithObjects( + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-rs0", + Namespace: cr.Namespace, + Labels: stsLabels, + }, + }, + pod, + ). + Build() + + _, err := MongoConfig( + t.Context(), + serviceImportErrorClient{Client: cl}, + cr, + rs, + clientTestCredentials(), + true, + ) + require.EqualError(t, err, + "get replset addr: failed to get external hostname for pod cluster-rs0-0: "+ + "check if service imported for cluster-rs0-0: service import lookup failed", + ) + }) + } +} + +func TestMongosConfigURIFromMongosAddrs(t *testing.T) { + tests := map[string]struct { + servicePerPod bool + exposeType corev1.ServiceType + useInternalAddr bool + objects func(*api.PerconaServerMongoDB) []client.Object + expectedURI string + }{ + "shared service": { + useInternalAddr: true, + objects: func(cr *api.PerconaServerMongoDB) []client.Object { + return []client.Object{clientTestMongosService(cr, naming.MongosServiceName(cr))} + }, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-mongos.database.svc.cluster.local:27017/?authSource=application", + }, + "service per pod": { + servicePerPod: true, + useInternalAddr: true, + objects: func(cr *api.PerconaServerMongoDB) []client.Object { + labels := naming.MongosLabels(cr) + return []client.Object{ + clientTestPod(cr, "cluster-mongos-0", labels), + clientTestPod(cr, "cluster-mongos-1", labels), + clientTestMongosService(cr, "cluster-mongos-0"), + clientTestMongosService(cr, "cluster-mongos-1"), + } + }, + expectedURI: "mongodb://app-user:p%40ss%2Fword@cluster-mongos-0.database.svc.cluster.local:27017,cluster-mongos-1.database.svc.cluster.local:27017/?authSource=application", + }, + "load balancer internal address": { + exposeType: corev1.ServiceTypeLoadBalancer, + useInternalAddr: true, + objects: func(cr *api.PerconaServerMongoDB) []client.Object { + svc := clientTestMongosService(cr, naming.MongosServiceName(cr)) + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + svc.Spec.ClusterIP = "10.0.0.20" + return []client.Object{svc} + }, + expectedURI: "mongodb://app-user:p%40ss%2Fword@10.0.0.20/?authSource=application", + }, + "load balancer external address": { + exposeType: corev1.ServiceTypeLoadBalancer, + objects: func(cr *api.PerconaServerMongoDB) []client.Object { + svc := clientTestMongosService(cr, naming.MongosServiceName(cr)) + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{ + {Hostname: "mongos.example.com"}, + } + return []client.Object{svc} + }, + expectedURI: "mongodb://app-user:p%40ss%2Fword@mongos.example.com/?authSource=application", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + cr := clientTestCluster() + cr.Spec.Sharding = api.Sharding{ + Enabled: true, + Mongos: &api.MongosSpec{ + Expose: api.MongosExpose{ + ServicePerPod: tt.servicePerPod, + Expose: api.Expose{ + ExposeType: tt.exposeType, + }, + }, + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithObjects(tt.objects(cr)...). + Build() + + cfg, err := MongosConfig( + t.Context(), + cl, + cr, + clientTestCredentials(), + tt.useInternalAddr, + tt.servicePerPod, + ) + require.NoError(t, err) + assert.Equal(t, tt.expectedURI, cfg.URI()) + }) + } +} + +func clientTestCluster() *api.PerconaServerMongoDB { + return &api.PerconaServerMongoDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + Namespace: "database", + }, + Spec: api.PerconaServerMongoDBSpec{ + CRVersion: "1.20.0", + ClusterServiceDNSMode: api.DNSModeInternal, + ClusterServiceDNSSuffix: "svc.cluster.local", + TLS: &api.TLSSpec{Mode: api.TLSModeDisabled}, + Replsets: []*api.ReplsetSpec{ + { + Name: "rs0", + Size: 1, + }, + }, + }, + } +} + +func clientTestCredentials() Credentials { + return Credentials{ + Username: "app-user", + Password: "p@ss/word", + AuthSource: "application", + } +} + +func clientTestPod(cr *api.PerconaServerMongoDB, name string, labels map[string]string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: cr.Namespace, + Labels: labels, + }, + } +} + +func clientTestMongosService(cr *api.PerconaServerMongoDB, name string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: cr.Namespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "mongos", Port: 27017}, + }, + }, + } +} + +func clientTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + s := runtime.NewScheme() + require.NoError(t, scheme.AddToScheme(s)) + require.NoError(t, mcsv1alpha1.Install(s)) + return s +} + +type serviceImportErrorClient struct { + client.Client +} + +func (c serviceImportErrorClient) Get( + ctx context.Context, + key types.NamespacedName, + obj client.Object, + opts ...client.GetOption, +) error { + if _, ok := obj.(*mcsv1alpha1.ServiceImport); ok { + return errors.New("service import lookup failed") + } + return c.Client.Get(ctx, key, obj, opts...) +} diff --git a/pkg/psmdb/mongo/mongo.go b/pkg/psmdb/mongo/mongo.go index 0525934551..73265af761 100644 --- a/pkg/psmdb/mongo/mongo.go +++ b/pkg/psmdb/mongo/mongo.go @@ -4,7 +4,9 @@ import ( "context" "crypto/tls" "fmt" + "net/url" "reflect" + "strings" "time" "github.com/pkg/errors" @@ -13,6 +15,7 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/readpref" "go.mongodb.org/mongo-driver/mongo/writeconcern" + "go.mongodb.org/mongo-driver/x/mongo/driver/connstring" logf "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -23,11 +26,85 @@ type Config struct { ReplSetName string Username string Password string + AuthSource string TLSConf *tls.Config Direct bool Timeout time.Duration } +func (conf *Config) URI() string { + return conf.uri(connstring.SchemeMongoDB, "") +} + +func (conf *Config) SRVURI(hostname string) string { + return conf.uri(connstring.SchemeMongoDBSRV, hostname) +} + +func (conf *Config) uri(scheme, hostname string) string { + u := url.URL{ + Scheme: scheme, + Host: strings.Join(conf.Hosts, ","), + } + if hostname != "" { + u.Host = hostname + } + + if conf.Username != "" || conf.Password != "" { + u.User = url.UserPassword(conf.Username, conf.Password) + } + + q := url.Values{} + if conf.ReplSetName != "" { + q.Set("replicaSet", conf.ReplSetName) + } + if conf.AuthSource != "" { + q.Set("authSource", conf.AuthSource) + } + if conf.TLSConf != nil { + q.Set("tls", "true") + } + if conf.Direct { + q.Set("directConnection", "true") + } + u.RawQuery = q.Encode() + if u.RawQuery != "" { + u.Path = "/" + } + + return u.String() +} + +func (conf *Config) Options() *options.ClientOptions { + timeout := 10 * time.Second + if conf.Timeout != 0 { + timeout = conf.Timeout + } + + journal := true + wc := writeconcern.Majority() + wc.Journal = &journal + opts := options.Client(). + SetHosts(conf.Hosts). + SetWriteConcern(wc). + SetReadPreference(readpref.Primary()). + SetTLSConfig(conf.TLSConf). + SetDirect(conf.Direct). + SetConnectTimeout(timeout). + SetServerSelectionTimeout(timeout) + + if conf.ReplSetName != "" { + opts.SetReplicaSet(conf.ReplSetName) + } + if conf.Username != "" || conf.Password != "" { + opts.SetAuth(options.Credential{ + Password: conf.Password, + Username: conf.Username, + AuthSource: conf.AuthSource, + }) + } + return opts +} + type Client interface { Disconnect(ctx context.Context) error Database(name string, opts ...*options.DatabaseOptions) ClientDatabase @@ -77,34 +154,9 @@ func ToInterface(client *mongo.Client) Client { } func Dial(ctx context.Context, conf *Config) (Client, error) { - timeout := 10 * time.Second - if conf.Timeout != 0 { - timeout = conf.Timeout - } - - journal := true - wc := writeconcern.Majority() - wc.Journal = &journal - opts := options.Client(). - SetHosts(conf.Hosts). - SetWriteConcern(wc). - SetReadPreference(readpref.Primary()). - SetTLSConfig(conf.TLSConf). - SetDirect(conf.Direct). - SetConnectTimeout(timeout). - SetServerSelectionTimeout(timeout) - - if conf.ReplSetName != "" { - opts.SetReplicaSet(conf.ReplSetName) - } - if conf.Username != "" || conf.Password != "" { - opts.SetAuth(options.Credential{ - Password: conf.Password, - Username: conf.Username, - }) - } + opts := conf.Options() - tCtx, cancel := context.WithTimeout(ctx, timeout) + tCtx, cancel := context.WithTimeout(ctx, *opts.ConnectTimeout) defer cancel() client, err := mongo.Connect(tCtx, opts) if err != nil { @@ -119,7 +171,7 @@ func Dial(ctx context.Context, conf *Config) (Client, error) { } }() - tCtx, cancel = context.WithTimeout(ctx, timeout) + tCtx, cancel = context.WithTimeout(ctx, *opts.ConnectTimeout) defer cancel() err = client.Ping(tCtx, readpref.Primary()) if err != nil { diff --git a/pkg/psmdb/service.go b/pkg/psmdb/service.go index b4a9b2dacf..f7acbc6e3b 100644 --- a/pkg/psmdb/service.go +++ b/pkg/psmdb/service.go @@ -3,6 +3,7 @@ package psmdb import ( "context" "fmt" + "maps" "strconv" "strings" "time" @@ -31,7 +32,7 @@ func Service(cr *api.PerconaServerMongoDB, replset *api.ReplsetSpec) *corev1.Ser Kind: "Service", }, ObjectMeta: metav1.ObjectMeta{ - Name: cr.Name + "-" + replset.Name, + Name: naming.ServiceName(cr, replset), Namespace: cr.Namespace, Annotations: replset.Expose.ServiceAnnotations, }, @@ -50,9 +51,7 @@ func Service(cr *api.PerconaServerMongoDB, replset *api.ReplsetSpec) *corev1.Ser } svc.Labels = make(map[string]string) - for k, v := range ls { - svc.Labels[k] = v - } + maps.Copy(svc.Labels, ls) for k, v := range replset.Expose.ServiceLabels { if _, ok := svc.Labels[k]; !ok { svc.Labels[k] = v @@ -246,8 +245,8 @@ func GetReplsetAddrs(ctx context.Context, cl client.Client, cr *api.PerconaServe } // GetMongosAddrs returns a slice of mongos addresses -func GetMongosAddrs(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, useInternalAddr bool) ([]string, error) { - if !cr.Spec.Sharding.Mongos.Expose.ServicePerPod { +func GetMongosAddrs(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, useInternalAddr bool, servicePerPod bool) ([]string, error) { + if !servicePerPod { host, err := MongosHost(ctx, cl, cr, nil, useInternalAddr) if err != nil { return nil, errors.Wrap(err, "failed to get mongos host") @@ -327,8 +326,11 @@ func MongoHost(ctx context.Context, cl client.Client, cr *api.PerconaServerMongo // MongosHost returns the mongos host for given pod func MongosHost(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, pod *corev1.Pod, useInternalAddr bool) (string, error) { - svcName := cr.Name + "-mongos" + svcName := naming.MongosServiceName(cr) if cr.Spec.Sharding.Mongos.Expose.ServicePerPod { + if pod == nil { + return "", errors.New("mongos pod is required for service-per-pod exposure") + } svcName = pod.Name } @@ -414,7 +416,7 @@ var ErrServiceNotExists = errors.New("service doesn't exist") func getExtServices(ctx context.Context, cl client.Client, namespace, podName string) (*corev1.Service, error) { svcMeta := &corev1.Service{} - for retries := 0; retries < 6; retries++ { + for range 6 { err := cl.Get(ctx, types.NamespacedName{Name: podName, Namespace: namespace}, svcMeta) if err != nil { if k8serrors.IsNotFound(err) { From 99d768aa1921885ae6f2979788aa27b946dd8be6 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Thu, 11 Jun 2026 12:18:07 +0300 Subject: [PATCH 02/11] fix unit-test --- pkg/controller/perconaservermongodb/connections_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/controller/perconaservermongodb/connections_test.go b/pkg/controller/perconaservermongodb/connections_test.go index eddb4e1349..d63911075d 100644 --- a/pkg/controller/perconaservermongodb/connections_test.go +++ b/pkg/controller/perconaservermongodb/connections_test.go @@ -152,6 +152,10 @@ func TestConnectionLeaks(t *testing.T) { Name: cr.Spec.Secrets.Users, Namespace: cr.Namespace, }, + Data: map[string][]byte{ + api.EnvMongoDBDatabaseAdminUser: []byte("databaseAdmin"), + api.EnvMongoDBDatabaseAdminPassword: []byte("databaseAdminPassword"), + }, }) } From b297e91dff8491f4573b7a03deab5adf39fece36 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Fri, 12 Jun 2026 11:51:51 +0300 Subject: [PATCH 03/11] address copilot comments --- pkg/controller/perconaservermongodb/secrets.go | 1 + pkg/psmdb/mongo/mongo.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/controller/perconaservermongodb/secrets.go b/pkg/controller/perconaservermongodb/secrets.go index c7464f701d..811e328f58 100644 --- a/pkg/controller/perconaservermongodb/secrets.go +++ b/pkg/controller/perconaservermongodb/secrets.go @@ -69,6 +69,7 @@ func ensureConnectionStringSecret( _, err := controllerutil.CreateOrUpdate(ctx, cl, connStrSecret, func() error { connStrSecret.Data = make(map[string][]byte) + connStrSecret.Labels = naming.ClusterLabels(cr) if includeReplsets { for _, rs := range cr.GetAllReplsets() { cfg, err := psmdb.MongoConfig(ctx, cl, cr, rs, cred, false) diff --git a/pkg/psmdb/mongo/mongo.go b/pkg/psmdb/mongo/mongo.go index 73265af761..dd6366a1c7 100644 --- a/pkg/psmdb/mongo/mongo.go +++ b/pkg/psmdb/mongo/mongo.go @@ -60,7 +60,7 @@ func (conf *Config) uri(scheme, hostname string) string { if conf.AuthSource != "" { q.Set("authSource", conf.AuthSource) } - if conf.TLSConf != nil { + if scheme != connstring.SchemeMongoDBSRV && conf.TLSConf != nil { q.Set("tls", "true") } if conf.Direct { From 57aee13a859556ff6d03cb4038d0d969e1d7aa82 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Fri, 12 Jun 2026 13:38:16 +0300 Subject: [PATCH 04/11] change secret name --- e2e-tests/custom-users-roles-sharded/run | 17 ++++++++--------- pkg/apis/psmdb/v1/psmdb_types.go | 12 ++++++++++++ .../perconaservermongodb/custom_users.go | 13 ++++--------- .../perconaservermongodb/custom_users_test.go | 5 +++++ pkg/naming/secret.go | 2 +- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/e2e-tests/custom-users-roles-sharded/run b/e2e-tests/custom-users-roles-sharded/run index b292388d02..29546227ab 100755 --- a/e2e-tests/custom-users-roles-sharded/run +++ b/e2e-tests/custom-users-roles-sharded/run @@ -186,13 +186,13 @@ userOne="user-one" userOnePass=$(getSecretData "user-one" "userOnePassKey") compare 'admin' "$(get_user_cmd \"user-one\")" "$mongosUri" "user-one" check_auth "$userOne:$userOnePass@$cluster-mongos.$namespace" -retry 60 2 check_connection_strings "$cluster-user-one-conn-str" +retry 60 2 check_connection_strings "user-one-conn-str" generatedUserSecret="$cluster-custom-user-secret" generatedPass=$(kubectl_bin get secret $generatedUserSecret -o jsonpath="{.data.user-gen}" | base64 -d) compare 'admin' "$(get_user_cmd \"user-gen\")" "$mongosUri" "user-gen" check_auth "user-gen:$generatedPass@$cluster-mongos.$namespace" -retry 60 2 check_connection_strings "$cluster-user-gen-conn-str" +retry 60 2 check_connection_strings "$generatedUserSecret-conn-str" # Only check if $external.user-external user exists, as the password is not known # since we don't have a external provider set in this test @@ -225,7 +225,7 @@ userTwoPass=$(getSecretData "user-two" "userTwoPassKey") # Both users should be in the DB, the operator should not delete the user removed from the CR check_auth "$userTwo:$userTwoPass@$cluster-mongos.$namespace" check_auth "$userOne:$userOnePass@$cluster-mongos.$namespace" -retry 60 2 check_connection_strings "$cluster-user-two-conn-str" +retry 60 2 check_connection_strings "user-two-conn-str" desc 'check password change' userTwoNewPass="new-user-two-password" @@ -233,7 +233,7 @@ patch_secret "user-two" "userTwoPassKey" "$(echo -n "$userTwoNewPass" | base64)" sleep 20 check_auth "$userTwo:$userTwoNewPass@$cluster-mongos.$namespace" -retry 60 2 check_connection_strings "$cluster-user-two-conn-str" +retry 60 2 check_connection_strings "user-two-conn-str" desc 'check user roles update from CR' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ @@ -294,7 +294,7 @@ compare 'admin' "$(get_user_cmd \"user-two\")" "$mongosUri" "user-two-update-rol # user-three and user-two should be in the DB check_auth "$userTwo:$userTwoNewPass@$cluster-mongos.$namespace" check_auth "user-three:$userTwoNewPass@$cluster-mongos.$namespace" -retry 60 2 check_connection_strings "$cluster-user-three-conn-str" +retry 60 2 check_connection_strings "user-two-conn-str" desc 'check new user created after updated user db via CR' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ @@ -316,7 +316,7 @@ wait_for_running $cluster-rs0 3 compare 'newDb' "$(get_user_cmd \"user-three\")" "$mongosUri" "user-three-newDb-db" compare 'admin' "$(get_user_cmd \"user-three\")" "$mongosUri" "user-three-admin-db" -retry 60 2 check_connection_strings "$cluster-user-three-conn-str" +retry 60 2 check_connection_strings "user-two-conn-str" desc 'check new user created with default db and secret password key' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ @@ -335,7 +335,7 @@ kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ wait_for_running $cluster-rs0 3 compare 'admin' "$(get_user_cmd \"user-four\")" "$mongosUri" "user-four" -retry 60 2 check_connection_strings "$cluster-user-four-conn-str" +retry 60 2 check_connection_strings "user-two-conn-str" # ======================== Roles ======================== @@ -555,8 +555,7 @@ compare 'testAdmin1' "$(get_role_cmd \"role-four\" )" "$mongosUri" "role-four" compare 'testAdmin2' "$(get_role_cmd \"role-five\" )" "$mongosUri" "role-five" compare 'testAdmin' "$(get_user_cmd \"user-five\")" "$mongosUri" "user-five" compare 'testAdmin' "$(get_user_cmd \"user-six\")" "$mongosUri" "user-six" -retry 60 2 check_connection_strings "$cluster-user-five-conn-str" -retry 60 2 check_connection_strings "$cluster-user-six-conn-str" +retry 60 2 check_connection_strings "user-one-conn-str" destroy $namespace diff --git a/pkg/apis/psmdb/v1/psmdb_types.go b/pkg/apis/psmdb/v1/psmdb_types.go index c06755fdb0..5ea818e307 100644 --- a/pkg/apis/psmdb/v1/psmdb_types.go +++ b/pkg/apis/psmdb/v1/psmdb_types.go @@ -132,6 +132,18 @@ func (u *User) IsExternalDB() bool { return u.DB == "$external" } +func (u *User) DefaultSecretName(cr *PerconaServerMongoDB) string { + return fmt.Sprintf("%s-custom-user-secret", cr.Name) +} + +func (u *User) SecretName(cr *PerconaServerMongoDB) string { + if u.PasswordSecretRef != nil { + return u.PasswordSecretRef.Name + } + + return u.DefaultSecretName(cr) +} + type RoleAuthenticationRestriction struct { ClientSource []string `json:"clientSource,omitempty"` ServerAddress []string `json:"serverAddress,omitempty"` diff --git a/pkg/controller/perconaservermongodb/custom_users.go b/pkg/controller/perconaservermongodb/custom_users.go index d003836849..c203fad1ea 100644 --- a/pkg/controller/perconaservermongodb/custom_users.go +++ b/pkg/controller/perconaservermongodb/custom_users.go @@ -481,21 +481,16 @@ func getCustomUserSecret(ctx context.Context, cl client.Client, cr *api.PerconaS return nil, nil } - defaultSecretName := fmt.Sprintf("%s-custom-user-secret", cr.Name) - - secretName := defaultSecretName - if user.PasswordSecretRef != nil { - secretName = user.PasswordSecretRef.Name - } + secretName := user.SecretName(cr) secret := &corev1.Secret{} err := cl.Get(ctx, types.NamespacedName{Name: secretName, Namespace: cr.Namespace}, secret) - if err != nil && secretName != defaultSecretName { + if err != nil && secretName != user.DefaultSecretName(cr) { return nil, errors.Wrap(err, "failed to get user secret") } - if err != nil && !k8serrors.IsNotFound(err) && secretName == defaultSecretName { + if err != nil && !k8serrors.IsNotFound(err) && secretName == user.DefaultSecretName(cr) { return nil, errors.Wrap(err, "failed to get user secret") } @@ -526,7 +521,7 @@ func getCustomUserSecret(ctx context.Context, cl client.Client, cr *api.PerconaS } _, hasPass := secret.Data[passKey] - if !hasPass && secretName == defaultSecretName { + if !hasPass && secretName == user.DefaultSecretName(cr) { pass, err := s.GeneratePassword() if err != nil { return nil, errors.Wrap(err, "generate custom user password") diff --git a/pkg/controller/perconaservermongodb/custom_users_test.go b/pkg/controller/perconaservermongodb/custom_users_test.go index eeb0764b38..1d2ffe7f37 100644 --- a/pkg/controller/perconaservermongodb/custom_users_test.go +++ b/pkg/controller/perconaservermongodb/custom_users_test.go @@ -14,6 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" + "github.com/percona/percona-server-mongodb-operator/pkg/naming" "github.com/percona/percona-server-mongodb-operator/pkg/psmdb/mongo" "github.com/percona/percona-server-mongodb-operator/pkg/version" ) @@ -495,12 +496,16 @@ func TestGetCustomUserSecret(t *testing.T) { if tt.hasExistingSecret && tt.errMsg == "" { assert.NoError(t, err) assert.Equal(t, secret.Name, "custom-secret") + assert.Equal(t, tt.user.SecretName(cr), secret.Name) + assert.Equal(t, naming.SecretCustomUserConnStrName(cr, tt.user), secret.Name+"-conn-str") assert.Equal(t, string(secret.Data[passKey]), "existing-password") return } if !tt.hasExistingSecret && tt.errMsg == "" { assert.NoError(t, err) assert.Equal(t, secret.Name, tt.crName+"-custom-user-secret") + assert.Equal(t, tt.user.SecretName(cr), secret.Name) + assert.Equal(t, naming.SecretCustomUserConnStrName(cr, tt.user), secret.Name+"-conn-str") assert.NotEmpty(t, string(secret.Data[passKey])) } if tt.errMsg != "" { diff --git a/pkg/naming/secret.go b/pkg/naming/secret.go index 715569b808..0ac0134882 100644 --- a/pkg/naming/secret.go +++ b/pkg/naming/secret.go @@ -11,5 +11,5 @@ func SecretDatabaseAdminConnStrName(cr *api.PerconaServerMongoDB) string { } func SecretCustomUserConnStrName(cr *api.PerconaServerMongoDB, user *api.User) string { - return strings.ToLower(cr.Name + "-" + user.Name + "-conn-str") + return user.SecretName(cr) + "-conn-str" } From 55fbfe42f29d10eb11f6bb7f4b42ece9d870fe29 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Fri, 12 Jun 2026 14:30:49 +0300 Subject: [PATCH 05/11] handle special chars in secret keys --- .../compare/user-four.json | 4 ++-- e2e-tests/custom-users-roles-sharded/run | 4 ++-- .../perconaservermongodb/secrets.go | 10 +++++++++ .../perconaservermongodb/secrets_test.go | 22 +++++++++++-------- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/e2e-tests/custom-users-roles-sharded/compare/user-four.json b/e2e-tests/custom-users-roles-sharded/compare/user-four.json index 4c274f8641..474d7745f1 100644 --- a/e2e-tests/custom-users-roles-sharded/compare/user-four.json +++ b/e2e-tests/custom-users-roles-sharded/compare/user-four.json @@ -1,7 +1,7 @@ switched to db admin { - "_id" : "admin.user-four", - "user" : "user-four", + "_id" : "admin.user/four", + "user" : "user/four", "db" : "admin", "roles" : [ { diff --git a/e2e-tests/custom-users-roles-sharded/run b/e2e-tests/custom-users-roles-sharded/run index 29546227ab..3d180e041e 100755 --- a/e2e-tests/custom-users-roles-sharded/run +++ b/e2e-tests/custom-users-roles-sharded/run @@ -322,7 +322,7 @@ desc 'check new user created with default db and secret password key' kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ "spec": {"users":[ { - "name":"user-four", + "name":"user/four", "passwordSecretRef": { "name": "user-two" }, @@ -334,7 +334,7 @@ kubectl_bin patch psmdb ${cluster} --type=merge --patch '{ }' wait_for_running $cluster-rs0 3 -compare 'admin' "$(get_user_cmd \"user-four\")" "$mongosUri" "user-four" +compare 'admin' "$(get_user_cmd \"user/four\")" "$mongosUri" "user-four" retry 60 2 check_connection_strings "user-two-conn-str" # ======================== Roles ======================== diff --git a/pkg/controller/perconaservermongodb/secrets.go b/pkg/controller/perconaservermongodb/secrets.go index 811e328f58..fe73d72dd8 100644 --- a/pkg/controller/perconaservermongodb/secrets.go +++ b/pkg/controller/perconaservermongodb/secrets.go @@ -60,6 +60,16 @@ func ensureConnectionStringSecret( owner metav1.Object, includeReplsets bool, ) error { + keyPrefix = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || + r >= 'A' && r <= 'Z' || + r >= '0' && r <= '9' || + r == '.' || r == '_' || r == '-' { + return r + } + return '_' + }, keyPrefix) + connStrSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, diff --git a/pkg/controller/perconaservermongodb/secrets_test.go b/pkg/controller/perconaservermongodb/secrets_test.go index 51b24536c9..c7a8ea9ba0 100644 --- a/pkg/controller/perconaservermongodb/secrets_test.go +++ b/pkg/controller/perconaservermongodb/secrets_test.go @@ -8,6 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/client" api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" @@ -38,8 +39,8 @@ func TestEnsureConnectionStringSecret(t *testing.T) { } }, expected: map[string][]byte{ - "app-user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), - "app-user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), + "app_user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), + "app_user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), }, }, "exposed replset": { @@ -68,9 +69,9 @@ func TestEnsureConnectionStringSecret(t *testing.T) { } }, expected: map[string][]byte{ - "app-user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), - "app-user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), - "app-user_rs0_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.10:27017/?authSource=application&replicaSet=rs0"), + "app_user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), + "app_user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), + "app_user_rs0_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.10:27017/?authSource=application&replicaSet=rs0"), }, }, "mongos without replsets": { @@ -94,7 +95,7 @@ func TestEnsureConnectionStringSecret(t *testing.T) { } }, expected: map[string][]byte{ - "app-user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-mongos.database.svc.cluster.local:27017/?authSource=application"), + "app_user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-mongos.database.svc.cluster.local:27017/?authSource=application"), }, }, "exposed mongos": { @@ -137,8 +138,8 @@ func TestEnsureConnectionStringSecret(t *testing.T) { } }, expected: map[string][]byte{ - "app-user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.20/?authSource=application"), - "app-user_mongos_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@mongos.example.com/?authSource=application"), + "app_user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.20/?authSource=application"), + "app_user_mongos_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@mongos.example.com/?authSource=application"), }, }, } @@ -161,7 +162,7 @@ func TestEnsureConnectionStringSecret(t *testing.T) { r.client, cr, "app-user-conn-str", - "app-user", + "app/user", psmdb.Credentials{ Username: "app-user", Password: "p@ss/word", @@ -178,6 +179,9 @@ func TestEnsureConnectionStringSecret(t *testing.T) { Namespace: cr.Namespace, }, actual)) assert.Equal(t, tt.expected, actual.Data) + for key := range actual.Data { + assert.Empty(t, validation.IsConfigMapKey(key)) + } require.Len(t, actual.OwnerReferences, 1) assert.Equal(t, owner.UID, actual.OwnerReferences[0].UID) assert.Equal(t, "Secret", actual.OwnerReferences[0].Kind) From 417957aad6c1ee78601fe7eef27ca52bbbb14101 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Fri, 12 Jun 2026 17:34:16 +0300 Subject: [PATCH 06/11] small fix --- pkg/controller/perconaservermongodb/secrets.go | 4 ++-- pkg/controller/perconaservermongodb/secrets_test.go | 13 +++++++++---- pkg/psmdb/client.go | 6 +++--- pkg/psmdb/client_test.go | 2 ++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pkg/controller/perconaservermongodb/secrets.go b/pkg/controller/perconaservermongodb/secrets.go index fe73d72dd8..4f65aaef92 100644 --- a/pkg/controller/perconaservermongodb/secrets.go +++ b/pkg/controller/perconaservermongodb/secrets.go @@ -82,7 +82,7 @@ func ensureConnectionStringSecret( connStrSecret.Labels = naming.ClusterLabels(cr) if includeReplsets { for _, rs := range cr.GetAllReplsets() { - cfg, err := psmdb.MongoConfig(ctx, cl, cr, rs, cred, false) + cfg, err := psmdb.MongoConfig(ctx, cl, cr, cr.Spec.ClusterServiceDNSMode, rs, cred, false) if err != nil { return errors.Wrap(err, "mongo config") } @@ -97,7 +97,7 @@ func ensureConnectionStringSecret( }, "."))) if rs.Expose.Enabled { - cfg, err := psmdb.MongoConfig(ctx, cl, cr, rs, cred, true) + cfg, err := psmdb.MongoConfig(ctx, cl, cr, api.DNSModeExternal, rs, cred, true) if err != nil { return errors.Wrap(err, "mongo config") } diff --git a/pkg/controller/perconaservermongodb/secrets_test.go b/pkg/controller/perconaservermongodb/secrets_test.go index c7a8ea9ba0..84311f346c 100644 --- a/pkg/controller/perconaservermongodb/secrets_test.go +++ b/pkg/controller/perconaservermongodb/secrets_test.go @@ -46,7 +46,6 @@ func TestEnsureConnectionStringSecret(t *testing.T) { "exposed replset": { includeReplsets: true, setup: func(cr *api.PerconaServerMongoDB) []client.Object { - cr.Spec.ClusterServiceDNSMode = api.DNSModeExternal rs := cr.Spec.Replsets[0] rs.Expose.Enabled = true pod := fakePodsForRS(cr, rs)[0] @@ -59,19 +58,25 @@ func TestEnsureConnectionStringSecret(t *testing.T) { Namespace: cr.Namespace, }, Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - ClusterIP: "10.0.0.10", + Type: corev1.ServiceTypeLoadBalancer, Ports: []corev1.ServicePort{ {Name: "mongodb", Port: 27017}, }, }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {Hostname: "rs0.example.com"}, + }, + }, + }, }, } }, expected: map[string][]byte{ "app_user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), "app_user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), - "app_user_rs0_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.10:27017/?authSource=application&replicaSet=rs0"), + "app_user_rs0_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@rs0.example.com:27017/?authSource=application&replicaSet=rs0"), }, }, "mongos without replsets": { diff --git a/pkg/psmdb/client.go b/pkg/psmdb/client.go index 442e1df35f..f1dff6cc74 100644 --- a/pkg/psmdb/client.go +++ b/pkg/psmdb/client.go @@ -18,7 +18,7 @@ type Credentials struct { } func MongoClient(ctx context.Context, k8sClient client.Client, cr *api.PerconaServerMongoDB, rs *api.ReplsetSpec, c Credentials) (mongo.Client, error) { - conf, err := MongoConfig(ctx, k8sClient, cr, rs, c, false) + conf, err := MongoConfig(ctx, k8sClient, cr, cr.Spec.ClusterServiceDNSMode, rs, c, false) if err != nil { return nil, errors.Wrap(err, "mongo config") } @@ -26,7 +26,7 @@ func MongoClient(ctx context.Context, k8sClient client.Client, cr *api.PerconaSe return mongo.Dial(ctx, conf) } -func MongoConfig(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, rs *api.ReplsetSpec, c Credentials, rsExposed bool) (*mongo.Config, error) { +func MongoConfig(ctx context.Context, cl client.Client, cr *api.PerconaServerMongoDB, dnsMode api.DNSMode, rs *api.ReplsetSpec, c Credentials, rsExposed bool) (*mongo.Config, error) { pods, err := GetRSPods(ctx, cl, cr, rs.Name) if err != nil { return nil, errors.Wrapf(err, "get pods list for replset %s", rs.Name) @@ -42,7 +42,7 @@ func MongoConfig(ctx context.Context, cl client.Client, cr *api.PerconaServerMon } } - rsAddrs, err := GetReplsetAddrs(ctx, cl, cr, cr.Spec.ClusterServiceDNSMode, rs, rsExposed, pods.Items) + rsAddrs, err := GetReplsetAddrs(ctx, cl, cr, dnsMode, rs, rsExposed, pods.Items) if err != nil { return nil, errors.Wrap(err, "get replset addr") } diff --git a/pkg/psmdb/client_test.go b/pkg/psmdb/client_test.go index ded8b3a9d7..1bb87527ae 100644 --- a/pkg/psmdb/client_test.go +++ b/pkg/psmdb/client_test.go @@ -155,6 +155,7 @@ func TestMongoConfigURIFromReplsetAddrs(t *testing.T) { t.Context(), cl, cr, + tt.dnsMode, rs, clientTestCredentials(), tt.rsExposed, @@ -196,6 +197,7 @@ func TestMongoConfigReturnsServiceImportLookupError(t *testing.T) { t.Context(), serviceImportErrorClient{Client: cl}, cr, + dnsMode, rs, clientTestCredentials(), true, From 0b8105f320fa6f84420444630e1fdabaa98e77df Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Thu, 18 Jun 2026 17:28:09 +0300 Subject: [PATCH 07/11] fix connection strings when using default custom user secret --- .../perconaservermongodb/custom_users.go | 25 +-- .../perconaservermongodb/secrets.go | 158 +++++++++++++----- .../perconaservermongodb/secrets_test.go | 48 ++++++ 3 files changed, 166 insertions(+), 65 deletions(-) diff --git a/pkg/controller/perconaservermongodb/custom_users.go b/pkg/controller/perconaservermongodb/custom_users.go index c203fad1ea..43cc3ae273 100644 --- a/pkg/controller/perconaservermongodb/custom_users.go +++ b/pkg/controller/perconaservermongodb/custom_users.go @@ -18,8 +18,6 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" api "github.com/percona/percona-server-mongodb-operator/pkg/apis/psmdb/v1" - "github.com/percona/percona-server-mongodb-operator/pkg/naming" - "github.com/percona/percona-server-mongodb-operator/pkg/psmdb" "github.com/percona/percona-server-mongodb-operator/pkg/psmdb/mongo" s "github.com/percona/percona-server-mongodb-operator/pkg/psmdb/secret" ) @@ -79,6 +77,8 @@ func handleUsers(ctx context.Context, cr *api.PerconaServerMongoDB, mongoCli mon uniqueUserNames := make(map[string]struct{}, len(cr.Spec.Users)) + usersWithConnStr := []api.User{} + for _, user := range cr.Spec.Users { err := validateUser(&user, systemUserNames, uniqueUserNames) if err != nil { @@ -112,23 +112,7 @@ func handleUsers(ctx context.Context, cr *api.PerconaServerMongoDB, mongoCli mon } if !user.IsExternalDB() { - cred := psmdb.Credentials{ - Username: user.Name, - Password: string(sec.Data[userSecretPassKey]), - AuthSource: user.DB, - } - if err := ensureConnectionStringSecret( - ctx, - client, - cr, - naming.SecretCustomUserConnStrName(cr, &user), - user.Name, - cred, - sec, - !cr.Spec.Sharding.Enabled, - ); err != nil { - return errors.Wrapf(err, "ensure user conn string secret %s", user.Name) - } + usersWithConnStr = append(usersWithConnStr, user) } annotationKey := buildAnnotationKey(cr, user.Name) @@ -153,6 +137,9 @@ func handleUsers(ctx context.Context, cr *api.PerconaServerMongoDB, mongoCli mon continue } } + if err := ensureCustomUsersConnectionStringSecrets(ctx, client, cr, usersWithConnStr); err != nil { + return errors.Wrap(err, "failed to create custom user conn str secrets") + } return nil } diff --git a/pkg/controller/perconaservermongodb/secrets.go b/pkg/controller/perconaservermongodb/secrets.go index 4f65aaef92..d283d604b1 100644 --- a/pkg/controller/perconaservermongodb/secrets.go +++ b/pkg/controller/perconaservermongodb/secrets.go @@ -60,16 +60,6 @@ func ensureConnectionStringSecret( owner metav1.Object, includeReplsets bool, ) error { - keyPrefix = strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || - r >= 'A' && r <= 'Z' || - r >= '0' && r <= '9' || - r == '.' || r == '_' || r == '-' { - return r - } - return '_' - }, keyPrefix) - connStrSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, @@ -80,56 +70,132 @@ func ensureConnectionStringSecret( _, err := controllerutil.CreateOrUpdate(ctx, cl, connStrSecret, func() error { connStrSecret.Data = make(map[string][]byte) connStrSecret.Labels = naming.ClusterLabels(cr) - if includeReplsets { - for _, rs := range cr.GetAllReplsets() { - cfg, err := psmdb.MongoConfig(ctx, cl, cr, cr.Spec.ClusterServiceDNSMode, rs, cred, false) - if err != nil { - return errors.Wrap(err, "mongo config") + if err := fillUserConnectionString(ctx, cl, connStrSecret.Data, cr, keyPrefix, cred, includeReplsets); err != nil { + return errors.Wrap(err, "fill user connection string") + } + if err := controllerutil.SetOwnerReference(owner, connStrSecret, cl.Scheme()); err != nil { + return errors.Wrap(err, "set owner reference") + } + return nil + }) + return errors.Wrap(err, "create or update") +} + +func ensureCustomUsersConnectionStringSecrets( + ctx context.Context, + cl client.Client, + cr *api.PerconaServerMongoDB, + users []api.User, +) error { + m := map[string][]api.User{} + for _, u := range users { + secretName := u.SecretName(cr) + if u.IsExternalDB() { + continue + } + m[secretName] = append(m[secretName], u) + } + + for secretName, users := range m { + secret := &corev1.Secret{} + err := cl.Get(ctx, types.NamespacedName{Name: secretName, Namespace: cr.Namespace}, secret) + if err != nil { + return err + } + + connStrSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.SecretCustomUserConnStrName(cr, &users[0]), + Namespace: cr.Namespace, + }, + } + + if _, err := controllerutil.CreateOrUpdate(ctx, cl, connStrSecret, func() error { + connStrSecret.Data = make(map[string][]byte) + connStrSecret.Labels = naming.ClusterLabels(cr) + for _, user := range users { + userSecretPassKey := user.Name + if user.PasswordSecretRef != nil { + userSecretPassKey = user.PasswordSecretRef.Key } - connStr := cfg.URI() - key := keyPrefix + "_" + rs.Name - connStrSecret.Data[key+"_connectionString"] = []byte(connStr) - connStrSecret.Data[key+"_connectionStringSrv"] = []byte(cfg.SRVURI(strings.Join([]string{ - naming.ServiceName(cr, rs), - cr.Namespace, - cr.Spec.ClusterServiceDNSSuffix, - }, "."))) - - if rs.Expose.Enabled { - cfg, err := psmdb.MongoConfig(ctx, cl, cr, api.DNSModeExternal, rs, cred, true) - if err != nil { - return errors.Wrap(err, "mongo config") - } - if exposedConnStr := cfg.URI(); exposedConnStr != connStr { - connStrSecret.Data[key+"_connectionStringExposed"] = []byte(exposedConnStr) - } + cred := psmdb.Credentials{ + Username: user.Name, + Password: string(secret.Data[userSecretPassKey]), + AuthSource: user.DB, + } + if err := fillUserConnectionString(ctx, cl, connStrSecret.Data, cr, user.Name, cred, !cr.Spec.Sharding.Enabled); err != nil { + return errors.Wrap(err, "fill user connection string") } } + if err := controllerutil.SetOwnerReference(secret, connStrSecret, cl.Scheme()); err != nil { + return errors.Wrap(err, "set owner reference") + } + return nil + }); err != nil { + return err } + } + + return nil +} - if cr.Spec.Sharding.Enabled { - servicePerPod := cr.Spec.Sharding.Mongos.Expose.ServicePerPod - mongosCfg, err := psmdb.MongosConfig(ctx, cl, cr, cred, true, servicePerPod) +func fillUserConnectionString(ctx context.Context, cl client.Client, data map[string][]byte, cr *api.PerconaServerMongoDB, keyPrefix string, cred psmdb.Credentials, includeReplsets bool) error { + keyPrefix = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || + r >= 'A' && r <= 'Z' || + r >= '0' && r <= '9' || + r == '.' || r == '_' || r == '-' { + return r + } + return '_' + }, keyPrefix) + + if includeReplsets { + for _, rs := range cr.GetAllReplsets() { + cfg, err := psmdb.MongoConfig(ctx, cl, cr, cr.Spec.ClusterServiceDNSMode, rs, cred, false) if err != nil { - return errors.Wrap(err, "mongos config") + return errors.Wrap(err, "mongo config") } - connStrSecret.Data[keyPrefix+"_mongos_connectionString"] = []byte(mongosCfg.URI()) - if servicePerPod { - mongosCfg, err := psmdb.MongosConfig(ctx, cl, cr, cred, false, true) + connStr := cfg.URI() + key := keyPrefix + "_" + rs.Name + data[key+"_connectionString"] = []byte(connStr) + data[key+"_connectionStringSrv"] = []byte(cfg.SRVURI(strings.Join([]string{ + naming.ServiceName(cr, rs), + cr.Namespace, + cr.Spec.ClusterServiceDNSSuffix, + }, "."))) + + if rs.Expose.Enabled { + cfg, err := psmdb.MongoConfig(ctx, cl, cr, api.DNSModeExternal, rs, cred, true) if err != nil { - return errors.Wrap(err, "mongos config") + return errors.Wrap(err, "mongo config") + } + if exposedConnStr := cfg.URI(); exposedConnStr != connStr { + data[key+"_connectionStringExposed"] = []byte(exposedConnStr) } - connStrSecret.Data[keyPrefix+"_mongos_connectionStringExposed"] = []byte(mongosCfg.URI()) } } - if err := controllerutil.SetOwnerReference(owner, connStrSecret, cl.Scheme()); err != nil { - return errors.Wrap(err, "set owner reference") + } + + if cr.Spec.Sharding.Enabled { + servicePerPod := cr.Spec.Sharding.Mongos.Expose.ServicePerPod + mongosCfg, err := psmdb.MongosConfig(ctx, cl, cr, cred, true, servicePerPod) + if err != nil { + return errors.Wrap(err, "mongos config") } - return nil - }) - return errors.Wrap(err, "create or update") + data[keyPrefix+"_mongos_connectionString"] = []byte(mongosCfg.URI()) + + if servicePerPod { + mongosCfg, err := psmdb.MongosConfig(ctx, cl, cr, cred, false, true) + if err != nil { + return errors.Wrap(err, "mongos config") + } + data[keyPrefix+"_mongos_connectionStringExposed"] = []byte(mongosCfg.URI()) + } + } + return nil } func (r *ReconcilePerconaServerMongoDB) reconcileUsersSecret(ctx context.Context, cr *api.PerconaServerMongoDB) error { diff --git a/pkg/controller/perconaservermongodb/secrets_test.go b/pkg/controller/perconaservermongodb/secrets_test.go index 84311f346c..8d44573fe2 100644 --- a/pkg/controller/perconaservermongodb/secrets_test.go +++ b/pkg/controller/perconaservermongodb/secrets_test.go @@ -234,6 +234,54 @@ func TestReconcileUsersCreatesConnectionStringSecretWhenCredentialsUnchanged(t * assert.Contains(t, actual.Data, "databaseAdmin_rs0_connectionString") } +func TestEnsureCustomUsersConnectionStringSecretsIncludesMultipleDefaultUsers(t *testing.T) { + cr := connectionStringTestCluster() + users := []api.User{ + {Name: "app-user", DB: "application", Roles: []api.UserRole{{Name: "readWrite", DB: "application"}}}, + {Name: "report-user", DB: "reports", Roles: []api.UserRole{{Name: "read", DB: "reports"}}}, + } + owner := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: users[0].DefaultSecretName(cr), + Namespace: cr.Namespace, + UID: types.UID("custom-users-secret-uid"), + }, + Data: map[string][]byte{ + "app-user": []byte("p@ss/word"), + "report-user": []byte("report/pass"), + }, + } + rs := cr.Spec.Replsets[0] + r := buildFakeClient( + owner, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.SecretCustomUserConnStrName(cr, &users[0]), + Namespace: cr.Namespace, + }, + Data: map[string][]byte{"stale": []byte("value")}, + }, + fakeStatefulset(cr, rs, rs.Size, "", "mongod"), + fakePodsForRS(cr, rs)[0], + ) + + require.NoError(t, ensureCustomUsersConnectionStringSecrets(t.Context(), r.client, cr, users)) + + actual := new(corev1.Secret) + require.NoError(t, r.client.Get(t.Context(), types.NamespacedName{ + Name: naming.SecretCustomUserConnStrName(cr, &users[0]), + Namespace: cr.Namespace, + }, actual)) + assert.Equal(t, map[string][]byte{ + "app-user_rs0_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=application&replicaSet=rs0"), + "app-user_rs0_connectionStringSrv": []byte("mongodb+srv://app-user:p%40ss%2Fword@cluster-rs0.database.svc.cluster.local/?authSource=application&replicaSet=rs0"), + "report-user_rs0_connectionString": []byte("mongodb://report-user:report%2Fpass@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=reports&replicaSet=rs0"), + "report-user_rs0_connectionStringSrv": []byte("mongodb+srv://report-user:report%2Fpass@cluster-rs0.database.svc.cluster.local/?authSource=reports&replicaSet=rs0"), + }, actual.Data) + require.Len(t, actual.OwnerReferences, 1) + assert.Equal(t, owner.UID, actual.OwnerReferences[0].UID) +} + func connectionStringTestCluster() *api.PerconaServerMongoDB { return &api.PerconaServerMongoDB{ ObjectMeta: metav1.ObjectMeta{ From 78dbc0e5da803487a9f48f102ec66601fdcf5b44 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Mon, 22 Jun 2026 14:17:25 +0300 Subject: [PATCH 08/11] add port for loadbalancer host --- pkg/psmdb/client_test.go | 4 +-- pkg/psmdb/service.go | 2 +- pkg/psmdb/service_test.go | 60 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/pkg/psmdb/client_test.go b/pkg/psmdb/client_test.go index 1bb87527ae..845f54d16e 100644 --- a/pkg/psmdb/client_test.go +++ b/pkg/psmdb/client_test.go @@ -248,7 +248,7 @@ func TestMongosConfigURIFromMongosAddrs(t *testing.T) { svc.Spec.ClusterIP = "10.0.0.20" return []client.Object{svc} }, - expectedURI: "mongodb://app-user:p%40ss%2Fword@10.0.0.20/?authSource=application", + expectedURI: "mongodb://app-user:p%40ss%2Fword@10.0.0.20:27017/?authSource=application", }, "load balancer external address": { exposeType: corev1.ServiceTypeLoadBalancer, @@ -260,7 +260,7 @@ func TestMongosConfigURIFromMongosAddrs(t *testing.T) { } return []client.Object{svc} }, - expectedURI: "mongodb://app-user:p%40ss%2Fword@mongos.example.com/?authSource=application", + expectedURI: "mongodb://app-user:p%40ss%2Fword@mongos.example.com:27017/?authSource=application", }, } diff --git a/pkg/psmdb/service.go b/pkg/psmdb/service.go index 1e89cd551c..ee52cc4f2e 100644 --- a/pkg/psmdb/service.go +++ b/pkg/psmdb/service.go @@ -387,7 +387,7 @@ func MongosHost(ctx context.Context, cl client.Client, cr *api.PerconaServerMong if err != nil { return "", errors.Wrap(err, "get ingress endpoint") } - return host, nil + return fmt.Sprintf("%s:%d", host, mongosPort), nil } return fmt.Sprintf("%s.%s.%s:%d", svc.Name, cr.Namespace, cr.Spec.ClusterServiceDNSSuffix, mongosPort), nil diff --git a/pkg/psmdb/service_test.go b/pkg/psmdb/service_test.go index 0c6c303e05..1307b0a6bd 100644 --- a/pkg/psmdb/service_test.go +++ b/pkg/psmdb/service_test.go @@ -30,6 +30,8 @@ func TestMongosHost(t *testing.T) { tests := map[string]struct { init func(cl client.Client) + useInternal bool + exposeType corev1.ServiceType expectedHost string expectedError error }{ @@ -62,6 +64,59 @@ func TestMongosHost(t *testing.T) { }, expectedHost: "test-cluster-mongos.default.svc.cluster.local:27018", }, + "loadbalancer service type": { + init: func(cl client.Client) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-mongos", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "mongos", + Port: 27018, + }, + }, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {Hostname: "mongos.example.com"}, + }, + }, + }, + } + assert.NoError(t, cl.Create(ctx, svc)) + }, + exposeType: corev1.ServiceTypeLoadBalancer, + expectedHost: "mongos.example.com:27018", + }, + "loadbalancer service type with internal address": { + init: func(cl client.Client) { + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-mongos", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + ClusterIP: "10.0.0.20", + Ports: []corev1.ServicePort{ + { + Name: "mongos", + Port: 27018, + }, + }, + }, + } + assert.NoError(t, cl.Create(ctx, svc)) + }, + useInternal: true, + exposeType: corev1.ServiceTypeLoadBalancer, + expectedHost: "10.0.0.20:27018", + }, "err: clusterip service type and port not found": { init: func(cl client.Client) { svc := &corev1.Service{ @@ -102,13 +157,16 @@ func TestMongosHost(t *testing.T) { Mongos: &api.MongosSpec{ Expose: api.MongosExpose{ ServicePerPod: false, + Expose: api.Expose{ + ExposeType: tt.exposeType, + }, }, }, }, }, } - host, err := MongosHost(ctx, cl, cr, pod, false) + host, err := MongosHost(ctx, cl, cr, pod, tt.useInternal) if tt.expectedError != nil { assert.Empty(t, host) assert.EqualError(t, err, tt.expectedError.Error()) From 698074ba9668d2d84d5ef978a145acbf1c395e24 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Mon, 22 Jun 2026 14:20:10 +0300 Subject: [PATCH 09/11] set authSource=admin --- pkg/controller/perconaservermongodb/secrets.go | 2 +- pkg/controller/perconaservermongodb/secrets_test.go | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/controller/perconaservermongodb/secrets.go b/pkg/controller/perconaservermongodb/secrets.go index 3f599c9994..49b5bbbd52 100644 --- a/pkg/controller/perconaservermongodb/secrets.go +++ b/pkg/controller/perconaservermongodb/secrets.go @@ -36,7 +36,7 @@ func getInternalCredentials(ctx context.Context, cl client.Reader, cr *api.Perco } func getCredentials(secret *corev1.Secret, role api.SystemUserRole) (psmdb.Credentials, error) { - creds := psmdb.Credentials{} + creds := psmdb.Credentials{AuthSource: "admin"} envKeyUser, envKeyPass := role.EnvKeyUsername(), role.EnvKeyPassword() if envKeyUser == "" || envKeyPass == "" { return creds, errors.Errorf("invalid role %s", string(role)) diff --git a/pkg/controller/perconaservermongodb/secrets_test.go b/pkg/controller/perconaservermongodb/secrets_test.go index 8d44573fe2..147f483f94 100644 --- a/pkg/controller/perconaservermongodb/secrets_test.go +++ b/pkg/controller/perconaservermongodb/secrets_test.go @@ -143,8 +143,8 @@ func TestEnsureConnectionStringSecret(t *testing.T) { } }, expected: map[string][]byte{ - "app_user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.20/?authSource=application"), - "app_user_mongos_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@mongos.example.com/?authSource=application"), + "app_user_mongos_connectionString": []byte("mongodb://app-user:p%40ss%2Fword@10.0.0.20:27017/?authSource=application"), + "app_user_mongos_connectionStringExposed": []byte("mongodb://app-user:p%40ss%2Fword@mongos.example.com:27017/?authSource=application"), }, }, } @@ -231,7 +231,10 @@ func TestReconcileUsersCreatesConnectionStringSecretWhenCredentialsUnchanged(t * Namespace: cr.Namespace, } require.NoError(t, r.client.Get(t.Context(), key, actual)) - assert.Contains(t, actual.Data, "databaseAdmin_rs0_connectionString") + assert.Equal(t, + []byte("mongodb://databaseAdmin:password@cluster-rs0-0.cluster-rs0.database.svc.cluster.local:27017/?authSource=admin&replicaSet=rs0"), + actual.Data["databaseAdmin_rs0_connectionString"], + ) } func TestEnsureCustomUsersConnectionStringSecretsIncludesMultipleDefaultUsers(t *testing.T) { From 186bddba9b9b9d332dbf5df7a699768688132265 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Mon, 22 Jun 2026 14:33:05 +0300 Subject: [PATCH 10/11] fix unit-test --- pkg/controller/perconaservermongodb/status_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/controller/perconaservermongodb/status_test.go b/pkg/controller/perconaservermongodb/status_test.go index a030040035..3777a55f0e 100644 --- a/pkg/controller/perconaservermongodb/status_test.go +++ b/pkg/controller/perconaservermongodb/status_test.go @@ -450,7 +450,7 @@ func TestConnectionEndpoint(t *testing.T) { VolumeSpec: fakeVolumeSpec(t), } }), - expected: "mongos-ip", + expected: "mongos-ip:27017", }, { name: "cluster ip expose for sharding", @@ -489,7 +489,7 @@ func TestConnectionEndpoint(t *testing.T) { VolumeSpec: fakeVolumeSpec(t), } }), - expected: "mongos-ip,mongos-ip,mongos-ip", + expected: "mongos-ip:27017,mongos-ip:27017,mongos-ip:27017", }, { name: "cluster ip expose for sharding with service per pod", From e70160a150f3f5ac58a42ece320215f1c864f5da Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Tue, 23 Jun 2026 14:52:23 +0300 Subject: [PATCH 11/11] fix e2e tests --- e2e-tests/functions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/functions b/e2e-tests/functions index 93341545aa..8b6c823bbd 100755 --- a/e2e-tests/functions +++ b/e2e-tests/functions @@ -1369,7 +1369,7 @@ get_mongo_primary() { check_exported_mongos_service_endpoint() { local host=$1 - if [ "$host" != "$(kubectl_bin get psmdb $cluster -o=jsonpath='{.status.host}')" ]; then + if [ "$host:27017" != "$(kubectl_bin get psmdb $cluster -o=jsonpath='{.status.host}')" ]; then echo "Exported host is not correct after the restore" exit 1 fi