diff --git a/internal/controller/postgresrole_controller.go b/internal/controller/postgresrole_controller.go index 01d07f8..aa4b75c 100644 --- a/internal/controller/postgresrole_controller.go +++ b/internal/controller/postgresrole_controller.go @@ -19,9 +19,12 @@ package controller import ( "context" "fmt" + "math/rand/v2" "time" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -80,10 +83,19 @@ func (r *PostgresRoleReconciler) Reconcile(ctx context.Context, req ctrl.Request resourceSecret := &corev1.Secret{} if err := r.Client.Get(ctx, secretNamespacedName, resourceSecret); err != nil { - return ctrlFailResult, client.IgnoreNotFound(err) + // If the secret doesn't exist, it creates it and generates a password + if errors.IsNotFound(err) { + rolePassword, err = r.generatePasswordSecret(&secretNamespacedName) + if err != nil { + return ctrlFailResult, err + } + r.logging.Info(fmt.Sprintf("Password has been generated and secret created for role \"%s\"", resource.Spec.Name)) + } else { + return ctrlFailResult, client.IgnoreNotFound(err) + } + } else { + rolePassword = string(resourceSecret.Data["password"]) } - - rolePassword = string(resourceSecret.Data["password"]) } desiredRole := postgresql.Role{ @@ -125,6 +137,26 @@ func (r *PostgresRoleReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrlFailResult, err } + // Remove associated secret if created by operator + if resource.Spec.PasswordSecretName != "" { + secretNamespacedName := types.NamespacedName{ + Namespace: resource.ObjectMeta.Namespace, + Name: resource.Spec.PasswordSecretName, + } + + resourceSecret := &corev1.Secret{} + if err := r.Client.Get(ctx, secretNamespacedName, resourceSecret); err != nil { + return ctrlFailResult, client.IgnoreNotFound(err) + } + + if val, ok := resourceSecret.Labels["app.kubernetes.io/managed-by"]; ok && val == "managed-postgres-operator.hoppscale.com" { + err = r.Client.Delete(ctx, resourceSecret) + if err != nil { + return ctrlFailResult, client.IgnoreNotFound(err) + } + } + } + // Remove our finalizer from the list and update it. controllerutil.RemoveFinalizer(resource, PostgresRoleFinalizer) if err := r.Update(ctx, resource); err != nil { @@ -281,3 +313,33 @@ func (r *PostgresRoleReconciler) reconcileRoleMembership(role string, desiredMem return err } + +func (r *PostgresRoleReconciler) generatePasswordSecret(secretName *types.NamespacedName) (password string, err error) { + // Generate password + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + random := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) + + result := make([]byte, 30) + for i := range result { + result[i] = charset[random.IntN(len(charset))] + } + + // Create K8S secret + resourceSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: secretName.Namespace, + Name: secretName.Name, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "managed-postgres-operator.hoppscale.com", + }, + }, + Type: "Opaque", + Data: map[string][]byte{ + "password": result, + }, + } + + err = r.Client.Create(context.Background(), resourceSecret) + password = string(result) + return +} diff --git a/internal/controller/postgresrole_controller_test.go b/internal/controller/postgresrole_controller_test.go index c1e8146..c100bd3 100644 --- a/internal/controller/postgresrole_controller_test.go +++ b/internal/controller/postgresrole_controller_test.go @@ -347,6 +347,62 @@ var _ = Describe("PostgresRole Controller", func() { }) }) + When("the resource is created without a password but a secret and no role exists", func() { + It("should reconcile the resource, create a secret, generate a password and create the role", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.Spec.PasswordSecretName = "db-config" + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: make(map[string]string), + } + + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolreplication", + "rolbypassrls", + }), + ) + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s[a-zA-Z0-9]{30}'$", regexp.QuoteMeta(`CREATE ROLE "foo" WITH NOSUPERUSER NOINHERIT CREATEROLE CREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS PASSWORD '`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetRoleMembershipStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "group_role", + }), + ) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolsMock["default"].ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + secretPassword := &corev1.Secret{} + secretPasswordName := types.NamespacedName{ + Namespace: "default", + Name: "db-config", + } + Expect(k8sClient.Get(ctx, secretPasswordName, secretPassword)).To(Succeed()) + Expect(k8sClient.Delete(ctx, secretPassword)).To(Succeed()) + }) + }) + When("the resource is deleted", func() { It("should successfully reconcile the resource on deletion", func() { By("Reconciling the deleted resource") @@ -402,6 +458,82 @@ var _ = Describe("PostgresRole Controller", func() { }) }) + When("the resource is deleted and an associated secret exists", func() { + It("should successfully reconcile the resource on deletion and delete the associated secret", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + controllerutil.AddFinalizer(resource, PostgresRoleFinalizer) + resource.Spec.PasswordSecretName = "db-config" + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + resourceSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "db-config", + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "managed-postgres-operator.hoppscale.com", + }, + }, + Type: "Opaque", + Data: map[string][]byte{ + "password": []byte("password"), + }, + } + Expect(k8sClient.Create(ctx, resourceSecret)).To(Succeed()) + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: make(map[string]string), + } + + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolreplication", + "rolbypassrls", + }). + AddRow( + "foo", + false, + false, + true, + true, + false, + false, + false, + ), + ) + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`DROP ROLE "foo"`))). + WillReturnResult(pgxmock.NewResult("", 1)) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolsMock["default"].ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + secretNamespacedName := types.NamespacedName{ + Namespace: "default", + Name: "db-config", + } + err = k8sClient.Get(ctx, secretNamespacedName, &corev1.Secret{}) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + }) + When("reconciling on creation", func() { It("should not create role if already exists", func() { existingRole := &postgresql.Role{