diff --git a/api/v1alpha1/postgresrole_types.go b/api/v1alpha1/postgresrole_types.go index 14de849..26e6d96 100644 --- a/api/v1alpha1/postgresrole_types.go +++ b/api/v1alpha1/postgresrole_types.go @@ -27,6 +27,11 @@ type PostgresRolePasswordFromSecret struct { Key string `json:"key"` } +// PostgresRoleOnDeleteSpec holds the options to change the operator's behavior when deleting a resource. +type PostgresRoleOnDeleteSpec struct { + ReassignOwnedTo string `json:"reassignOwnedTo,omitempty"` +} + // PostgresRoleSpec defines the desired state of PostgresRole. type PostgresRoleSpec struct { // PostgreSQL role name @@ -50,6 +55,8 @@ type PostgresRoleSpec struct { SecretTemplate map[string]string `json:"secretTemplate,omitempty"` MemberOfRoles []string `json:"memberOfRoles,omitempty"` + + OnDelete *PostgresRoleOnDeleteSpec `json:"onDelete,omitempty"` } // PostgresRoleStatus defines the observed state of PostgresRole. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 16154c1..669541d 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -199,6 +199,21 @@ func (in *PostgresRoleList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresRoleOnDeleteSpec) DeepCopyInto(out *PostgresRoleOnDeleteSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRoleOnDeleteSpec. +func (in *PostgresRoleOnDeleteSpec) DeepCopy() *PostgresRoleOnDeleteSpec { + if in == nil { + return nil + } + out := new(PostgresRoleOnDeleteSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresRolePasswordFromSecret) DeepCopyInto(out *PostgresRolePasswordFromSecret) { *out = *in @@ -234,6 +249,11 @@ func (in *PostgresRoleSpec) DeepCopyInto(out *PostgresRoleSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.OnDelete != nil { + in, out := &in.OnDelete, &out.OnDelete + *out = new(PostgresRoleOnDeleteSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRoleSpec. diff --git a/deploy/crds/managed-postgres-operator.hoppscale.com_postgresroles.yaml b/deploy/crds/managed-postgres-operator.hoppscale.com_postgresroles.yaml index 448dbd9..df61419 100644 --- a/deploy/crds/managed-postgres-operator.hoppscale.com_postgresroles.yaml +++ b/deploy/crds/managed-postgres-operator.hoppscale.com_postgresroles.yaml @@ -63,6 +63,13 @@ spec: x-kubernetes-validations: - message: name is immutable rule: self == oldSelf + onDelete: + description: PostgresRoleOnDeleteSpec holds the options to change + the operator's behavior when deleting a resource. + properties: + reassignOwnedTo: + type: string + type: object passwordFromSecret: properties: key: diff --git a/docs/docs/how_to_guides/usage/configure_role_postgresrole.md b/docs/docs/how_to_guides/usage/configure_role_postgresrole.md index 3768648..e93a99d 100644 --- a/docs/docs/how_to_guides/usage/configure_role_postgresrole.md +++ b/docs/docs/how_to_guides/usage/configure_role_postgresrole.md @@ -219,3 +219,20 @@ spec: ``` In this example, we assign our role `myrole` to the role `admin-role`. + +## Change the objects' ownership before deleting the role + +You can configure the resource to change the ownership on the objects that the role owns by setting the option `onDelete.reassignOwnedTo`. + +The value is the name of a role that must already exist in the PostgreSQL instance. + +```yaml +apiVersion: managed-postgres-operator.hoppscale.com/v1alpha1 +kind: PostgresRole +metadata: + name: myrole +spec: + name: myrole + onDelete: + reassignOwnedTo: myotherrole +``` diff --git a/docs/docs/reference/api/v1alpha1/index.md b/docs/docs/reference/api/v1alpha1/index.md index f1c09c3..5e9a157 100644 --- a/docs/docs/reference/api/v1alpha1/index.md +++ b/docs/docs/reference/api/v1alpha1/index.md @@ -61,7 +61,7 @@ This resource aims to implement most of the PostgreSQL role's parameters: [https | **`apiVersion`**
*string* | :material-check: | `managed-postgres-operator.hoppscale.com/v1alpha1` | | **`kind`**
*string* | :material-check: | `PostgresRole` | | **`metadata`**
*[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#objectmeta-v1-meta)* | :material-check: | Refer to Kubernetes API documentation for fields of metadata. | -| role **`spec`**
*[PostgresRoleSpec](#postgresrolespec)* | :material-check: | | +| **`spec`**
*[PostgresRoleSpec](#postgresrolespec)* | :material-check: | | | **`status`**
*[PostgresRoleStatus](#postgresrolestatus)* | :material-minus: | | ### PostgresRoleSpec @@ -83,6 +83,15 @@ PostgresRoleSpec holds the specification of a PostgreSQL role. | **`secretName`**
*string* | :material-close: | Name of the Secret the operator should create, containing the role's log in information.
*Default: `""`* | | **`secretTemplate`**
*map[string]string* | :material-close: | Dictionnary containing the key/value to configure in the Secret created by the operator (cf. `secretName`).
*Default: `{}`* | | **`memberOfRoles`**
*[]string* | :material-close: | List of role's names of which the role should be member of.
*Default: `[]`* | +| **`onDelete`**
*[PostgresRoleOnDeleteSpec](#postgresroleondeletespec)* | :material-close: | Options to change the operator's default behavior on resource deletion.
*Default: `nil`* | + +### PostgresRoleOnDeleteSpec + +PostgresRoleOnDeleteSpec holds the options to change the operator's behavior when deleting a resource. + +| Field | Required | Description | +|-------|----------|-------------| +| **`reassignOwnedTo`**
*string* | :material-close: | Reassign objects owned by the current role to another.
*Default: `""`* | ### PostgresRoleStatus diff --git a/internal/controller/postgresrole_controller.go b/internal/controller/postgresrole_controller.go index 5e051d3..3aed6d5 100644 --- a/internal/controller/postgresrole_controller.go +++ b/internal/controller/postgresrole_controller.go @@ -144,7 +144,7 @@ func (r *PostgresRoleReconciler) Reconcile(ctx context.Context, req ctrl.Request return r.Result(nil) } - err = r.reconcileOnDeletion(existingRole, resource.Spec.KeepOnDelete) + err = r.reconcileOnDeletion(existingRole, resource.Spec.KeepOnDelete, resource.Spec.OnDelete) if err != nil { return r.Result(err) } @@ -217,16 +217,38 @@ func (r *PostgresRoleReconciler) Result(err error) (ctrl.Result, error) { } // reconcileOnDeletion performs all actions related to deleting the resource -func (r *PostgresRoleReconciler) reconcileOnDeletion(existingRole *postgresql.Role, keepOnDelete bool) (err error) { +func (r *PostgresRoleReconciler) reconcileOnDeletion(existingRole *postgresql.Role, keepOnDelete bool, onDeleteOptions *managedpostgresoperatorhoppscalecomv1alpha1.PostgresRoleOnDeleteSpec) (err error) { if existingRole == nil { r.logging.Info("Role doesn't exist, skipping DROP ROLE") - return + return nil } if keepOnDelete { // If the resource is configured to keep the remote role on delete r.logging.Info("keepOnDelete is true, skipping DROP ROLE") - return + return nil + } + + if onDeleteOptions != nil { + if onDeleteOptions.ReassignOwnedTo != "" { + databases, err := postgresql.ListDatabases(r.PGPools.Default) + if err != nil { + return fmt.Errorf("failed to list databases: %s", err) + } + + for _, database := range databases { + err := postgresql.EnsurePGPoolExists(r.PGPools, database) + if err != nil { + return fmt.Errorf("failed to open pg pool: %s", err) + } + + err = postgresql.ReassignOwnedToRole(r.PGPools.Databases[database], existingRole.Name, onDeleteOptions.ReassignOwnedTo) + if err != nil { + return fmt.Errorf("failed to reassign owned objects in database before deletion: %s", err) + } + } + r.logging.Info(fmt.Sprintf("Objects owned by '%s' have been reassigned to '%s'", existingRole.Name, onDeleteOptions.ReassignOwnedTo)) + } } err = postgresql.DropRole(r.PGPools.Default, existingRole.Name) @@ -236,7 +258,7 @@ func (r *PostgresRoleReconciler) reconcileOnDeletion(existingRole *postgresql.Ro r.logging.Info("Role has been deleted") - return + return nil } // reconcileOnCreation performs all actions related to creating the resource diff --git a/internal/controller/postgresrole_controller_test.go b/internal/controller/postgresrole_controller_test.go index 272f973..1369cef 100644 --- a/internal/controller/postgresrole_controller_test.go +++ b/internal/controller/postgresrole_controller_test.go @@ -867,6 +867,7 @@ var _ = Describe("PostgresRole Controller", func() { When("no role exists", func() { It("should return immediately", func() { var existingRole *postgresql.Role + var onDeleteOptions *managedpostgresoperatorhoppscalecomv1alpha1.PostgresRoleOnDeleteSpec controllerReconciler := &PostgresRoleReconciler{ Client: k8sClient, @@ -875,7 +876,7 @@ var _ = Describe("PostgresRole Controller", func() { CacheRolePasswords: map[string]string{}, } - err := controllerReconciler.reconcileOnDeletion(existingRole, false) + err := controllerReconciler.reconcileOnDeletion(existingRole, false, onDeleteOptions) Expect(err).NotTo(HaveOccurred()) for _, poolMock := range pgpoolsMock { if err := poolMock.ExpectationsWereMet(); err != nil { @@ -884,6 +885,68 @@ var _ = Describe("PostgresRole Controller", func() { } }) }) + + When("reassignOwnedTo is configured", func() { + It("reassign owned objects before deletion", func() { + existingRole := &postgresql.Role{ + Name: "myrole", + } + onDeleteOptions := &managedpostgresoperatorhoppscalecomv1alpha1.PostgresRoleOnDeleteSpec{ + ReassignOwnedTo: "myrolebis", + } + + databases := []string{ + "postgres", + "foo", + } + + for _, database := range databases { + mock, err := pgxmock.NewPool() + if err != nil { + Fail(err.Error()) + } + pgpoolsMock[database] = mock + pgpools.Databases[database] = pgpoolsMock[database] + } + + pgpoolsMock["default"].ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta("SELECT datname FROM pg_database WHERE datistemplate = false"))). + WillReturnRows( + pgxmock.NewRows([]string{ + "datname", + }). + AddRow( + "postgres", + ). + AddRow( + "foo", + ), + ) + + for _, database := range databases { + pgpoolsMock[database].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`REASSIGN OWNED BY "myrole" TO "myrolebis"`))). + WillReturnResult(pgxmock.NewResult("REASSIGN OWNED", 1)) + } + + pgpoolsMock["default"].ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`DROP ROLE "myrole"`))). + WillReturnResult(pgxmock.NewResult("DROP ROLE", 1)) + + controllerReconciler := &PostgresRoleReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + PGPools: pgpools, + CacheRolePasswords: map[string]string{}, + } + + err := controllerReconciler.reconcileOnDeletion(existingRole, false, onDeleteOptions) + Expect(err).NotTo(HaveOccurred()) + for _, poolMock := range pgpoolsMock { + if err := poolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + } + }) + }) + }) }) }) diff --git a/internal/postgresql/database.go b/internal/postgresql/database.go index 981ce47..4f8e3f3 100644 --- a/internal/postgresql/database.go +++ b/internal/postgresql/database.go @@ -171,3 +171,20 @@ func RevokeDatabaseRolePrivilege(pgpool PGPoolInterface, database, role, privile return } + +func ListDatabases(pgpool PGPoolInterface) (databases []string, err error) { + rows, err := pgpool.Query(context.Background(), "SELECT datname FROM pg_database WHERE datistemplate = false") + if err != nil { + err = fmt.Errorf("pg query failed: %s", err) + return + } + defer rows.Close() + + databases, err = pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + err = fmt.Errorf("failed to collect rows: %s", err) + return + } + + return +} diff --git a/internal/postgresql/database_test.go b/internal/postgresql/database_test.go index e59d4ee..31cd389 100644 --- a/internal/postgresql/database_test.go +++ b/internal/postgresql/database_test.go @@ -481,4 +481,43 @@ var _ = Describe("PostgreSQL Database", func() { }) + Context("Calling ListDatabases", func() { + It("should return a list of databases without templates", func() { + pgpoolMock.ExpectQuery(fmt.Sprintf("^%s$", regexp.QuoteMeta(`SELECT datname FROM pg_database WHERE datistemplate = false`))). + WillReturnRows( + pgxmock.NewRows([]string{ + "datname", + }). + AddRow( + "foo", + ). + AddRow( + "postgres", + ), + ) + + databases, err := ListDatabases(pgpool) + + Expect(err).NotTo(HaveOccurred()) + Expect(databases).To(Equal([]string{"foo", "postgres"})) + 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(`SELECT datname FROM pg_database WHERE datistemplate = false`))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + databases, err := ListDatabases(pgpool) + + Expect(err).To(HaveOccurred()) + Expect(databases).To(BeEmpty()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + + }) + }) diff --git a/internal/postgresql/role.go b/internal/postgresql/role.go index 3e69a9b..f4fdb04 100644 --- a/internal/postgresql/role.go +++ b/internal/postgresql/role.go @@ -195,3 +195,15 @@ func AlterRole(pgpool PGPoolInterface, operatorRole, existingRole, desiredRole * } return } + +func ReassignOwnedToRole(pgpool PGPoolInterface, oldRole, newRole string) (err error) { + sanitizedOldRoleName := pgx.Identifier{oldRole}.Sanitize() + sanitizedNewRoleName := pgx.Identifier{newRole}.Sanitize() + + _, err = pgpool.Exec(context.Background(), fmt.Sprintf("REASSIGN OWNED BY %s TO %s", sanitizedOldRoleName, sanitizedNewRoleName)) + 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 index 093a4b0..132dc2c 100644 --- a/internal/postgresql/role_test.go +++ b/internal/postgresql/role_test.go @@ -422,4 +422,30 @@ var _ = Describe("PostgreSQL Role", func() { }) }) }) + + Context("Calling ReassignOwnedToRole", func() { + It("should reassign owned objects to a new role", func() { + pgpoolMock.ExpectExec(fmt.Sprintf("^%s$", regexp.QuoteMeta(`REASSIGN OWNED BY "foo" TO "bar"`))). + WillReturnResult(pgxmock.NewResult("bar", 1)) + + err := ReassignOwnedToRole(pgpool, "foo", "bar") + + 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(`REASSIGN OWNED BY "foo" TO "bar"`))). + WillReturnError(fmt.Errorf("fake error from PostgreSQL")) + + err := ReassignOwnedToRole(pgpool, "foo", "bar") + + Expect(err).To(HaveOccurred()) + if err := pgpoolMock.ExpectationsWereMet(); err != nil { + Fail(err.Error()) + } + }) + }) + })