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())
+ }
+ })
+ })
+
})