From 2ba795f90cbf42709d3fad6c62cfbb67dab63809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Depriester?= Date: Sat, 8 Nov 2025 23:43:40 +0100 Subject: [PATCH] feat: add support of PostgreSQL schemas --- PROJECT | 8 + api/v1alpha1/postgresschema_types.go | 76 ++++ api/v1alpha1/zz_generated.deepcopy.go | 111 +++++ cmd/main.go | 9 + ...perator.hoppscale.com_postgresschemas.yaml | 89 ++++ config/crd/kustomization.yaml | 1 + config/rbac/kustomization.yaml | 3 + config/rbac/postgresschema_admin_role.yaml | 27 ++ config/rbac/postgresschema_editor_role.yaml | 33 ++ config/rbac/postgresschema_viewer_role.yaml | 29 ++ config/samples/kustomization.yaml | 1 + config/samples/v1alpha1_postgresschema.yaml | 9 + .../controller/postgresschema_controller.go | 262 +++++++++++ .../postgresschema_controller_test.go | 423 ++++++++++++++++++ internal/postgresql/schema.go | 132 ++++++ internal/postgresql/schema_test.go | 282 ++++++++++++ 16 files changed, 1495 insertions(+) create mode 100644 api/v1alpha1/postgresschema_types.go create mode 100644 config/crd/bases/managed-postgres-operator.hoppscale.com_postgresschemas.yaml create mode 100644 config/rbac/postgresschema_admin_role.yaml create mode 100644 config/rbac/postgresschema_editor_role.yaml create mode 100644 config/rbac/postgresschema_viewer_role.yaml create mode 100644 config/samples/v1alpha1_postgresschema.yaml create mode 100644 internal/controller/postgresschema_controller.go create mode 100644 internal/controller/postgresschema_controller_test.go create mode 100644 internal/postgresql/schema.go create mode 100644 internal/postgresql/schema_test.go diff --git a/PROJECT b/PROJECT index 1c60f6d..35a1f18 100644 --- a/PROJECT +++ b/PROJECT @@ -24,4 +24,12 @@ resources: kind: PostgresRole path: github.com/hoppscale/managed-postgres-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: managed-postgres-operator.hoppscale.com + kind: PostgresSchema + path: github.com/hoppscale/managed-postgres-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/postgresschema_types.go b/api/v1alpha1/postgresschema_types.go new file mode 100644 index 0000000..14d418d --- /dev/null +++ b/api/v1alpha1/postgresschema_types.go @@ -0,0 +1,76 @@ +/* +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" +) + +// PostgresSchemaPrivilegesSpec defines the desired schema privileges to grant to roles +type PostgresSchemaPrivilegesSpec struct { + Create bool `json:"create,omitempty"` + Usage bool `json:"usage,omitempty"` +} + +// PostgresSchemaSpec defines the desired state of a PostgreSQL schema +type PostgresSchemaSpec struct { + // Database is the PostgreSQL database's name in which the schema exists + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:message="database is immutable",rule="self == oldSelf" + Database string `json:"database"` + + // Name is the PostgreSQL schema's name + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:message="name is immutable",rule="self == oldSelf" + Name string `json:"name"` + + // Owner is the PostgreSQL schema's owner. It must be a valid existing role. + Owner string `json:"owner,omitempty"` + + // PrivilegesByRole will grant privileges to roles on this schema + PrivilegesByRole map[string]PostgresSchemaPrivilegesSpec `json:"privilegesByRole,omitempty"` +} + +// PostgresSchemaStatus defines the observed state of PostgresSchema. +type PostgresSchemaStatus struct { + Succeeded bool `json:"succeeded"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// PostgresSchema is the Schema for the postgresschemas API. +type PostgresSchema struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PostgresSchemaSpec `json:"spec,omitempty"` + Status PostgresSchemaStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// PostgresSchemaList contains a list of PostgresSchema. +type PostgresSchemaList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PostgresSchema `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PostgresSchema{}, &PostgresSchemaList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index af8c353..887cfa5 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -233,3 +233,114 @@ func (in *PostgresRoleStatus) DeepCopy() *PostgresRoleStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresSchema) DeepCopyInto(out *PostgresSchema) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSchema. +func (in *PostgresSchema) DeepCopy() *PostgresSchema { + if in == nil { + return nil + } + out := new(PostgresSchema) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresSchema) 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 *PostgresSchemaList) DeepCopyInto(out *PostgresSchemaList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PostgresSchema, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSchemaList. +func (in *PostgresSchemaList) DeepCopy() *PostgresSchemaList { + if in == nil { + return nil + } + out := new(PostgresSchemaList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresSchemaList) 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 *PostgresSchemaPrivilegesSpec) DeepCopyInto(out *PostgresSchemaPrivilegesSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSchemaPrivilegesSpec. +func (in *PostgresSchemaPrivilegesSpec) DeepCopy() *PostgresSchemaPrivilegesSpec { + if in == nil { + return nil + } + out := new(PostgresSchemaPrivilegesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresSchemaSpec) DeepCopyInto(out *PostgresSchemaSpec) { + *out = *in + if in.PrivilegesByRole != nil { + in, out := &in.PrivilegesByRole, &out.PrivilegesByRole + *out = make(map[string]PostgresSchemaPrivilegesSpec, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSchemaSpec. +func (in *PostgresSchemaSpec) DeepCopy() *PostgresSchemaSpec { + if in == nil { + return nil + } + out := new(PostgresSchemaSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresSchemaStatus) DeepCopyInto(out *PostgresSchemaStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSchemaStatus. +func (in *PostgresSchemaStatus) DeepCopy() *PostgresSchemaStatus { + if in == nil { + return nil + } + out := new(PostgresSchemaStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 6f54110..70549f7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -228,6 +228,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "PostgresRole") os.Exit(1) } + if err = (&controller.PostgresSchemaReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + PGPools: pgpools, + OperatorInstanceName: operatorInstanceName, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PostgresSchema") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresschemas.yaml b/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresschemas.yaml new file mode 100644 index 0000000..e8298ee --- /dev/null +++ b/config/crd/bases/managed-postgres-operator.hoppscale.com_postgresschemas.yaml @@ -0,0 +1,89 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: postgresschemas.managed-postgres-operator.hoppscale.com +spec: + group: managed-postgres-operator.hoppscale.com + names: + kind: PostgresSchema + listKind: PostgresSchemaList + plural: postgresschemas + singular: postgresschema + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: PostgresSchema is the Schema for the postgresschemas 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: PostgresSchemaSpec defines the desired state of a PostgreSQL + schema + properties: + database: + description: Database is the PostgreSQL database's name in which the + schema exists + type: string + x-kubernetes-validations: + - message: database is immutable + rule: self == oldSelf + name: + description: Name is the PostgreSQL schema's name + type: string + x-kubernetes-validations: + - message: name is immutable + rule: self == oldSelf + owner: + description: Owner is the PostgreSQL schema's owner. It must be a + valid existing role. + type: string + privilegesByRole: + additionalProperties: + description: PostgresSchemaPrivilegesSpec defines the desired schema + privileges to grant to roles + properties: + create: + type: boolean + usage: + type: boolean + type: object + description: PrivilegesByRole will grant privileges to roles on this + schema + type: object + required: + - database + - name + type: object + status: + description: PostgresSchemaStatus defines the observed state of PostgresSchema. + properties: + succeeded: + type: boolean + required: + - succeeded + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 5de0798..7f17eba 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -4,6 +4,7 @@ resources: - bases/managed-postgres-operator.hoppscale.com_postgresdatabases.yaml - bases/managed-postgres-operator.hoppscale.com_postgresroles.yaml +- bases/managed-postgres-operator.hoppscale.com_postgresschemas.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index e623cb0..c2d3d60 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -21,6 +21,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the {{ .ProjectName }} itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- postgresschema_admin_role.yaml +- postgresschema_editor_role.yaml +- postgresschema_viewer_role.yaml - postgresrole_admin_role.yaml - postgresrole_editor_role.yaml - postgresrole_viewer_role.yaml diff --git a/config/rbac/postgresschema_admin_role.yaml b/config/rbac/postgresschema_admin_role.yaml new file mode 100644 index 0000000..fad3102 --- /dev/null +++ b/config/rbac/postgresschema_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project managed-postgres-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over managed-postgres-operator.hoppscale.com. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: managed-postgres-operator + app.kubernetes.io/managed-by: kustomize + name: postgresschema-admin-role +rules: +- apiGroups: + - managed-postgres-operator.hoppscale.com + resources: + - postgresschemas + verbs: + - '*' +- apiGroups: + - managed-postgres-operator.hoppscale.com + resources: + - postgresschemas/status + verbs: + - get diff --git a/config/rbac/postgresschema_editor_role.yaml b/config/rbac/postgresschema_editor_role.yaml new file mode 100644 index 0000000..3454cd3 --- /dev/null +++ b/config/rbac/postgresschema_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project managed-postgres-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the managed-postgres-operator.hoppscale.com. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: managed-postgres-operator + app.kubernetes.io/managed-by: kustomize + name: postgresschema-editor-role +rules: +- apiGroups: + - managed-postgres-operator.hoppscale.com + resources: + - postgresschemas + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - managed-postgres-operator.hoppscale.com + resources: + - postgresschemas/status + verbs: + - get diff --git a/config/rbac/postgresschema_viewer_role.yaml b/config/rbac/postgresschema_viewer_role.yaml new file mode 100644 index 0000000..644292f --- /dev/null +++ b/config/rbac/postgresschema_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project managed-postgres-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to managed-postgres-operator.hoppscale.com resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: managed-postgres-operator + app.kubernetes.io/managed-by: kustomize + name: postgresschema-viewer-role +rules: +- apiGroups: + - managed-postgres-operator.hoppscale.com + resources: + - postgresschemas + verbs: + - get + - list + - watch +- apiGroups: + - managed-postgres-operator.hoppscale.com + resources: + - postgresschemas/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 6187928..772833a 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,4 +3,5 @@ resources: - v1alpha1_postgresdatabase.yaml - v1alpha1_postgresfunction.yaml - v1alpha1_postgresrole.yaml +- v1alpha1_postgresschema.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/v1alpha1_postgresschema.yaml b/config/samples/v1alpha1_postgresschema.yaml new file mode 100644 index 0000000..4240946 --- /dev/null +++ b/config/samples/v1alpha1_postgresschema.yaml @@ -0,0 +1,9 @@ +apiVersion: managed-postgres-operator.hoppscale.com/v1alpha1 +kind: PostgresSchema +metadata: + labels: + app.kubernetes.io/name: managed-postgres-operator + app.kubernetes.io/managed-by: kustomize + name: postgresschema-sample +spec: + # TODO(user): Add fields here diff --git a/internal/controller/postgresschema_controller.go b/internal/controller/postgresschema_controller.go new file mode 100644 index 0000000..b5db9ac --- /dev/null +++ b/internal/controller/postgresschema_controller.go @@ -0,0 +1,262 @@ +/* +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" + "slices" + "time" + + "k8s.io/apimachinery/pkg/runtime" + 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" + "github.com/hoppscale/managed-postgres-operator/internal/utils" +) + +const PostgresSchemaFinalizer = "postgresschema.managed-postgres-operator.hoppscale.com/finalizer" + +// PostgresSchemaReconciler reconciles a PostgresSchema object +type PostgresSchemaReconciler struct { + client.Client + Scheme *runtime.Scheme + logging logr.Logger + + PGPools *postgresql.PGPools + OperatorInstanceName string +} + +// +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresschemas,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresschemas/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=managed-postgres-operator.hoppscale.com,resources=postgresschemas/finalizers,verbs=update +func (r *PostgresSchemaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.logging = log.FromContext(ctx) + + ctrlSuccessResult := ctrl.Result{RequeueAfter: time.Minute} + ctrlFailResult := ctrl.Result{} + + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + + if err := r.Client.Get(ctx, req.NamespacedName, resource); err != nil { + 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 + } + + err := postgresql.EnsurePGPoolExists(r.PGPools, resource.Spec.Database) + if err != nil { + r.logging.Error(err, "failed to open pg pool") + return ctrlFailResult, err + } + + existingSchema, err := postgresql.GetSchema(r.PGPools.Databases[resource.Spec.Database], resource.Spec.Name) + if err != nil { + return ctrlFailResult, fmt.Errorf("failed to retrieve schema: %s", err) + } + + if existingSchema != nil { + existingSchema.Database = resource.Spec.Database + } + + desiredSchema := postgresql.Schema{ + Database: resource.Spec.Database, + Name: resource.Spec.Name, + Owner: resource.Spec.Owner, + } + + // + // Deletion logic + // + + if resource.ObjectMeta.DeletionTimestamp.IsZero() { + if !controllerutil.ContainsFinalizer(resource, PostgresSchemaFinalizer) { + controllerutil.AddFinalizer(resource, PostgresSchemaFinalizer) + 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, PostgresSchemaFinalizer) { + return ctrlSuccessResult, nil + } + + err = r.reconcileOnDeletion(existingSchema) + if err != nil { + return ctrlFailResult, err + } + + // Remove our finalizer from the list and update it. + controllerutil.RemoveFinalizer(resource, PostgresSchemaFinalizer) + 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(existingSchema, &desiredSchema) + 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) + } + } + + for roleName, rolePrivileges := range resource.Spec.PrivilegesByRole { + err = r.reconcilePrivileges( + desiredSchema.Database, + desiredSchema.Name, + roleName, + r.convertPrivilegesSpecToList(rolePrivileges), + ) + if err != nil { + return ctrlFailResult, err + } + } + + return ctrlSuccessResult, nil + +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PostgresSchemaReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{}). + Named("postgresschema"). + Complete(r) +} + +// reconcileOnDeletion performs all actions related to deleting the resource +func (r *PostgresSchemaReconciler) reconcileOnDeletion(schema *postgresql.Schema) (err error) { + if schema == nil { + // If the remote schema doesn't exists + r.logging.Info("Schema doesn't exist, skipping DROP SCHEMA") + return + } + + // Drop the schema + err = postgresql.DropSchema(r.PGPools.Databases[schema.Database], schema.Name) + if err != nil { + r.logging.Error(err, "failed to delete schema") + return + } + + r.logging.Info("Schema has been deleted") + + return +} + +// reconcileOnCreation performs all actions related to creating the resource +func (r *PostgresSchemaReconciler) reconcileOnCreation(existingSchema, desiredSchema *postgresql.Schema) (err error) { + alterOwner := false + + if existingSchema == nil { + err = postgresql.CreateSchema(r.PGPools.Databases[desiredSchema.Database], desiredSchema.Name) + if err != nil { + r.logging.Error(err, "failed to create schema") + return err + } + r.logging.Info("Schema has been created") + alterOwner = true + } else { + if existingSchema.Owner != desiredSchema.Owner { + alterOwner = true + } + } + + if alterOwner && desiredSchema.Owner != "" { + err = postgresql.AlterSchemaOwner( + r.PGPools.Databases[desiredSchema.Database], + desiredSchema.Name, + desiredSchema.Owner, + ) + if err != nil { + r.logging.Error(err, "failed to alter schema owner") + return err + } + r.logging.Info(fmt.Sprintf("Owner of the schema \"%s\" has been updated", desiredSchema.Name)) + } + + return err +} + +// reconcilePrivileges performs all actions related to the schema privileges for a single role +func (r *PostgresSchemaReconciler) reconcilePrivileges(databaseName, schemaName, roleName string, desiredPrivileges []string) (err error) { + // We retrieve the existing privileges + existingPrivileges, err := postgresql.GetSchemaRolePrivileges(r.PGPools.Databases[databaseName], schemaName, roleName) + if err != nil { + r.logging.Error(err, fmt.Sprintf("failed to retrieve privileges of schema \"%s\" in database \"%s\" on role \"%s\": %s", schemaName, databaseName, roleName, err)) + return err + } + + // We grant the missing privileges + for _, desiredPrivilege := range desiredPrivileges { + if !slices.Contains(existingPrivileges, desiredPrivilege) { + err := postgresql.GrantSchemaRolePrivilege(r.PGPools.Databases[databaseName], schemaName, roleName, desiredPrivilege) + if err != nil { + r.logging.Error(err, fmt.Sprintf("failed to grant \"%s\" privilege on schema \"%s\" in database \"%s\" to role \"%s\"", desiredPrivilege, schemaName, databaseName, roleName)) + return err + } + + r.logging.Info(fmt.Sprintf("Privilege \"%s\" has been granted to \"%s\" on schema \"%s\" in database \"%s\"", desiredPrivilege, roleName, schemaName, databaseName)) + } + } + + // We revoke the non-declared privileges + for _, existingPrivilege := range existingPrivileges { + if !slices.Contains(desiredPrivileges, existingPrivilege) { + err := postgresql.RevokeSchemaRolePrivilege(r.PGPools.Databases[databaseName], schemaName, roleName, existingPrivilege) + if err != nil { + r.logging.Error(err, fmt.Sprintf("failed to revoke \"%s\" privilege on schema \"%s\" in database \"%s\" to role \"%s\"", existingPrivilege, schemaName, databaseName, roleName)) + return err + } + + r.logging.Info(fmt.Sprintf("Privilege \"%s\" has been revoked from \"%s\" on schema \"%s\" in database \"%s\"", existingPrivilege, roleName, schemaName, databaseName)) + } + } + return err +} + +func (r *PostgresSchemaReconciler) convertPrivilegesSpecToList(privilegesSpec managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchemaPrivilegesSpec) []string { + privileges := []string{} + if privilegesSpec.Create { + privileges = append(privileges, "CREATE") + } + if privilegesSpec.Usage { + privileges = append(privileges, "USAGE") + } + return privileges +} diff --git a/internal/controller/postgresschema_controller_test.go b/internal/controller/postgresschema_controller_test.go new file mode 100644 index 0000000..f237e17 --- /dev/null +++ b/internal/controller/postgresschema_controller_test.go @@ -0,0 +1,423 @@ +/* +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" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + 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("PostgresSchema Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + postgresschema := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + + var pgpoolsMock map[string]pgxmock.PgxPoolIface + var pgpools *postgresql.PGPools + + BeforeEach(func() { + By("creating the custom resource for the Kind PostgresSchema") + err := k8sClient.Get(ctx, typeNamespacedName, postgresschema) + if err != nil && errors.IsNotFound(err) { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchemaSpec{ + Database: "mydb", + Name: "myschema", + Owner: "myrole", + PrivilegesByRole: map[string]managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchemaPrivilegesSpec{ + "fakerole": { + Create: false, + Usage: true, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + + mock, err := pgxmock.NewPool() + if err != nil { + Fail(err.Error()) + } + pgpoolsMock = map[string]pgxmock.PgxPoolIface{ + "default": mock, + "mydb": mock, + } + pgpools = &postgresql.PGPools{ + Default: mock, + Databases: map[string]postgresql.PGPoolInterface{ + "mydb": mock, + }, + } + }) + + AfterEach(func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err != nil && errors.IsNotFound(err) { + return + } + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance PostgresSchema") + controllerutil.RemoveFinalizer(resource, PostgresSchemaFinalizer) + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + for _, pool := range pgpoolsMock { + pool.Close() + } + }) + + When("the resource is managed by the operator's instance", func() { + It("should continue to reconcile the resource and create the schema", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + 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 := &PostgresSchemaReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }), + ) + pgpoolsMock["mydb"].ExpectExec(`CREATE SCHEMA "myschema"`). + WillReturnResult(pgxmock.NewResult("", 1)) + pgpoolsMock["mydb"].ExpectExec(`ALTER SCHEMA "myschema" OWNER TO "myrole"`). + WillReturnResult(pgxmock.NewResult("", 1)) + for _, privilege := range postgresql.ListSchemaAvailablePrivileges() { + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta("SELECT has_schema_privilege($1, $2, $3)"))). + WithArgs( + "fakerole", + "myschema", + privilege, + ). + WillReturnRows( + pgxmock.NewRows([]string{ + "has_schema_privilege", + }). + AddRow( + false, + ), + ) + } + pgpoolsMock["mydb"].ExpectExec(`GRANT USAGE ON SCHEMA "myschema" TO "fakerole"`). + WillReturnResult(pgxmock.NewResult("", 1)) + + _, 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.PostgresSchema{} + 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 := &PostgresSchemaReconciler{ + 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 schema is owned by another role", func() { + It("should change the owner of the schema", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + 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 := &PostgresSchemaReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }). + AddRow( + "myschema", + "anotherrole", + ), + ) + pgpoolsMock["mydb"].ExpectExec(`ALTER SCHEMA "myschema" OWNER TO "myrole"`). + WillReturnResult(pgxmock.NewResult("", 1)) + // Loop over all privileges + existingPrivileges := map[string]bool{ + "CREATE": false, + "USAGE": true, + } + for _, privilege := range postgresql.ListSchemaAvailablePrivileges() { + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta("SELECT has_schema_privilege($1, $2, $3)"))). + WithArgs( + "fakerole", + "myschema", + privilege, + ). + WillReturnRows( + pgxmock.NewRows([]string{ + "has_schema_privilege", + }). + AddRow( + existingPrivileges[privilege], + ), + ) + } + + _, 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("role privileges are updated", func() { + It("should grant missing privileges and revoke the others", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "foo", + } + resource.Spec.PrivilegesByRole = map[string]managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchemaPrivilegesSpec{ + "fakerole": { + Create: true, + Usage: false, + }, + } + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresSchemaReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }). + AddRow( + "myschema", + "myrole", + ), + ) + // Loop over all privileges + existingPrivileges := map[string]bool{ + "CREATE": false, + "USAGE": true, + } + + for _, privilege := range postgresql.ListSchemaAvailablePrivileges() { + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta("SELECT has_schema_privilege($1, $2, $3)"))). + WithArgs( + "fakerole", + "myschema", + privilege, + ). + WillReturnRows( + pgxmock.NewRows([]string{ + "has_schema_privilege", + }). + AddRow( + existingPrivileges[privilege], + ), + ) + } + pgpoolsMock["mydb"].ExpectExec(`GRANT CREATE ON SCHEMA "myschema" TO "fakerole"`). + WillReturnResult(pgxmock.NewResult("", 1)) + pgpoolsMock["mydb"].ExpectExec(`REVOKE USAGE ON SCHEMA "myschema" FROM "fakerole"`). + WillReturnResult(pgxmock.NewResult("", 1)) + + _, 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 deleted", func() { + It("should successfully reconcile the resource on deletion", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "foo", + } + controllerutil.AddFinalizer(resource, PostgresSchemaFinalizer) + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresSchemaReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }). + AddRow( + "myschema", + "myrole", + ), + ) + + pgpoolsMock["mydb"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`DROP SCHEMA "myschema"`))). + WillReturnResult(pgxmock.NewResult("", 1)) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolsMock["mydb"].ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("the resource is deleted but the schema doesn't exist", func() { + It("should delete the resource but skip the DROP SCHEMA", func() { + resource := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresSchema{} + Expect(k8sClient.Get(ctx, typeNamespacedName, resource)).To(Succeed()) + resource.ObjectMeta.Annotations = map[string]string{ + utils.OperatorInstanceAnnotationName: "foo", + } + controllerutil.AddFinalizer(resource, PostgresSchemaFinalizer) + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + controllerReconciler := &PostgresSchemaReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + OperatorInstanceName: "foo", + } + + pgpoolsMock["mydb"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(postgresql.GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }), + ) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolsMock["mydb"].ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + }) +}) diff --git a/internal/postgresql/schema.go b/internal/postgresql/schema.go new file mode 100644 index 0000000..123930b --- /dev/null +++ b/internal/postgresql/schema.go @@ -0,0 +1,132 @@ +package postgresql + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type Schema struct { + Database string `db:"-"` + Name string `db:"name"` + Owner string `db:"owner"` +} + +const GetSchemaSQLStatement = "SELECT schema_name as name, schema_owner as owner FROM information_schema.schemata WHERE schema_name = $1" + +func GetSchema(pgpool PGPoolInterface, name string) (schema *Schema, err error) { + rows, err := pgpool.Query(context.Background(), GetSchemaSQLStatement, name) + if err != nil { + err = fmt.Errorf("pg query failed: %s", err) + return + } + defer rows.Close() + + schemas, err := pgx.CollectRows(rows, pgx.RowToStructByName[Schema]) + if err != nil { + err = fmt.Errorf("failed to collect rows: %s", err) + return + } + + if len(schemas) > 1 { + err = fmt.Errorf("wrong number of rows returned, expected 1, got %d", len(schemas)) + return + } + + if len(schemas) == 0 { + return + } + + schema = &schemas[0] + + return schema, err +} + +func CreateSchema(pgpool PGPoolInterface, name string) (err error) { + sanitizedName := pgx.Identifier{name}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("CREATE SCHEMA %s", sanitizedName)) + if err != nil { + return fmt.Errorf("failed to create schema: %s", err) + } + + return err +} + +func DropSchema(pgpool PGPoolInterface, name string) (err error) { + sanitizedName := pgx.Identifier{name}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("DROP SCHEMA %s", sanitizedName)) + if err != nil { + return fmt.Errorf("failed to drop schema: %s", err) + } + + return err +} + +func AlterSchemaOwner(pgpool PGPoolInterface, schema, owner string) (err error) { + sanitizedSchemaName := pgx.Identifier{schema}.Sanitize() + sanitizedOwnerName := pgx.Identifier{owner}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("ALTER SCHEMA %s OWNER TO %s", sanitizedSchemaName, sanitizedOwnerName)) + if err != nil { + return fmt.Errorf("failed to alter schema owner: %s", err) + } + return err +} + +func ListSchemaAvailablePrivileges() []string { + return []string{ + "CREATE", + "USAGE", + } +} + +func GetSchemaRolePrivileges(pgpool PGPoolInterface, schema, role string) (existingPrivileges []string, err error) { + existingPrivileges = []string{} + var hasPrivilege bool + for _, privilege := range ListSchemaAvailablePrivileges() { + rows, err := pgpool.Query(context.Background(), "SELECT has_schema_privilege($1, $2, $3)", role, schema, privilege) + if err != nil { + err = fmt.Errorf("pg query failed: %s", err) + return []string{}, err + } + defer rows.Close() + + hasPrivilege, err = pgx.CollectOneRow(rows, pgx.RowTo[bool]) + if err != nil { + err = fmt.Errorf("failed to collect rows: %s", err) + return []string{}, err + } + + if hasPrivilege { + existingPrivileges = append(existingPrivileges, privilege) + } + } + return +} + +func GrantSchemaRolePrivilege(pgpool PGPoolInterface, schema, role, privilege string) (err error) { + sanitizedSchema := pgx.Identifier{schema}.Sanitize() + sanitizedRole := pgx.Identifier{role}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("GRANT %s ON SCHEMA %s TO %s", privilege, sanitizedSchema, sanitizedRole)) + if err != nil { + return fmt.Errorf("failed to grant privilege \"%s\" on schema %s to role %s: %s", privilege, sanitizedSchema, sanitizedRole, err) + } + + return +} + +func RevokeSchemaRolePrivilege(pgpool PGPoolInterface, schema, role, privilege string) (err error) { + sanitizedSchema := pgx.Identifier{schema}.Sanitize() + sanitizedRole := pgx.Identifier{role}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("REVOKE %s ON SCHEMA %s FROM %s", privilege, sanitizedSchema, sanitizedRole)) + if err != nil { + return fmt.Errorf("failed to revoke privilege \"%s\" on schema %s from role %s: %s", privilege, sanitizedSchema, sanitizedRole, err) + } + + return +} diff --git a/internal/postgresql/schema_test.go b/internal/postgresql/schema_test.go new file mode 100644 index 0000000..a7a5bf8 --- /dev/null +++ b/internal/postgresql/schema_test.go @@ -0,0 +1,282 @@ +package postgresql + +import ( + "fmt" + "regexp" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + pgxmock "github.com/pashagolub/pgxmock/v4" +) + +var _ = Describe("PostgreSQL Schema", 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 GetSchema", func() { + When("the schema already exists", func() { + It("should return a Schema pre-filled struct if the schema already exists", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }). + AddRow( + "myschema", + "myrole", + ), + ) + + schema, err := GetSchema(pgpool, "myschema") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + + Expect(schema.Name).To(Equal("myschema")) + Expect(schema.Owner).To(Equal("myrole")) + }) + }) + When("the schema doesn't exist", func() { + It("should return a nil Schema and no error", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(GetSchemaSQLStatement))). + WithArgs("myschema"). + WillReturnRows( + pgxmock.NewRows([]string{ + "name", + "owner", + }), + ) + + schema, err := GetSchema(pgpool, "myschema") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + + Expect(schema).To(BeNil()) + }) + }) + }) + + Context("Calling CreateSchema", func() { + When("the schema doesn't exist", func() { + It("should create the schema in database and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("CREATE SCHEMA \"myschema\""))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := CreateSchema(pgpool, "myschema") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("PostgreSQL returns an error", func() { + It("should return an error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("CREATE SCHEMA \"myschema\""))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := CreateSchema(pgpool, "myschema") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + }) + + Context("Calling DropSchema", func() { + When("the schema exists", func() { + It("should drop the schema from database and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("DROP SCHEMA \"myschema\""))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := DropSchema(pgpool, "myschema") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("PostgreSQL returns an error", func() { + It("should return an error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("DROP SCHEMA \"myschema\""))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := DropSchema(pgpool, "myschema") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + }) + + Context("Calling AlterSchemaOwner", func() { + When("the schema exists and the role exists", func() { + It("should successfully alter owner of the schema and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("ALTER SCHEMA \"myschema\" OWNER TO \"myrole\""))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := AlterSchemaOwner(pgpool, "myschema", "myrole") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("PostgreSQL returns an error", func() { + It("should return an error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("ALTER SCHEMA \"myschema\" OWNER TO \"myrole\""))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := AlterSchemaOwner(pgpool, "myschema", "myrole") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + }) + + Context("Calling GetSchemaRolePrivileges", func() { + When("the schema and role exist", func() { + It("should retrieve all privileges associated to the role and return no error", func() { + // Loop over all privileges + existingPrivileges := map[string]bool{ + "CREATE": false, + "USAGE": true, + } + for _, privilege := range ListSchemaAvailablePrivileges() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta("SELECT has_schema_privilege($1, $2, $3)"))). + WithArgs( + "myrole", + "myschema", + privilege, + ). + WillReturnRows( + pgxmock.NewRows([]string{ + "has_schema_privilege", + }). + AddRow( + existingPrivileges[privilege], + ), + ) + } + + privs, err := GetSchemaRolePrivileges(pgpool, "myschema", "myrole") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + + Expect(privs).To(Equal([]string{"USAGE"})) + }) + }) + + When("PostgreSQL returns an error", func() { + It("should return an error and an empty list of privileges", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(`SELECT has_schema_privilege($1, $2, $3)`))). + WithArgs("myrole", "mydb", "CREATE"). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + privs, err := GetSchemaRolePrivileges(pgpool, "mydb", "myrole") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + + Expect(privs).To(Equal([]string{})) + }) + }) + }) + + Context("Calling GrantSchemaRolePrivilege", func() { + When("the schema and role exist", func() { + It("should grant privilege to the role and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("GRANT CREATE ON SCHEMA \"myschema\" TO \"myrole\""))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := GrantSchemaRolePrivilege(pgpool, "myschema", "myrole", "CREATE") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("PostgreSQL returns an error", func() { + It("should return an error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("GRANT CREATE ON SCHEMA \"myschema\" TO \"myrole\""))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := GrantSchemaRolePrivilege(pgpool, "myschema", "myrole", "CREATE") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + }) + + Context("Calling RevokeSchemaRolePrivilege", func() { + When("the schema and role exist", func() { + It("should revoke privilege to the role and return no error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("REVOKE CREATE ON SCHEMA \"myschema\" FROM \"myrole\""))). + WillReturnResult(pgxmock.NewResult("foo", 1)) + + err := RevokeSchemaRolePrivilege(pgpool, "myschema", "myrole", "CREATE") + + Expect(err).NotTo(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + + When("PostgreSQL returns an error", func() { + It("should return an error", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta("REVOKE CREATE ON SCHEMA \"myschema\" FROM \"myrole\""))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := RevokeSchemaRolePrivilege(pgpool, "myschema", "myrole", "CREATE") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + }) +})