From 4d193e724d8ef8e04c0e215fbcce503dc64dd9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Depriester?= Date: Fri, 25 Apr 2025 14:05:29 +0200 Subject: [PATCH] feat: add support of role --- README.md | 19 + api/v1alpha1/postgresrole_types.go | 69 +++ api/v1alpha1/zz_generated.deepcopy.go | 89 ++++ cmd/main.go | 11 + ...-operator.hoppscale.com_postgresroles.yaml | 79 +++ config/samples/v1alpha1_postgresrole.yaml | 9 + .../controller/postgresrole_controller.go | 220 +++++++++ .../postgresrole_controller_test.go | 457 ++++++++++++++++++ internal/postgresql/role.go | 135 ++++++ internal/postgresql/role_test.go | 311 ++++++++++++ 10 files changed, 1399 insertions(+) create mode 100644 api/v1alpha1/postgresrole_types.go create mode 100644 config/crd/bases/managed-postgres-operator.hoppscale.com_postgresroles.yaml create mode 100644 config/samples/v1alpha1_postgresrole.yaml create mode 100644 internal/controller/postgresrole_controller.go create mode 100644 internal/controller/postgresrole_controller_test.go create mode 100644 internal/postgresql/role.go create mode 100644 internal/postgresql/role_test.go diff --git a/README.md b/README.md index fd9cc68..0ec09fa 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,22 @@ spec: keepDatabaseOnDelete: true # Should the database be kept if the Kubernetes resource is deleted? preserveConnectionsOnDelete: false # Should the operator wait until the open connections are closed before deleting the database? ``` + +### PostgresRole + +```yaml +apiVersion: managed-postgres-operator.hoppscale.com/v1alpha1 +kind: PostgresRole +metadata: + name: myrole +spec: + name: myrole # Role's name + superUser: false # Should the role be a superuser? + createDB: false # Should the role be able to create databases? + createRole: false # Should the role be able to create roles? + inherit: false # Should the role inherit the permissions of the role of which it is a member? + login: false # Should the role be able to log in? + replication: false # Is the role used for replication? + bypassRLS: false # Should the role bypass the defined row-level security (RLS) policies? + passwordSecretName: "my-secret" # Name of the secret from where the role's password should be retrieved under the key `password` +``` diff --git a/api/v1alpha1/postgresrole_types.go b/api/v1alpha1/postgresrole_types.go new file mode 100644 index 0000000..6854890 --- /dev/null +++ b/api/v1alpha1/postgresrole_types.go @@ -0,0 +1,69 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PostgresRoleSpec defines the desired state of PostgresRole. +type PostgresRoleSpec struct { + // PostgreSQL role name + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:message="name is immutable",rule="self == oldSelf" + Name string `json:"name,omitempty"` + + SuperUser bool `json:"superUser,omitempty"` + CreateDB bool `json:"createDB,omitempty"` + CreateRole bool `json:"createRole,omitempty"` + Inherit bool `json:"inherit,omitempty"` + Login bool `json:"login,omitempty"` + Replication bool `json:"replication,omitempty"` + BypassRLS bool `json:"bypassRLS,omitempty"` + + PasswordSecretName string `json:"passwordSecretName,omitempty"` +} + +// PostgresRoleStatus defines the observed state of PostgresRole. +type PostgresRoleStatus struct { + Succeeded bool `json:"succeeded"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// PostgresRole is the Schema for the postgresroles API. +type PostgresRole struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PostgresRoleSpec `json:"spec,omitempty"` + Status PostgresRoleStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// PostgresRoleList contains a list of PostgresRole. +type PostgresRoleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PostgresRole `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PostgresRole{}, &PostgresRoleList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d1b5a9d..599ca49 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -117,3 +117,92 @@ func (in *PostgresDatabaseStatus) DeepCopy() *PostgresDatabaseStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresRole) DeepCopyInto(out *PostgresRole) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRole. +func (in *PostgresRole) DeepCopy() *PostgresRole { + if in == nil { + return nil + } + out := new(PostgresRole) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresRole) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresRoleList) DeepCopyInto(out *PostgresRoleList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PostgresRole, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRoleList. +func (in *PostgresRoleList) DeepCopy() *PostgresRoleList { + if in == nil { + return nil + } + out := new(PostgresRoleList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresRoleList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresRoleSpec) DeepCopyInto(out *PostgresRoleSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRoleSpec. +func (in *PostgresRoleSpec) DeepCopy() *PostgresRoleSpec { + if in == nil { + return nil + } + out := new(PostgresRoleSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresRoleStatus) DeepCopyInto(out *PostgresRoleStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRoleStatus. +func (in *PostgresRoleStatus) DeepCopy() *PostgresRoleStatus { + if in == nil { + return nil + } + out := new(PostgresRoleStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 7ccfa40..3302226 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -106,6 +106,8 @@ func main() { }, } + cacheRolePasswords := make(map[string]string) + // Create watchers for metrics and webhooks certificates var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher @@ -213,6 +215,15 @@ func main() { os.Exit(1) } + if err = (&controller.PostgresRoleReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + PGPools: pgpools, + CacheRolePasswords: cacheRolePasswords, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PostgresRole") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresroles.yaml b/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresroles.yaml new file mode 100644 index 0000000..431c395 --- /dev/null +++ b/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresroles.yaml @@ -0,0 +1,79 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: postgresroles.managed-postgres-operator.hoppscale.com +spec: + group: managed-postgres-operator.hoppscale.com + names: + kind: PostgresRole + listKind: PostgresRoleList + plural: postgresroles + singular: postgresrole + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: PostgresRole is the Schema for the postgresroles API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: PostgresRoleSpec defines the desired state of PostgresRole. + properties: + bypassRLS: + type: boolean + createDB: + type: boolean + createRole: + type: boolean + inherit: + type: boolean + login: + type: boolean + name: + description: PostgreSQL role name + type: string + x-kubernetes-validations: + - message: name is immutable + rule: self == oldSelf + passwordSecretName: + type: string + replication: + type: boolean + superUser: + type: boolean + required: + - name + type: object + status: + description: PostgresRoleStatus defines the observed state of PostgresRole. + properties: + succeeded: + type: boolean + required: + - succeeded + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/samples/v1alpha1_postgresrole.yaml b/config/samples/v1alpha1_postgresrole.yaml new file mode 100644 index 0000000..7d7ffdf --- /dev/null +++ b/config/samples/v1alpha1_postgresrole.yaml @@ -0,0 +1,9 @@ +apiVersion: managed-postgres-operator.hoppscale.com/v1alpha1 +kind: PostgresRole +metadata: + labels: + app.kubernetes.io/name: managed-postgres-operator + app.kubernetes.io/managed-by: kustomize + name: postgresrole-sample +spec: + # TODO(user): Add fields here diff --git a/internal/controller/postgresrole_controller.go b/internal/controller/postgresrole_controller.go new file mode 100644 index 0000000..46627b9 --- /dev/null +++ b/internal/controller/postgresrole_controller.go @@ -0,0 +1,220 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/go-logr/logr" + managedpostgresoperatorhoppscalecomv1alpha1 "github.com/hoppscale/managed-postgres-operator/api/v1alpha1" + "github.com/hoppscale/managed-postgres-operator/internal/postgresql" +) + +const PostgresRoleFinalizer = "postgresrole.managed-postgres-operator.hoppscale.com/finalizer" + +// PostgresRoleReconciler reconciles a PostgresRole object +type PostgresRoleReconciler struct { + client.Client + Scheme *runtime.Scheme + logging logr.Logger + + PGPools *postgresql.PGPools + + CacheRolePasswords map[string]string +} + +// +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresroles,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresroles/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresroles/finalizers,verbs=update +func (r *PostgresRoleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.logging = log.FromContext(ctx) + + ctrlSuccessResult := ctrl.Result{RequeueAfter: time.Minute} + ctrlFailResult := ctrl.Result{RequeueAfter: time.Second} + + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + + if err := r.Client.Get(ctx, req.NamespacedName, resource); err != nil { + return ctrlFailResult, client.IgnoreNotFound(err) + } + + rolePassword := "" + + 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) + } + + rolePassword = string(resourceSecret.Data["password"]) + } + + desiredRole := postgresql.Role{ + Name: resource.Spec.Name, + SuperUser: resource.Spec.SuperUser, + Inherit: resource.Spec.Inherit, + CreateRole: resource.Spec.CreateRole, + CreateDB: resource.Spec.CreateDB, + Login: resource.Spec.Login, + Replication: resource.Spec.Replication, + BypassRLS: resource.Spec.BypassRLS, + Password: rolePassword, + } + + existingRole, err := postgresql.GetRole(r.PGPools.Default, resource.Spec.Name) + if err != nil { + return ctrlFailResult, fmt.Errorf("failed to get role: %s", err) + } + + // + // Deletion logic + // + + if resource.ObjectMeta.DeletionTimestamp.IsZero() { + if !controllerutil.ContainsFinalizer(resource, PostgresRoleFinalizer) { + controllerutil.AddFinalizer(resource, PostgresRoleFinalizer) + if err := r.Update(ctx, resource); err != nil { + return ctrlFailResult, err + } + } + } else { + // If there is no finalizer, delete the resource immediately + if !controllerutil.ContainsFinalizer(resource, PostgresRoleFinalizer) { + return ctrlSuccessResult, nil + } + + err = r.reconcileOnDeletion(existingRole) + if err != nil { + return ctrlFailResult, err + } + + // Remove our finalizer from the list and update it. + controllerutil.RemoveFinalizer(resource, PostgresRoleFinalizer) + if err := r.Update(ctx, resource); err != nil { + return ctrlFailResult, err + } + + // Stop reconciliation as the item is being deleted + return ctrlSuccessResult, nil + } + + // + // Creation logic + // + + err = r.reconcileOnCreation(existingRole, &desiredRole) + if err != nil { + return ctrlFailResult, err + } + + if !resource.Status.Succeeded { + resource.Status.Succeeded = true + if err = r.Client.Status().Update(context.Background(), resource); err != nil { + return ctrlFailResult, fmt.Errorf("failed to update object: %s", err) + } + } + + return ctrlSuccessResult, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PostgresRoleReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{}). + Named("postgresrole"). + Complete(r) +} + +// reconcileOnDeletion performs all actions related to deleting the resource +func (r *PostgresRoleReconciler) reconcileOnDeletion(existingRole *postgresql.Role) (err error) { + if existingRole == nil { + r.logging.Info("Role doesn't exist, skipping DROP ROLE") + return + } + + err = postgresql.DropRole(r.PGPools.Default, existingRole.Name) + if err != nil { + r.logging.Error(err, "failed to delete role") + return + } + + r.logging.Info("Role has been deleted") + + return +} + +// reconcileOnCreation performs all actions related to creating the resource +func (r *PostgresRoleReconciler) reconcileOnCreation(existingRole, desiredRole *postgresql.Role) (err error) { + if existingRole == nil { + err = postgresql.CreateRole(r.PGPools.Default, desiredRole) + if err != nil { + r.logging.Error(err, "failed to create role") + return err + } + r.logging.Info("Role has been created") + + r.CacheRolePasswords[desiredRole.Name] = desiredRole.Password + + return err + } + + needUpdate := false + + // Update the role if the desired role password is different than the one in cache + if desiredRole.Password != r.CacheRolePasswords[desiredRole.Name] { + needUpdate = true + r.logging.Info("Desired role's password and the cached password are different, an update is needed") + } + + copyDesiredRole := *desiredRole + copyDesiredRole.Password = "" + + // Update the role if the the existing role is different than the desired role + if *existingRole != copyDesiredRole { + needUpdate = true + r.logging.Info("Existing role and desired role are different, an update is needed") + } + + if needUpdate { + err = postgresql.AlterRole(r.PGPools.Default, desiredRole) + if err != nil { + r.logging.Error(err, "failed to alter role") + return err + } + r.logging.Info("Role has been updated") + + r.CacheRolePasswords[desiredRole.Name] = desiredRole.Password + } + + return err +} diff --git a/internal/controller/postgresrole_controller_test.go b/internal/controller/postgresrole_controller_test.go new file mode 100644 index 0000000..546be2d --- /dev/null +++ b/internal/controller/postgresrole_controller_test.go @@ -0,0 +1,457 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "regexp" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + pgxmock "github.com/pashagolub/pgxmock/v4" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + managedpostgresoperatorhoppscalecomv1alpha1 "github.com/hoppscale/managed-postgres-operator/api/v1alpha1" + "github.com/hoppscale/managed-postgres-operator/internal/postgresql" +) + +var _ = Describe("PostgresRole Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + postgresrole := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + + var pgpoolsMock map[string]pgxmock.PgxPoolIface + var pgpools *postgresql.PGPools + + BeforeEach(func() { + By("creating the custom resource for the Kind PostgresRole") + err := k8sClient.Get(ctx, typeNamespacedName, postgresrole) + if err != nil && errors.IsNotFound(err) { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: managedpostgresoperatorhoppscalecomv1alpha1.PostgresRoleSpec{ + Name: "foo", + CreateRole: true, + CreateDB: true, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + resourceSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "db-foo", + }, + Type: "Opaque", + Data: map[string][]byte{ + "password": []byte("password"), + }, + } + Expect(k8sClient.Create(ctx, resourceSecret)).To(Succeed()) + + mock, err := pgxmock.NewPool() + if err != nil { + Fail(err.Error()) + } + pgpoolsMock = map[string]pgxmock.PgxPoolIface{ + "default": mock, + } + pgpools = &postgresql.PGPools{ + Default: mock, + Databases: map[string]postgresql.PGPoolInterface{}, + } + } + }) + + AfterEach(func() { + // Delete PGPools + for _, pool := range pgpoolsMock { + pool.Close() + } + + // Delete Secret + resourceSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "db-foo", + }, + } + Expect(k8sClient.Delete(ctx, resourceSecret)).To(Succeed()) + + // Delete CustomResource + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err != nil && errors.IsNotFound(err) { + return + } + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance PostgresRole") + controllerutil.RemoveFinalizer(resource, PostgresRoleFinalizer) + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + When("the resource is created without password and no role exists", func() { + It("should reconcile the resource and create the role", func() { + 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$", 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 created but the role already exists", func() { + It("should not try to create role and match existing role", func() { + 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, + ), + ) + + _, 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 with a password and no role exists", func() { + It("should reconcile the resource and create the role", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.Spec.PasswordSecretName = "db-foo" + 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$", regexp.QuoteMeta(`CREATE ROLE "foo" WITH NOSUPERUSER NOINHERIT CREATEROLE CREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'password'`))). + 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 deleted", func() { + It("should successfully reconcile the resource on deletion", func() { + By("Reconciling the deleted resource") + + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRole{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + controllerutil.AddFinalizer(resource, PostgresRoleFinalizer) + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(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", + }). + 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()) + } + }) + }) + + When("reconciling on creation", func() { + It("should not create role if already exists", func() { + existingRole := &postgresql.Role{ + Name: "foo", + } + desiredRole := &postgresql.Role{ + Name: "foo", + } + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{}, + } + + err := controllerReconciler.reconcileOnCreation(existingRole, desiredRole) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + + It("should create role if it doesn't exist", func() { + var existingRole *postgresql.Role = nil + desiredRole := &postgresql.Role{ + Name: "foo", + } + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{}, + } + + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`CREATE ROLE "foo" WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := controllerReconciler.reconcileOnCreation(existingRole, desiredRole) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + + It("should update role if the desired role's password is different than the cached password", func() { + existingRole := &postgresql.Role{ + Name: "foo", + } + desiredRole := &postgresql.Role{ + Name: "foo", + Password: "fake", + } + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{ + "foo": "foo", + }, + } + + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`ALTER ROLE "foo" WITH NOSUPERUSER NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'fake'`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := controllerReconciler.reconcileOnCreation(existingRole, desiredRole) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + + It("should update role if the desired role is different than the existing role", func() { + existingRole := &postgresql.Role{ + Name: "foo", + } + desiredRole := &postgresql.Role{ + Name: "foo", + CreateDB: true, + } + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{}, + } + + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`ALTER ROLE "foo" WITH NOSUPERUSER NOINHERIT NOCREATEROLE CREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := controllerReconciler.reconcileOnCreation(existingRole, desiredRole) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + }) + + When("reconciling on deletion", func() { + It("should return immediately if no role exists", func() { + var existingRole *postgresql.Role + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{}, + } + + err := controllerReconciler.reconcileOnDeletion(existingRole) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + + It("should drop database successfully", func() { + existingRole := &postgresql.Role{ + Name: "foo", + } + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{}, + } + + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`DROP ROLE "foo"`))). + WillReturnResult(pgxmock.NewResult("", 1)) + + err := controllerReconciler.reconcileOnDeletion(existingRole) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + }) + }) +}) diff --git a/internal/postgresql/role.go b/internal/postgresql/role.go new file mode 100644 index 0000000..80ffb04 --- /dev/null +++ b/internal/postgresql/role.go @@ -0,0 +1,135 @@ +package postgresql + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +type Role struct { + Name string `db:"rolname"` + SuperUser bool `db:"rolsuper"` + Inherit bool `db:"rolinherit"` + CreateRole bool `db:"rolcreaterole"` + CreateDB bool `db:"rolcreatedb"` + Login bool `db:"rolcanlogin"` + Replication bool `db:"rolreplication"` + BypassRLS bool `db:"rolbypassrls"` + + Password string `db:"-"` +} + +const GetRoleSQLStatement = "SELECT rolname, rolsuper, rolinherit, rolcreaterole, rolcreatedb, rolcanlogin, rolreplication, rolbypassrls FROM pg_roles WHERE rolname = $1" + +func GetRole(pgpool PGPoolInterface, name string) (role *Role, err error) { + rows, err := pgpool.Query(context.Background(), GetRoleSQLStatement, name) + if err != nil { + err = fmt.Errorf("pg query failed: %s", err) + return + } + defer rows.Close() + + roles, err := pgx.CollectRows(rows, pgx.RowToStructByName[Role]) + if err != nil { + err = fmt.Errorf("failed to collect rows: %s", err) + return + } + + if len(roles) > 1 { + err = fmt.Errorf("wrong number of rows returned, expected 1, got %d", len(roles)) + return + } + + if len(roles) == 0 { + return + } + + role = &roles[0] + return +} + +func CreateRole(pgpool PGPoolInterface, role *Role) (err error) { + sanitizedName := pgx.Identifier{role.Name}.Sanitize() + options := generateRoleOptionsString(role) + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("CREATE ROLE %s %s", sanitizedName, options)) + if err != nil { + err = fmt.Errorf("pg exec failed: %s", err) + return + } + return +} + +func generateRoleOptionsString(role *Role) string { + s := "WITH " + + if role.SuperUser { + s += "SUPERUSER " + } else { + s += "NOSUPERUSER " + } + + if role.Inherit { + s += "INHERIT " + } else { + s += "NOINHERIT " + } + + if role.CreateRole { + s += "CREATEROLE " + } else { + s += "NOCREATEROLE " + } + + if role.CreateDB { + s += "CREATEDB " + } else { + s += "NOCREATEDB " + } + + if role.Login { + s += "LOGIN " + } else { + s += "NOLOGIN " + } + + if role.Replication { + s += "REPLICATION " + } else { + s += "NOREPLICATION " + } + + if role.BypassRLS { + s += "BYPASSRLS " + } else { + s += "NOBYPASSRLS " + } + + if role.Password != "" { + s += fmt.Sprintf("PASSWORD '%s' ", strings.Replace(role.Password, "'", "''", -1)) + } + + return s +} + +func DropRole(pgpool PGPoolInterface, name string) (err error) { + sanitizedName := pgx.Identifier{name}.Sanitize() + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("DROP ROLE %s", sanitizedName)) + if err != nil { + err = fmt.Errorf("pg exec failed: %s", err) + return + } + return +} + +func AlterRole(pgpool PGPoolInterface, role *Role) (err error) { + sanitizedName := pgx.Identifier{role.Name}.Sanitize() + options := generateRoleOptionsString(role) + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("ALTER ROLE %s %s", sanitizedName, options)) + if err != nil { + err = fmt.Errorf("pg exec failed: %s", err) + return + } + return +} diff --git a/internal/postgresql/role_test.go b/internal/postgresql/role_test.go new file mode 100644 index 0000000..75c5f2c --- /dev/null +++ b/internal/postgresql/role_test.go @@ -0,0 +1,311 @@ +package postgresql + +import ( + "fmt" + "regexp" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + pgxmock "github.com/pashagolub/pgxmock/v4" +) + +var _ = Describe("PostgreSQL Role", func() { + var pgpoolMock pgxmock.PgxPoolIface + var pgpool PGPoolInterface + + BeforeEach(func() { + mock, err := pgxmock.NewPool() + if err != nil { + Fail(err.Error()) + } + pgpoolMock = mock + pgpool = mock + }) + AfterEach(func() { + pgpoolMock.Close() + }) + + Context("Calling GetRole", func() { + It("should return the role if the role already exists", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolreplication", + "rolbypassrls", + }). + AddRow( + "foo", + false, + false, + true, + true, + false, + false, + false, + ), + ) + + role, err := GetRole(pgpool, "foo") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + + Expect(role.Name).To(Equal("foo")) + Expect(role.SuperUser).To(BeFalse()) + Expect(role.Inherit).To(BeFalse()) + Expect(role.CreateRole).To(BeTrue()) + Expect(role.CreateDB).To(BeTrue()) + Expect(role.Login).To(BeFalse()) + Expect(role.Replication).To(BeFalse()) + Expect(role.Password).To(Equal("")) + Expect(role.BypassRLS).To(BeFalse()) + }) + + It("should return an empty role if the role doesn't exist", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolreplication", + "rolbypassrls", + }), + ) + + role, err := GetRole(pgpool, "foo") + + Expect(role).To(BeNil()) + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + role, err := GetRole(pgpool, "foo") + + Expect(role).To(BeNil()) + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the PostgreSQL request returns more than one row", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolreplication", + "rolbypassrls", + }). + AddRow( + "foo", + false, + false, + true, + true, + false, + false, + false, + ). + AddRow( + "foo2", + false, + false, + true, + true, + false, + false, + false, + ), + ) + + role, err := GetRole(pgpool, "foo") + + Expect(role).To(BeNil()) + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the result do not match the model", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetRoleSQLStatement))). + WithArgs("foo"). + WillReturnRows( + pgxmock.NewRows([]string{ + "rolname", + "rolsuper", + "rolinherit", + "rolcreaterole", + "rolcreatedb", + "rolcanlogin", + "rolreplication", + "rolbypassrls", + "fake", + }). + AddRow( + "foo", + false, + false, + true, + true, + false, + false, + false, + "fake", + ). + AddRow( + "foo2", + false, + false, + true, + true, + false, + false, + false, + "fake", + ), + ) + + role, err := GetRole(pgpool, "foo") + + Expect(role).To(BeNil()) + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + Context("Calling CreateRole", func() { + It("should create a role with the defined options and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`CREATE ROLE "foo" WITH SUPERUSER NOINHERIT CREATEROLE NOCREATEDB NOLOGIN NOREPLICATION BYPASSRLS PASSWORD 'password'`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + role := Role{ + Name: "foo", + SuperUser: true, + Inherit: false, + CreateRole: true, + BypassRLS: true, + Password: "password", + } + err := CreateRole(pgpool, &role) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`CREATE ROLE "foo" WITH NOSUPERUSER INHERIT NOCREATEROLE CREATEDB LOGIN REPLICATION NOBYPASSRLS`))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + role := Role{ + Name: "foo", + Inherit: true, + CreateDB: true, + Login: true, + Replication: true, + } + err := CreateRole(pgpool, &role) + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + Context("Calling DropRole", func() { + It("should drop a role and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`DROP ROLE "foo"`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := DropRole(pgpool, "foo") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`DROP ROLE "foo"`))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := DropRole(pgpool, "foo") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + Context("Calling AlterRole", func() { + It("should update a role with the defined options and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`ALTER ROLE "foo" WITH SUPERUSER NOINHERIT CREATEROLE NOCREATEDB NOLOGIN NOREPLICATION BYPASSRLS`))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + role := Role{ + Name: "foo", + SuperUser: true, + Inherit: false, + CreateRole: true, + BypassRLS: true, + } + err := AlterRole(pgpool, &role) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + It("should return an error if the PostgreSQL request failed", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`ALTER ROLE "foo" WITH NOSUPERUSER INHERIT NOCREATEROLE CREATEDB LOGIN REPLICATION NOBYPASSRLS`))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + role := Role{ + Name: "foo", + Inherit: true, + CreateDB: true, + Login: true, + Replication: true, + } + err := AlterRole(pgpool, &role) + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) +})