diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6211ba27..b6066f54 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,8 +16,9 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: - go-version: "1.24.2" + go-version: "stable" - run: | + go clean -modcache make test build: runs-on: ubuntu-latest @@ -26,7 +27,8 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: - go-version: "1.24.2" + go-version: "stable" - run: | + go clean -modcache make build file bin/manager diff --git a/.gitignore b/.gitignore index 9f2e6d9a..4360d8bf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ deploy/secret.yaml # kuttl/kind tests/kind-logs-*/ kubeconfig + +.gomodcache +.gocache diff --git a/Makefile b/Makefile index 3561c95c..2efd3b78 100644 --- a/Makefile +++ b/Makefile @@ -196,7 +196,7 @@ ENVTEST ?= $(LOCALBIN)/setup-envtest ## Tool Versions KUSTOMIZE_VERSION ?= v5.4.3 -CONTROLLER_TOOLS_VERSION ?= v0.16.1 +CONTROLLER_TOOLS_VERSION ?= v0.19.0 ENVTEST_VERSION ?= release-0.19 .PHONY: kustomize diff --git a/api/v1alpha1/postgresuser_types.go b/api/v1alpha1/postgresuser_types.go index 14988760..ac4cff83 100644 --- a/api/v1alpha1/postgresuser_types.go +++ b/api/v1alpha1/postgresuser_types.go @@ -17,11 +17,19 @@ type PostgresUserSpec struct { // +optional Privileges string `json:"privileges"` // +optional + AWS *PostgresUserAWSSpec `json:"aws,omitempty"` + // +optional Annotations map[string]string `json:"annotations,omitempty"` // +optional Labels map[string]string `json:"labels,omitempty"` } +// PostgresUserAWSSpec encapsulates AWS specific configuration toggles. +type PostgresUserAWSSpec struct { + // +optional + EnableIamAuth bool `json:"enableIamAuth,omitempty"` +} + // PostgresUserStatus defines the observed state of PostgresUser type PostgresUserStatus struct { Succeeded bool `json:"succeeded"` @@ -29,6 +37,7 @@ type PostgresUserStatus struct { PostgresLogin string `json:"postgresLogin"` PostgresGroup string `json:"postgresGroup"` DatabaseName string `json:"databaseName"` + EnableIamAuth bool `json:"enableIamAuth"` } // +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 3b45527c..07a5304c 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 @@ -35,17 +35,29 @@ spec: additionalProperties: type: string type: object + aws: + description: AWS specific settings for the user + properties: + enableIamAuth: + description: Enable IAM authentication for this user (PostgreSQL on AWS RDS only) + default: false + type: boolean + type: object database: + description: Name of the PostgresDatabase this user will be related to type: string labels: additionalProperties: type: string type: object privileges: + description: List of privileges to grant to this user type: string role: + description: Name of the PostgresRole this user will be associated with type: string secretName: + description: Name of the secret to create with user credentials type: string secretTemplate: additionalProperties: @@ -59,6 +71,9 @@ 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 postgresGroup: diff --git a/config/crd/bases/db.movetokube.com_postgres.yaml b/config/crd/bases/db.movetokube.com_postgres.yaml index a7de6e1f..209ed20b 100644 --- a/config/crd/bases/db.movetokube.com_postgres.yaml +++ b/config/crd/bases/db.movetokube.com_postgres.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.19.0 name: postgres.db.movetokube.com spec: group: db.movetokube.com @@ -20,19 +20,14 @@ spec: description: Postgres is the Schema for the postgres 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 diff --git a/config/crd/bases/db.movetokube.com_postgresusers.yaml b/config/crd/bases/db.movetokube.com_postgresusers.yaml index 478eb378..0cbd8510 100644 --- a/config/crd/bases/db.movetokube.com_postgresusers.yaml +++ b/config/crd/bases/db.movetokube.com_postgresusers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.19.0 name: postgresusers.db.movetokube.com spec: group: db.movetokube.com @@ -20,19 +20,14 @@ 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 @@ -43,17 +38,29 @@ spec: additionalProperties: type: string type: object + aws: + description: AWS specific settings for this user. + properties: + enableIamAuth: + description: Enable IAM authentication for this user (PostgreSQL on AWS RDS only) + default: false + type: boolean + type: object database: + description: Name of the PostgresDatabase this user will be related to type: string labels: additionalProperties: type: string type: object privileges: + description: List of privileges to grant to this user type: string role: + description: Name of the PostgresRole this user will be associated with type: string secretName: + description: Name of the secret to create with user credentials type: string secretTemplate: additionalProperties: @@ -67,6 +74,9 @@ 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 postgresGroup: diff --git a/go.mod b/go.mod index e30667d6..cf252eb6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/movetokube/postgres-operator -go 1.24.0 +go 1.25.1 require ( github.com/go-logr/logr v1.4.3 @@ -56,7 +56,7 @@ require ( github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/go.sum b/go.sum index 878ed5ec..065043ea 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,9 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/controller/postgres_controller.go b/internal/controller/postgres_controller.go index b0992b2b..151aaea1 100644 --- a/internal/controller/postgres_controller.go +++ b/internal/controller/postgres_controller.go @@ -256,7 +256,7 @@ func (r *PostgresReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } } - reqLogger.Info("reconciler done", "CR.Namespace", instance.Namespace, "CR.Name", instance.Name) + reqLogger.Info("Reconciling done") return ctrl.Result{}, nil } func (r *PostgresReconciler) addFinalizer(reqLogger logr.Logger, m *dbv1alpha1.Postgres) error { diff --git a/internal/controller/postgresuser_controller.go b/internal/controller/postgresuser_controller.go index da94df08..6870a683 100644 --- a/internal/controller/postgresuser_controller.go +++ b/internal/controller/postgresuser_controller.go @@ -33,6 +33,7 @@ type PostgresUserReconciler struct { pgUriArgs string instanceFilter string keepSecretName bool // use secret name as defined in PostgresUserSpec + cloudProvider string } // NewPostgresUserReconciler returns a new reconcile.Reconciler @@ -45,6 +46,7 @@ func NewPostgresUserReconciler(mgr manager.Manager, cfg *config.Cfg, pg postgres pgUriArgs: cfg.PostgresUriArgs, instanceFilter: cfg.AnnotationFilter, keepSecretName: cfg.KeepSecretName, + cloudProvider: cfg.CloudProvider, } } @@ -171,6 +173,36 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request login = instance.Status.PostgresLogin } + awsConfig := instance.Spec.AWS + awsIamRequested := awsConfig != nil && awsConfig.EnableIamAuth + + if r.cloudProvider == "AWS" { + if awsIamRequested && !instance.Status.EnableIamAuth { + if err := r.pg.GrantRole("rds_iam", role); err != nil { + reqLogger.WithValues("role", role).Error(err, "failed to grant rds_iam role") + } else { + instance.Status.EnableIamAuth = true + if sErr := r.Status().Update(ctx, instance); sErr != nil { + reqLogger.WithValues("role", role).Error(sErr, "failed to update status after IAM grant") + } + } + } + + // Revoke aws_iam role on transition: spec=false, status=true + if !awsIamRequested && instance.Status.EnableIamAuth { + if err := r.pg.RevokeRole("rds_iam", role); err != nil { + reqLogger.WithValues("role", role).Error(err, "failed to revoke rds_iam role") + } else { + instance.Status.EnableIamAuth = false + if sErr := r.Status().Update(ctx, instance); sErr != nil { + reqLogger.WithValues("role", role).Error(sErr, "failed to update status after IAM revoke") + } + } + } + } else if awsIamRequested { + reqLogger.WithValues("role", role).Info("IAM Auth requested while we are not running with AWS cloud provider config") + } + err = r.addFinalizer(ctx, reqLogger, instance) if err != nil { return r.requeue(ctx, instance, err) @@ -213,7 +245,7 @@ func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request return r.requeue(ctx, instance, err) } - reqLogger.Info("reconciler done", "CR.Namespace", instance.Namespace, "CR.Name", instance.Name) + reqLogger.Info("Reconciling done") return ctrl.Result{}, nil } diff --git a/internal/controller/postgresuser_controller_test.go b/internal/controller/postgresuser_controller_test.go index f54789e3..543671da 100644 --- a/internal/controller/postgresuser_controller_test.go +++ b/internal/controller/postgresuser_controller_test.go @@ -29,6 +29,7 @@ var _ = Describe("PostgresUser Controller", func() { databaseName = "test-db" secretName = "db-credentials" roleName = "app" + roleAws = "rds_iam" ) var ( @@ -98,10 +99,11 @@ var _ = Describe("PostgresUser Controller", func() { sc.AddKnownTypes(dbv1alpha1.GroupVersion, &dbv1alpha1.PostgresUserList{}) // Create PostgresUserReconciler rp = &PostgresUserReconciler{ - Client: managerClient, - Scheme: sc, - pg: pg, - pgHost: "postgres.local", + Client: managerClient, + Scheme: sc, + pg: pg, + pgHost: "postgres.local", + cloudProvider: "AWS", } if k8sManager != nil { rp.SetupWithManager(k8sManager) @@ -531,6 +533,135 @@ var _ = Describe("PostgresUser Controller", func() { }) }) }) + + Context("IAM authentication", 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() { + // Clean up any created secrets + 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("grants rds_iam role when enableIamAuth is true", func() { + // Create DB and user with IAM enabled + initClient(postgresDB, nil, false) + user := postgresUser.DeepCopy() + user.Spec.AWS = &dbv1alpha1.PostgresUserAWSSpec{EnableIamAuth: true} + Expect(cl.Create(ctx, user)).To(Succeed()) + + var capturedRole string + pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() + pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).DoAndReturn( + func(role, password string) (string, error) { + Expect(role).To(HavePrefix(roleName + "-")) + capturedRole = role + return role, nil + }) + pg.EXPECT().GrantRole(databaseName+"-writer", gomock.Any()).Return(nil) + pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) + pg.EXPECT().GrantRole(roleAws, gomock.Any()).DoAndReturn( + func(role, grantee string) error { + Expect(role).To(Equal(roleAws)) + Expect(grantee).To(Equal(capturedRole)) + 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.EnableIamAuth).To(BeTrue()) + }) + + It("does not flip status on grant error", func() { + initClient(postgresDB, nil, false) + user := postgresUser.DeepCopy() + user.Spec.AWS = &dbv1alpha1.PostgresUserAWSSpec{EnableIamAuth: true} + Expect(cl.Create(ctx, user)).To(Succeed()) + + pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() + pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).Return(roleName+"-mock", nil) + pg.EXPECT().GrantRole(databaseName+"-writer", gomock.Any()).Return(nil) + pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) + pg.EXPECT().GrantRole(roleAws, gomock.Any()).Return(fmt.Errorf("grant failed")) + + 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.EnableIamAuth).To(BeFalse()) + }) + + It("revokes rds_iam role when enableIamAuth turns false", func() { + // Pre-create a user with IAM already enabled in status + user := postgresUser.DeepCopy() + user.Spec.AWS = &dbv1alpha1.PostgresUserAWSSpec{EnableIamAuth: false} + user.Status = dbv1alpha1.PostgresUserStatus{ + Succeeded: true, + PostgresGroup: databaseName + "-writer", + PostgresRole: roleName + "-exists", + DatabaseName: databaseName, + EnableIamAuth: true, + PostgresLogin: "login", + } + initClient(postgresDB, user, false) + + pg.EXPECT().RevokeRole(roleAws, roleName+"-exists").Return(nil) + // Since Status.Succeeded=true and the secret does not yet exist, the reconciler + // updates the password before creating the secret. + 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.EnableIamAuth).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/tests/kuttl-test-self-hosted-postgres.yaml b/tests/kuttl-test-self-hosted-postgres.yaml index a6d088af..1f9769f5 100644 --- a/tests/kuttl-test-self-hosted-postgres.yaml +++ b/tests/kuttl-test-self-hosted-postgres.yaml @@ -11,7 +11,6 @@ artifactsDir: ./tests/ commands: - command: >- helm install -n $NAMESPACE postgresql oci://registry-1.docker.io/bitnamicharts/postgresql - --version 16.6.0 --set global.postgresql.auth.password=postgres --set global.postgresql.auth.username=postgres --wait