From 98c083a3af0a56bd2d078a0c69290e116c7aa0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Depriester?= Date: Sun, 11 May 2025 15:18:20 +0200 Subject: [PATCH] feat: add support of multiple operator instances --- cmd/main.go | 18 +++-- .../controller/postgresdatabase_controller.go | 9 ++- .../postgresdatabase_controller_test.go | 81 +++++++++++++++++++ .../controller/postgresrole_controller.go | 9 ++- .../postgresrole_controller_test.go | 72 +++++++++++++++++ internal/utils/suite_test.go | 14 ++++ internal/utils/utils.go | 15 ++++ internal/utils/utils_test.go | 58 +++++++++++++ 8 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 internal/utils/suite_test.go create mode 100644 internal/utils/utils.go create mode 100644 internal/utils/utils_test.go diff --git a/cmd/main.go b/cmd/main.go index 3302226..6f54110 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -92,6 +92,8 @@ func main() { tlsOpts = append(tlsOpts, disableHTTP2) } + operatorInstanceName := os.Getenv("OPERATOR_INSTANCE_NAME") + pgpool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) if err != nil { setupLog.Error(err, "Failed to connect PostgreSQL server: %s", err) @@ -207,19 +209,21 @@ func main() { } if err = (&controller.PostgresDatabaseReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - PGPools: pgpools, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + PGPools: pgpools, + OperatorInstanceName: operatorInstanceName, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "PostgresDatabase") os.Exit(1) } if err = (&controller.PostgresRoleReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - PGPools: pgpools, - CacheRolePasswords: cacheRolePasswords, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + PGPools: pgpools, + OperatorInstanceName: operatorInstanceName, + CacheRolePasswords: cacheRolePasswords, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "PostgresRole") os.Exit(1) diff --git a/internal/controller/postgresdatabase_controller.go b/internal/controller/postgresdatabase_controller.go index bae9682..c5d63ab 100644 --- a/internal/controller/postgresdatabase_controller.go +++ b/internal/controller/postgresdatabase_controller.go @@ -15,6 +15,7 @@ import ( managedpostgresoperatorhoppscalecomv1alpha1 "github.com/hoppscale/managed-postgres-operator/api/v1alpha1" "github.com/hoppscale/managed-postgres-operator/internal/postgresql" + "github.com/hoppscale/managed-postgres-operator/internal/utils" ) const PostgresDatabaseFinalizer = "postgresdatabase.managed-postgres-operator.hoppscale.com/finalizer" @@ -25,7 +26,8 @@ type PostgresDatabaseReconciler struct { Scheme *runtime.Scheme logging logr.Logger - PGPools *postgresql.PGPools + PGPools *postgresql.PGPools + OperatorInstanceName string } // +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresdatabases,verbs=get;list;watch;create;update;patch;delete @@ -43,6 +45,11 @@ func (r *PostgresDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrlFailResult, client.IgnoreNotFound(err) } + // Skip reconcile if the resource is not managed by this operator + if !utils.IsManagedByOperatorInstance(resource.ObjectMeta.Annotations, r.OperatorInstanceName) { + return ctrlSuccessResult, nil + } + existingDatabase, err := postgresql.GetDatabase(r.PGPools.Default, resource.Spec.Name) if err != nil { return ctrlFailResult, fmt.Errorf("failed to retrieve database: %s", err) diff --git a/internal/controller/postgresdatabase_controller_test.go b/internal/controller/postgresdatabase_controller_test.go index da64132..dfd3b60 100644 --- a/internal/controller/postgresdatabase_controller_test.go +++ b/internal/controller/postgresdatabase_controller_test.go @@ -17,6 +17,7 @@ import ( managedpostgresoperatorhoppscalecomv1alpha1 "github.com/hoppscale/managed-postgres-operator/api/v1alpha1" "github.com/hoppscale/managed-postgres-operator/internal/postgresql" + "github.com/hoppscale/managed-postgres-operator/internal/utils" ) var _ = Describe("PostgresDatabase Controller", func() { @@ -87,6 +88,86 @@ var _ = Describe("PostgresDatabase Controller", func() { } }) + When("the resource is managed by the operator's instance", func() { + It("should continue to reconcile the resource and create the database", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresDatabase{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "foo", + } + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresDatabaseReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetDatabaseSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "datname", + "owner", + }), + ) + pgpoolsMock["default"].ExpectExec(`CREATE DATABASE "foo"`). + WillReturnResult(pgxmock.NewResult("", 1)) + pgpoolsMock["default"].ExpectExec(`ALTER DATABASE "foo" OWNER TO "foo_owner"`). + WillReturnResult(pgxmock.NewResult("", 1)) + pgpoolsMock["foo"].ExpectQuery(`SELECT extname FROM pg_extension`). + WillReturnRows( + pgxmock.NewRows([]string{ + "extname", + }). + AddRow( + "plpgsql", + ), + ) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + }) + + When("the resource is not managed by the operator's instance", func() { + It("should skip reconciliation", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresDatabase{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "bar", + } + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresDatabaseReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + }) + When("the resource is created and no database exists", func() { It("should reconcile the resource and create the database", func() { By("Reconciling the created resource") diff --git a/internal/controller/postgresrole_controller.go b/internal/controller/postgresrole_controller.go index 46627b9..c30d476 100644 --- a/internal/controller/postgresrole_controller.go +++ b/internal/controller/postgresrole_controller.go @@ -32,6 +32,7 @@ import ( "github.com/go-logr/logr" managedpostgresoperatorhoppscalecomv1alpha1 "github.com/hoppscale/managed-postgres-operator/api/v1alpha1" "github.com/hoppscale/managed-postgres-operator/internal/postgresql" + "github.com/hoppscale/managed-postgres-operator/internal/utils" ) const PostgresRoleFinalizer = "postgresrole.managed-postgres-operator.hoppscale.com/finalizer" @@ -42,7 +43,8 @@ type PostgresRoleReconciler struct { Scheme *runtime.Scheme logging logr.Logger - PGPools *postgresql.PGPools + PGPools *postgresql.PGPools + OperatorInstanceName string CacheRolePasswords map[string]string } @@ -62,6 +64,11 @@ func (r *PostgresRoleReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrlFailResult, client.IgnoreNotFound(err) } + // Skip reconcile if the resource is not managed by this operator + if !utils.IsManagedByOperatorInstance(resource.ObjectMeta.Annotations, r.OperatorInstanceName) { + return ctrlSuccessResult, nil + } + rolePassword := "" if resource.Spec.PasswordSecretName != "" { diff --git a/internal/controller/postgresrole_controller_test.go b/internal/controller/postgresrole_controller_test.go index 546be2d..171fd52 100644 --- a/internal/controller/postgresrole_controller_test.go +++ b/internal/controller/postgresrole_controller_test.go @@ -33,6 +33,7 @@ import ( managedpostgresoperatorhoppscalecomv1alpha1 "github.com/hoppscale/managed-postgres-operator/api/v1alpha1" "github.com/hoppscale/managed-postgres-operator/internal/postgresql" + "github.com/hoppscale/managed-postgres-operator/internal/utils" ) var _ = Describe("PostgresRole Controller", func() { @@ -122,6 +123,77 @@ var _ = Describe("PostgresRole Controller", func() { Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) + When("the resource is managed by the operator's instance", func() { + It("should continue to reconcile the resource and create the role", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "foo", + } + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + 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$", regexp.QuoteMeta(`CREATE ROLE "foo" WITH NOSUPERUSER NOINHERIT CREATEROLE CREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolsMock["default"].ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("the resource is not managed by the operator's instance", func() { + It("should skip reconciliation", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "bar", + } + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + CacheRolePasswords: make(map[string]string), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolsMock["default"].ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + When("the resource is created without password and no role exists", func() { It("should reconcile the resource and create the role", func() { controllerReconciler := &PostgresRoleReconciler{ diff --git a/internal/utils/suite_test.go b/internal/utils/suite_test.go new file mode 100644 index 0000000..f056548 --- /dev/null +++ b/internal/utils/suite_test.go @@ -0,0 +1,14 @@ +package utils + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Utils") +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..049b98f --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,15 @@ +package utils + +const OperatorInstanceAnnotationName string = "managed-postgres-operator.hoppscale.com/instance" + +func IsManagedByOperatorInstance(annotations map[string]string, instanceName string) bool { + if instance, ok := annotations[OperatorInstanceAnnotationName]; ok && instance == instanceName { + return true + } + + if instanceName == "" { + return true + } + + return false +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..f94e1e7 --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,58 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Utils functions", func() { + Context("Calling IsManagedByOperatorInstance", func() { + When("operator's instance is not defined and the resource has no instance annotation", func() { + + It("should return true", func() { + resourceAnnotations := map[string]string{} + + result := IsManagedByOperatorInstance(resourceAnnotations, "") + + Expect(result).To(BeTrue()) + }) + }) + + When("operator's instance is defined and the resource has no instance annotation", func() { + + It("should return false", func() { + resourceAnnotations := map[string]string{} + + result := IsManagedByOperatorInstance(resourceAnnotations, "foo") + + Expect(result).To(BeFalse()) + }) + }) + + When("operator's instance is defined and the resource has another instance annotation", func() { + + It("should return false", func() { + resourceAnnotations := map[string]string{ + OperatorInstanceAnnotationName: "bar", + } + + result := IsManagedByOperatorInstance(resourceAnnotations, "foo") + + Expect(result).To(BeFalse()) + }) + }) + + When("operator's instance is defined and the resource has the same another instance annotation", func() { + + It("should return false", func() { + resourceAnnotations := map[string]string{ + OperatorInstanceAnnotationName: "foo", + } + + result := IsManagedByOperatorInstance(resourceAnnotations, "foo") + + Expect(result).To(BeTrue()) + }) + }) + }) +})