diff --git a/api/v1alpha1/postgresuser_types.go b/api/v1alpha1/postgresuser_types.go index a2d49d1a..3d98bf62 100644 --- a/api/v1alpha1/postgresuser_types.go +++ b/api/v1alpha1/postgresuser_types.go @@ -26,6 +26,8 @@ type PostgresUserSpec struct { Annotations map[string]string `json:"annotations,omitempty"` // +optional Labels map[string]string `json:"labels,omitempty"` + // +optional + Replication bool `json:"replication,omitempty"` } // PostgresUserAWSSpec encapsulates AWS specific configuration toggles. @@ -46,6 +48,9 @@ type PostgresUserStatus struct { // Reflects whether IAM authentication is enabled for this user. // +optional EnableIamAuth bool `json:"enableIamAuth"` + // Grants the REPLICATION attribute, or rds_replication on AWS RDS. + // +optional + Replication bool `json:"replication,omitempty"` } // +kubebuilder:object:root=true diff --git a/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml b/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml index 07a5304c..99c90e30 100644 --- a/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml +++ b/charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml @@ -1,6 +1,9 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 name: postgresusers.db.movetokube.com spec: group: db.movetokube.com @@ -17,14 +20,19 @@ spec: description: PostgresUser is the Schema for the postgresusers 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' + 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' + 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 @@ -36,15 +44,18 @@ spec: type: string type: object aws: - description: AWS specific settings for the user + description: PostgresUserAWSSpec encapsulates AWS specific configuration + toggles. properties: enableIamAuth: - description: Enable IAM authentication for this user (PostgreSQL on AWS RDS only) default: false + description: Enable IAM authentication for this user (PostgreSQL + on AWS RDS only) type: boolean type: object database: - description: Name of the PostgresDatabase this user will be related to + description: Name of the PostgresDatabase this user will be related + to type: string labels: additionalProperties: @@ -53,8 +64,11 @@ spec: privileges: description: List of privileges to grant to this user type: string + replication: + type: boolean role: - description: Name of the PostgresRole this user will be associated with + description: Name of the PostgresRole this user will be associated + with type: string secretName: description: Name of the secret to create with user credentials @@ -71,17 +85,20 @@ spec: status: description: PostgresUserStatus defines the observed state of PostgresUser properties: - enableIamAuth: - description: Reflects whether IAM authentication is enabled for this user. - type: boolean databaseName: type: string + enableIamAuth: + description: Reflects whether IAM authentication is enabled for this + user. + type: boolean postgresGroup: type: string postgresLogin: type: string postgresRole: type: string + replication: + type: boolean succeeded: type: boolean required: diff --git a/config/crd/bases/db.movetokube.com_postgresusers.yaml b/config/crd/bases/db.movetokube.com_postgresusers.yaml index 14b8d6e0..99c90e30 100644 --- a/config/crd/bases/db.movetokube.com_postgresusers.yaml +++ b/config/crd/bases/db.movetokube.com_postgresusers.yaml @@ -64,6 +64,8 @@ spec: privileges: description: List of privileges to grant to this user type: string + replication: + type: boolean role: description: Name of the PostgresRole this user will be associated with @@ -95,6 +97,8 @@ spec: type: string postgresRole: type: string + replication: + type: boolean succeeded: type: boolean required: diff --git a/internal/controller/postgresuser_controller.go b/internal/controller/postgresuser_controller.go index aeb0dc8b..a1f86576 100644 --- a/internal/controller/postgresuser_controller.go +++ b/internal/controller/postgresuser_controller.go @@ -203,6 +203,17 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request reqLogger.WithValues("role", role).Info("IAM Auth requested while we are not running with AWS cloud provider config") } + if instance.Spec.Replication != instance.Status.Replication { + if err := r.pg.SetReplication(role, instance.Spec.Replication); err != nil { + reqLogger.WithValues("role", role).Error(err, "failed to set replication") + return r.requeue(ctx, instance, err) + } + instance.Status.Replication = instance.Spec.Replication + if sErr := r.Status().Update(ctx, instance); sErr != nil { + return r.requeue(ctx, instance, sErr) + } + } + // Reconcile logic for changes in group membership // This is only applicable if user role is already created // and privileges are changed in spec diff --git a/internal/controller/postgresuser_controller_test.go b/internal/controller/postgresuser_controller_test.go index 87072f6e..8847e215 100644 --- a/internal/controller/postgresuser_controller_test.go +++ b/internal/controller/postgresuser_controller_test.go @@ -662,6 +662,124 @@ var _ = Describe("PostgresUser Controller", func() { Expect(foundUser.Status.EnableIamAuth).To(BeFalse()) }) }) + Context("Replication", func() { + var ( + postgresDB *dbv1alpha1.Postgres + postgresUser *dbv1alpha1.PostgresUser + ) + + BeforeEach(func() { + postgresDB = &dbv1alpha1.Postgres{ + ObjectMeta: metav1.ObjectMeta{ + Name: databaseName, + Namespace: namespace, + }, + Spec: dbv1alpha1.PostgresSpec{Database: databaseName}, + Status: dbv1alpha1.PostgresStatus{ + Succeeded: true, + Roles: dbv1alpha1.PostgresRoles{ + Owner: databaseName + "-group", + Reader: databaseName + "-reader", + Writer: databaseName + "-writer", + }, + }, + } + + postgresUser = &dbv1alpha1.PostgresUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: dbv1alpha1.PostgresUserSpec{ + Database: databaseName, + SecretName: secretName, + Role: roleName, + Privileges: "WRITE", + }, + } + }) + + AfterEach(func() { + secretList := &corev1.SecretList{} + Expect(cl.List(ctx, secretList, client.InNamespace(namespace))).To(Succeed()) + for _, secret := range secretList.Items { + Expect(cl.Delete(ctx, &secret)).To(Succeed()) + } + }) + + It("enables replication when spec is true and status is false", func() { + user := postgresUser.DeepCopy() + user.Spec.Replication = true + user.Status = dbv1alpha1.PostgresUserStatus{ + Succeeded: true, + PostgresGroup: databaseName + "-writer", + PostgresRole: roleName + "-exists", + DatabaseName: databaseName, + PostgresLogin: "login", + } + initClient(postgresDB, user, false) + + pg.EXPECT().SetReplication(roleName+"-exists", true).Return(nil) + pg.EXPECT().UpdatePassword(gomock.Any(), gomock.Any()).Return(nil) + + err := runReconcile(rp, ctx, req) + Expect(err).NotTo(HaveOccurred()) + + foundUser := &dbv1alpha1.PostgresUser{} + err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) + Expect(err).NotTo(HaveOccurred()) + Expect(foundUser.Status.Replication).To(BeTrue()) + }) + + It("disables replication when spec is false and status is true", func() { + user := postgresUser.DeepCopy() + user.Spec.Replication = false + user.Status = dbv1alpha1.PostgresUserStatus{ + Succeeded: true, + PostgresGroup: databaseName + "-writer", + PostgresRole: roleName + "-exists", + DatabaseName: databaseName, + PostgresLogin: "login", + Replication: true, + } + initClient(postgresDB, user, false) + + pg.EXPECT().SetReplication(roleName+"-exists", false).Return(nil) + pg.EXPECT().UpdatePassword(gomock.Any(), gomock.Any()).Return(nil) + + err := runReconcile(rp, ctx, req) + Expect(err).NotTo(HaveOccurred()) + + foundUser := &dbv1alpha1.PostgresUser{} + err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) + Expect(err).NotTo(HaveOccurred()) + Expect(foundUser.Status.Replication).To(BeFalse()) + }) + + It("requeues on SetReplication error", func() { + user := postgresUser.DeepCopy() + user.Spec.Replication = true + user.Status = dbv1alpha1.PostgresUserStatus{ + Succeeded: true, + PostgresGroup: databaseName + "-writer", + PostgresRole: roleName + "-exists", + DatabaseName: databaseName, + PostgresLogin: "login", + } + initClient(postgresDB, user, false) + + pg.EXPECT().SetReplication(roleName+"-exists", true).Return(fmt.Errorf("replication failed")) + + err := runReconcile(rp, ctx, req) + Expect(err).To(HaveOccurred()) + + foundUser := &dbv1alpha1.PostgresUser{} + err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) + Expect(err).NotTo(HaveOccurred()) + Expect(foundUser.Status.Succeeded).To(BeFalse()) + Expect(foundUser.Status.Replication).To(BeFalse()) + }) + }) Context("Secret creation with user-defined labels and annotations", func() { It("should create a secret with user-defined labels and annotations", func() { // Set up the reconciler with host and keepSecretName setting diff --git a/pkg/postgres/aws.go b/pkg/postgres/aws.go index a27167a4..f082a66a 100644 --- a/pkg/postgres/aws.go +++ b/pkg/postgres/aws.go @@ -53,6 +53,13 @@ func (c *awspg) CreateUserRole(role, password string) (string, error) { return returnedRole, nil } +func (c *awspg) SetReplication(role string, enable bool) error { + if enable { + return c.GrantRole("rds_replication", role) + } + return c.RevokeRole("rds_replication", role) +} + func (c *awspg) DropRole(role, newOwner, database string) error { // On AWS RDS the postgres user isn't really superuser so he doesn't have permissions // to REASSIGN OWNED BY unless he belongs to both roles diff --git a/pkg/postgres/mock/postgres.go b/pkg/postgres/mock/postgres.go index 23cfdba8..3e04d550 100644 --- a/pkg/postgres/mock/postgres.go +++ b/pkg/postgres/mock/postgres.go @@ -251,6 +251,20 @@ func (mr *MockPGMockRecorder) RevokeRole(role, revoked any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeRole", reflect.TypeOf((*MockPG)(nil).RevokeRole), role, revoked) } +// SetReplication mocks base method. +func (m *MockPG) SetReplication(role string, enable bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetReplication", role, enable) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetReplication indicates an expected call of SetReplication. +func (mr *MockPGMockRecorder) SetReplication(role, enable any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetReplication", reflect.TypeOf((*MockPG)(nil).SetReplication), role, enable) +} + // SetSchemaPrivileges mocks base method. func (m *MockPG) SetSchemaPrivileges(schemaPrivileges postgres.PostgresSchemaPrivileges) error { m.ctrl.T.Helper() diff --git a/pkg/postgres/postgres.go b/pkg/postgres/postgres.go index dd5886a5..76989748 100644 --- a/pkg/postgres/postgres.go +++ b/pkg/postgres/postgres.go @@ -24,6 +24,7 @@ type PG interface { AlterDefaultLoginRole(role, setRole string) error DropDatabase(db string) error DropRole(role, newOwner, database string) error + SetReplication(role string, enable bool) error GetUser() string GetDefaultDatabase() string } diff --git a/pkg/postgres/role.go b/pkg/postgres/role.go index d6e57d2c..b1b11399 100644 --- a/pkg/postgres/role.go +++ b/pkg/postgres/role.go @@ -106,6 +106,15 @@ func (c *pg) DropRole(role, newOwner, database string) error { return nil } +func (c *pg) SetReplication(role string, enable bool) error { + attribute := "NOREPLICATION" + if enable { + attribute = "REPLICATION" + } + _, err := c.db.Exec(fmt.Sprintf(`ALTER ROLE %s WITH %s`, pq.QuoteIdentifier(role), attribute)) + return err +} + func (c *pg) UpdatePassword(role, password string) error { _, err := c.db.Exec(fmt.Sprintf(UPDATE_PASSWORD, role, password)) if err != nil { diff --git a/tests/e2e/basic-operations/02-assert.yaml b/tests/e2e/basic-operations/02-assert.yaml index b1ec242a..6eafa8ff 100644 --- a/tests/e2e/basic-operations/02-assert.yaml +++ b/tests/e2e/basic-operations/02-assert.yaml @@ -15,6 +15,7 @@ spec: status: databaseName: test-db postgresGroup: test-db-group + replication: true succeeded: true --- apiVersion: v1 diff --git a/tests/e2e/basic-operations/02-postgresuser.yaml b/tests/e2e/basic-operations/02-postgresuser.yaml index f5785c47..d02a2128 100644 --- a/tests/e2e/basic-operations/02-postgresuser.yaml +++ b/tests/e2e/basic-operations/02-postgresuser.yaml @@ -7,5 +7,6 @@ spec: database: my-db secretName: my-secret privileges: OWNER + replication: true labels: custom-label: custom-value