diff --git a/internal/controller/postgresdatabase_controller_test.go b/internal/controller/postgresdatabase_controller_test.go index 4e0589cad..614efc49b 100644 --- a/internal/controller/postgresdatabase_controller_test.go +++ b/internal/controller/postgresdatabase_controller_test.go @@ -18,67 +18,539 @@ package controller import ( "context" + "fmt" + "slices" + "time" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/api/errors" + enterprisev4 "github.com/splunk/splunk-operator/api/v4" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" +) - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +const postgresDatabaseFinalizer = "postgresdatabases.enterprise.splunk.com/finalizer" - enterprisev4 "github.com/splunk/splunk-operator/api/v4" -) +func reconcilePostgresDatabase(ctx context.Context, nn types.NamespacedName) (ctrl.Result, error) { + reconciler := &PostgresDatabaseReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + return reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) +} -var _ = Describe("Database Controller", func() { - Context("When reconciling a resource", func() { - const resourceName = "test-resource" - - ctx := context.Background() - - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed - } - database := &enterprisev4.PostgresDatabase{} - - BeforeEach(func() { - By("creating the custom resource for the Kind Database") - err := k8sClient.Get(ctx, typeNamespacedName, database) - if err != nil && errors.IsNotFound(err) { - resource := &enterprisev4.PostgresDatabase{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - // TODO(user): Specify other spec details if needed. - } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) - } +func managedRoleNames(roles []enterprisev4.ManagedRole) []string { + names := make([]string, 0, len(roles)) + for _, role := range roles { + names = append(names, role.Name) + } + return names +} + +func adminRoleNameForTest(dbName string) string { + return dbName + "_admin" +} + +func rwRoleNameForTest(dbName string) string { + return dbName + "_rw" +} + +func ownedByPostgresDatabase(postgresDB *enterprisev4.PostgresDatabase) []metav1.OwnerReference { + controller := true + blockOwnerDeletion := true + return []metav1.OwnerReference{{ + APIVersion: enterprisev4.GroupVersion.String(), + Kind: "PostgresDatabase", + Name: postgresDB.Name, + UID: postgresDB.UID, + Controller: &controller, + BlockOwnerDeletion: &blockOwnerDeletion, + }} +} + +func createPostgresDatabaseResource(ctx context.Context, namespace, resourceName, clusterName string, databases []enterprisev4.DatabaseDefinition, finalizers ...string) *enterprisev4.PostgresDatabase { + postgresDB := &enterprisev4.PostgresDatabase{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: namespace, + Finalizers: finalizers, + }, + Spec: enterprisev4.PostgresDatabaseSpec{ + ClusterRef: corev1.LocalObjectReference{Name: clusterName}, + Databases: databases, + }, + } + Expect(k8sClient.Create(ctx, postgresDB)).To(Succeed()) + return postgresDB +} + +func createPostgresClusterResource(ctx context.Context, namespace, clusterName string) *enterprisev4.PostgresCluster { + postgresCluster := &enterprisev4.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + }, + Spec: enterprisev4.PostgresClusterSpec{ + Class: "dev", + }, + } + Expect(k8sClient.Create(ctx, postgresCluster)).To(Succeed()) + return postgresCluster +} + +func markPostgresClusterReady(ctx context.Context, postgresCluster *enterprisev4.PostgresCluster, cnpgClusterName, namespace string, poolerEnabled bool) { + clusterPhase := "Ready" + postgresCluster.Status.Phase = &clusterPhase + postgresCluster.Status.ProvisionerRef = &corev1.ObjectReference{ + APIVersion: cnpgv1.SchemeGroupVersion.String(), + Kind: "Cluster", + Name: cnpgClusterName, + Namespace: namespace, + } + if poolerEnabled { + postgresCluster.Status.ConnectionPoolerStatus = &enterprisev4.ConnectionPoolerStatus{Enabled: true} + } + Expect(k8sClient.Status().Update(ctx, postgresCluster)).To(Succeed()) +} + +func createCNPGClusterResource(ctx context.Context, namespace, cnpgClusterName string) *cnpgv1.Cluster { + cnpgCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: cnpgClusterName, + Namespace: namespace, + }, + Spec: cnpgv1.ClusterSpec{ + Instances: 1, + StorageConfiguration: cnpgv1.StorageConfiguration{ + Size: "1Gi", + }, + }, + } + Expect(k8sClient.Create(ctx, cnpgCluster)).To(Succeed()) + return cnpgCluster +} + +func markCNPGClusterReady(ctx context.Context, cnpgCluster *cnpgv1.Cluster, reconciledRoles []string, writeService, readService string) { + cnpgCluster.Status.ManagedRolesStatus = cnpgv1.ManagedRoles{ + ByStatus: map[cnpgv1.RoleStatus][]string{ + cnpgv1.RoleStatusReconciled: reconciledRoles, + }, + } + cnpgCluster.Status.WriteService = writeService + cnpgCluster.Status.ReadService = readService + Expect(k8sClient.Status().Update(ctx, cnpgCluster)).To(Succeed()) +} + +type readyClusterScenario struct { + namespace string + resourceName string + clusterName string + cnpgClusterName string + dbName string + requestName types.NamespacedName +} + +func newReadyClusterScenario(namespace, resourceName, clusterName, cnpgClusterName, dbName string) readyClusterScenario { + return readyClusterScenario{ + namespace: namespace, + resourceName: resourceName, + clusterName: clusterName, + cnpgClusterName: cnpgClusterName, + dbName: dbName, + requestName: types.NamespacedName{Name: resourceName, Namespace: namespace}, + } +} + +func seedReadyClusterScenario(ctx context.Context, scenario readyClusterScenario, poolerEnabled bool) { + createPostgresDatabaseResource(ctx, scenario.namespace, scenario.resourceName, scenario.clusterName, []enterprisev4.DatabaseDefinition{{Name: scenario.dbName}}) + postgresCluster := createPostgresClusterResource(ctx, scenario.namespace, scenario.clusterName) + markPostgresClusterReady(ctx, postgresCluster, scenario.cnpgClusterName, scenario.namespace, poolerEnabled) + cnpgCluster := createCNPGClusterResource(ctx, scenario.namespace, scenario.cnpgClusterName) + markCNPGClusterReady(ctx, cnpgCluster, []string{adminRoleNameForTest(scenario.dbName), rwRoleNameForTest(scenario.dbName)}, "tenant-rw", "tenant-ro") +} + +func expectReconcileResult(result ctrl.Result, err error, requeueAfter time.Duration) { + Expect(err).NotTo(HaveOccurred()) + Expect(result.RequeueAfter).To(Equal(requeueAfter)) +} + +func expectEmptyReconcileResult(result ctrl.Result, err error) { + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) +} + +func fetchPostgresDatabase(ctx context.Context, requestName types.NamespacedName) *enterprisev4.PostgresDatabase { + current := &enterprisev4.PostgresDatabase{} + Expect(k8sClient.Get(ctx, requestName, current)).To(Succeed()) + return current +} + +func expectFinalizerAdded(ctx context.Context, requestName types.NamespacedName) *enterprisev4.PostgresDatabase { + current := fetchPostgresDatabase(ctx, requestName) + Expect(current.Finalizers).To(ContainElement(postgresDatabaseFinalizer)) + return current +} + +func seedExistingDatabaseStatus(ctx context.Context, current *enterprisev4.PostgresDatabase, dbName string) { + current.Status.Databases = []enterprisev4.DatabaseInfo{{Name: dbName}} + Expect(k8sClient.Status().Update(ctx, current)).To(Succeed()) +} + +func expectProvisionedArtifacts(ctx context.Context, scenario readyClusterScenario, owner *enterprisev4.PostgresDatabase) { + adminSecret := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-%s-admin", scenario.resourceName, scenario.dbName), Namespace: scenario.namespace}, adminSecret)).To(Succeed()) + Expect(adminSecret.Data).To(HaveKey("password")) + Expect(metav1.IsControlledBy(adminSecret, owner)).To(BeTrue()) + + rwSecret := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-%s-rw", scenario.resourceName, scenario.dbName), Namespace: scenario.namespace}, rwSecret)).To(Succeed()) + Expect(rwSecret.Data).To(HaveKey("password")) + Expect(metav1.IsControlledBy(rwSecret, owner)).To(BeTrue()) + + configMap := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-%s-config", scenario.resourceName, scenario.dbName), Namespace: scenario.namespace}, configMap)).To(Succeed()) + Expect(configMap.Data).To(HaveKeyWithValue("rw-host", "tenant-rw."+scenario.namespace+".svc.cluster.local")) + Expect(configMap.Data).To(HaveKeyWithValue("ro-host", "tenant-ro."+scenario.namespace+".svc.cluster.local")) + Expect(configMap.Data).To(HaveKeyWithValue("admin-user", adminRoleNameForTest(scenario.dbName))) + Expect(configMap.Data).To(HaveKeyWithValue("rw-user", rwRoleNameForTest(scenario.dbName))) + Expect(metav1.IsControlledBy(configMap, owner)).To(BeTrue()) +} + +func expectManagedRolesPatched(ctx context.Context, scenario readyClusterScenario) { + updatedCluster := &enterprisev4.PostgresCluster{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: scenario.clusterName, Namespace: scenario.namespace}, updatedCluster)).To(Succeed()) + Expect(managedRoleNames(updatedCluster.Spec.ManagedRoles)).To(ConsistOf(adminRoleNameForTest(scenario.dbName), rwRoleNameForTest(scenario.dbName))) +} + +func expectCNPGDatabaseCreated(ctx context.Context, scenario readyClusterScenario, owner *enterprisev4.PostgresDatabase) *cnpgv1.Database { + cnpgDatabase := &cnpgv1.Database{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-%s", scenario.resourceName, scenario.dbName), Namespace: scenario.namespace}, cnpgDatabase)).To(Succeed()) + Expect(cnpgDatabase.Spec.Name).To(Equal(scenario.dbName)) + Expect(cnpgDatabase.Spec.Owner).To(Equal(adminRoleNameForTest(scenario.dbName))) + Expect(cnpgDatabase.Spec.ClusterRef.Name).To(Equal(scenario.cnpgClusterName)) + Expect(metav1.IsControlledBy(cnpgDatabase, owner)).To(BeTrue()) + return cnpgDatabase +} + +func markCNPGDatabaseApplied(ctx context.Context, cnpgDatabase *cnpgv1.Database) { + applied := true + cnpgDatabase.Status.Applied = &applied + Expect(k8sClient.Status().Update(ctx, cnpgDatabase)).To(Succeed()) +} + +func expectPoolerConfigMap(ctx context.Context, scenario readyClusterScenario) { + configMap := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-%s-config", scenario.resourceName, scenario.dbName), Namespace: scenario.namespace}, configMap)).To(Succeed()) + Expect(configMap.Data).To(HaveKeyWithValue("pooler-rw-host", scenario.cnpgClusterName+"-pooler-rw."+scenario.namespace+".svc.cluster.local")) + Expect(configMap.Data).To(HaveKeyWithValue("pooler-ro-host", scenario.cnpgClusterName+"-pooler-ro."+scenario.namespace+".svc.cluster.local")) +} + +func seedMissingClusterScenario(ctx context.Context, namespace, resourceName string, finalizers ...string) types.NamespacedName { + createPostgresDatabaseResource(ctx, namespace, resourceName, "absent-cluster", []enterprisev4.DatabaseDefinition{{Name: "appdb"}}, finalizers...) + return types.NamespacedName{Name: resourceName, Namespace: namespace} +} + +func seedConflictScenario(ctx context.Context, namespace, resourceName, clusterName string) types.NamespacedName { + createPostgresDatabaseResource(ctx, namespace, resourceName, clusterName, []enterprisev4.DatabaseDefinition{{Name: "appdb"}}, postgresDatabaseFinalizer) + postgresCluster := createPostgresClusterResource(ctx, namespace, clusterName) + markPostgresClusterReady(ctx, postgresCluster, "unused-cnpg", namespace, false) + return types.NamespacedName{Name: resourceName, Namespace: namespace} +} + +func seedOwnedDatabaseArtifacts(ctx context.Context, namespace, resourceName, clusterName string, postgresDB *enterprisev4.PostgresDatabase, dbNames ...string) { + ownerReferences := ownedByPostgresDatabase(postgresDB) + for _, dbName := range dbNames { + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-admin", resourceName, dbName), + Namespace: namespace, + OwnerReferences: ownerReferences, + }, + })).To(Succeed()) + + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-rw", resourceName, dbName), + Namespace: namespace, + OwnerReferences: ownerReferences, + }, + })).To(Succeed()) + + Expect(k8sClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-config", resourceName, dbName), + Namespace: namespace, + OwnerReferences: ownerReferences, + }, + })).To(Succeed()) + + Expect(k8sClient.Create(ctx, &cnpgv1.Database{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s", resourceName, dbName), + Namespace: namespace, + OwnerReferences: ownerReferences, + }, + Spec: cnpgv1.DatabaseSpec{ + ClusterRef: corev1.LocalObjectReference{Name: clusterName}, + Name: dbName, + Owner: adminRoleNameForTest(dbName), + }, + })).To(Succeed()) + } +} + +func expectRetainedArtifact(ctx context.Context, name, namespace, resourceName string, obj client.Object) { + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj)).To(Succeed()) + Expect(obj.GetAnnotations()).To(HaveKeyWithValue("enterprise.splunk.com/retained-from", resourceName)) + Expect(obj.GetOwnerReferences()).To(BeEmpty()) +} + +func expectDeletedArtifact(ctx context.Context, name, namespace string, obj client.Object) { + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, obj) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected %s to be deleted", name) +} + +func expectStatusPhase(current *enterprisev4.PostgresDatabase, expectedPhase string) { + Expect(current.Status.Phase).NotTo(BeNil()) + Expect(*current.Status.Phase).To(Equal(expectedPhase)) +} + +func expectStatusCondition(current *enterprisev4.PostgresDatabase, conditionType string, expectedStatus metav1.ConditionStatus, expectedReason string) { + condition := meta.FindStatusCondition(current.Status.Conditions, conditionType) + Expect(condition).NotTo(BeNil(), "missing status condition %s", conditionType) + Expect(condition.Status).To(Equal(expectedStatus), "unexpected status for %s", conditionType) + Expect(condition.Reason).To(Equal(expectedReason), "unexpected reason for %s", conditionType) +} + +func expectReadyStatus(current *enterprisev4.PostgresDatabase, generation int64, expectedDatabase enterprisev4.DatabaseInfo) { + expectStatusPhase(current, "Ready") + Expect(current.Status.ObservedGeneration).NotTo(BeNil()) + Expect(*current.Status.ObservedGeneration).To(Equal(generation)) + Expect(current.Status.Databases).To(HaveLen(1)) + Expect(current.Status.Databases[0].Name).To(Equal(expectedDatabase.Name)) + Expect(current.Status.Databases[0].Ready).To(Equal(expectedDatabase.Ready)) + Expect(current.Status.Databases[0].AdminUserSecretRef).NotTo(BeNil()) + Expect(current.Status.Databases[0].RWUserSecretRef).NotTo(BeNil()) + Expect(current.Status.Databases[0].ConfigMapRef).NotTo(BeNil()) +} + +var _ = Describe("PostgresDatabase Controller", func() { + var ( + ctx context.Context + namespace string + ) + + BeforeEach(func() { + ctx = context.Background() + namespace = fmt.Sprintf("postgresdatabase-%d", time.Now().UnixNano()) + Expect(k8sClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + })).To(Succeed()) + }) + + AfterEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + Expect(k8sClient.Delete(ctx, ns)).To(Succeed()) + }) + + When("the referenced PostgresCluster is missing", func() { + Context("on the first reconcile", func() { + It("adds the finalizer", func() { + requestName := seedMissingClusterScenario(ctx, namespace, "missing-cluster") + + result, err := reconcilePostgresDatabase(ctx, requestName) + expectEmptyReconcileResult(result, err) + + current := fetchPostgresDatabase(ctx, requestName) + Expect(current.Finalizers).To(ContainElement(postgresDatabaseFinalizer)) + }) + }) + + Context("after the finalizer is already present", func() { + It("reports ClusterNotFound and requeues", func() { + requestName := seedMissingClusterScenario(ctx, namespace, "missing-cluster-with-finalizer", postgresDatabaseFinalizer) + + result, err := reconcilePostgresDatabase(ctx, requestName) + expectReconcileResult(result, err, 30*time.Second) + + current := fetchPostgresDatabase(ctx, requestName) + expectStatusPhase(current, "Pending") + expectStatusCondition(current, "ClusterReady", metav1.ConditionFalse, "ClusterNotFound") + clusterReady := meta.FindStatusCondition(current.Status.Conditions, "ClusterReady") + Expect(clusterReady.ObservedGeneration).To(Equal(current.Generation)) + }) + }) + }) + + When("the referenced PostgresCluster is ready", func() { + Context("and live grants are not invoked", func() { + It("reconciles secrets, configmaps, roles, and CNPG databases", func() { + scenario := newReadyClusterScenario(namespace, "ready-cluster", "tenant-cluster", "tenant-cnpg", "appdb") + seedReadyClusterScenario(ctx, scenario, false) + + result, err := reconcilePostgresDatabase(ctx, scenario.requestName) + expectEmptyReconcileResult(result, err) + + current := expectFinalizerAdded(ctx, scenario.requestName) + seedExistingDatabaseStatus(ctx, current, scenario.dbName) + + result, err = reconcilePostgresDatabase(ctx, scenario.requestName) + expectReconcileResult(result, err, 15*time.Second) + expectProvisionedArtifacts(ctx, scenario, current) + expectManagedRolesPatched(ctx, scenario) + + result, err = reconcilePostgresDatabase(ctx, scenario.requestName) + expectReconcileResult(result, err, 15*time.Second) + cnpgDatabase := expectCNPGDatabaseCreated(ctx, scenario, current) + markCNPGDatabaseApplied(ctx, cnpgDatabase) + + result, err = reconcilePostgresDatabase(ctx, scenario.requestName) + expectEmptyReconcileResult(result, err) + + current = fetchPostgresDatabase(ctx, scenario.requestName) + expectReadyStatus(current, current.Generation, enterprisev4.DatabaseInfo{Name: scenario.dbName, Ready: true}) + expectStatusCondition(current, "ClusterReady", metav1.ConditionTrue, "ClusterAvailable") + expectStatusCondition(current, "SecretsReady", metav1.ConditionTrue, "SecretsCreated") + expectStatusCondition(current, "ConfigMapsReady", metav1.ConditionTrue, "ConfigMapsCreated") + expectStatusCondition(current, "RolesReady", metav1.ConditionTrue, "UsersAvailable") + expectStatusCondition(current, "DatabasesReady", metav1.ConditionTrue, "DatabasesAvailable") + Expect(meta.FindStatusCondition(current.Status.Conditions, "PrivilegesReady")).To(BeNil()) + }) }) - AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &enterprisev4.PostgresDatabase{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + Context("and connection pooling is enabled", func() { + It("adds pooler endpoints to the generated ConfigMap", func() { + scenario := newReadyClusterScenario(namespace, "pooler-cluster", "pooler-postgres", "pooler-cnpg", "appdb") + seedReadyClusterScenario(ctx, scenario, true) + + result, err := reconcilePostgresDatabase(ctx, scenario.requestName) + expectEmptyReconcileResult(result, err) - By("Cleanup the specific resource instance Database") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + current := fetchPostgresDatabase(ctx, scenario.requestName) + seedExistingDatabaseStatus(ctx, current, scenario.dbName) + + result, err = reconcilePostgresDatabase(ctx, scenario.requestName) + expectReconcileResult(result, err, 15*time.Second) + expectPoolerConfigMap(ctx, scenario) + }) }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &PostgresDatabaseReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + }) + + When("role ownership conflicts exist", func() { + It("marks the resource failed and stops provisioning dependent resources", func() { + resourceName := "conflict-cluster" + clusterName := "conflict-postgres" + requestName := seedConflictScenario(ctx, namespace, resourceName, clusterName) + + conflictPatch := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": enterprisev4.GroupVersion.String(), + "kind": "PostgresCluster", + "metadata": map[string]any{ + "name": clusterName, + "namespace": namespace, + }, + "spec": map[string]any{ + "managedRoles": []map[string]any{ + {"name": "appdb_admin", "exists": true}, + {"name": "appdb_rw", "exists": true}, + }, + }, + }, } + Expect(k8sClient.Patch(ctx, conflictPatch, client.Apply, client.FieldOwner("postgresdatabase-legacy"))).To(Succeed()) + + result, err := reconcilePostgresDatabase(ctx, requestName) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("role conflict detected")) + Expect(result).To(Equal(ctrl.Result{})) + + current := fetchPostgresDatabase(ctx, requestName) + expectStatusPhase(current, "Failed") + expectStatusCondition(current, "RolesReady", metav1.ConditionFalse, "RoleConflict") + + rolesReady := meta.FindStatusCondition(current.Status.Conditions, "RolesReady") + Expect(rolesReady.Message).To(ContainSubstring("appdb_admin")) + Expect(rolesReady.Message).To(ContainSubstring("postgresdatabase-legacy")) + + configMap := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "conflict-cluster-appdb-config", Namespace: namespace}, configMap) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + + cnpgDatabase := &cnpgv1.Database{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "conflict-cluster-appdb", Namespace: namespace}, cnpgDatabase) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + }) + + When("the PostgresDatabase is being deleted", func() { + Context("with retained and deleted databases", func() { + It("orphans retained resources, removes deleted resources, and patches managed roles", func() { + resourceName := "delete-cluster" + clusterName := "delete-postgres" + requestName := types.NamespacedName{Name: resourceName, Namespace: namespace} + + postgresDB := createPostgresDatabaseResource(ctx, namespace, resourceName, clusterName, []enterprisev4.DatabaseDefinition{ + {Name: "keepdb", DeletionPolicy: "Retain"}, + {Name: "dropdb"}, + }, postgresDatabaseFinalizer) + Expect(k8sClient.Get(ctx, requestName, postgresDB)).To(Succeed()) + + createPostgresClusterResource(ctx, namespace, clusterName) + + initialRolesPatch := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": enterprisev4.GroupVersion.String(), + "kind": "PostgresCluster", + "metadata": map[string]any{ + "name": clusterName, + "namespace": namespace, + }, + "spec": map[string]any{ + "managedRoles": []map[string]any{ + {"name": "keepdb_admin", "exists": true, "passwordSecretRef": map[string]any{"name": "delete-cluster-keepdb-admin", "key": "password"}}, + {"name": "keepdb_rw", "exists": true, "passwordSecretRef": map[string]any{"name": "delete-cluster-keepdb-rw", "key": "password"}}, + {"name": "dropdb_admin", "exists": true, "passwordSecretRef": map[string]any{"name": "delete-cluster-dropdb-admin", "key": "password"}}, + {"name": "dropdb_rw", "exists": true, "passwordSecretRef": map[string]any{"name": "delete-cluster-dropdb-rw", "key": "password"}}, + }, + }, + }, + } + Expect(k8sClient.Patch(ctx, initialRolesPatch, client.Apply, client.FieldOwner("postgresdatabase-delete-cluster"))).To(Succeed()) + + seedOwnedDatabaseArtifacts(ctx, namespace, resourceName, clusterName, postgresDB, "keepdb", "dropdb") + + Expect(k8sClient.Delete(ctx, postgresDB)).To(Succeed()) + + result, err := reconcilePostgresDatabase(ctx, requestName) + expectEmptyReconcileResult(result, err) + + expectRetainedArtifact(ctx, "delete-cluster-keepdb-config", namespace, resourceName, &corev1.ConfigMap{}) + expectRetainedArtifact(ctx, "delete-cluster-keepdb-admin", namespace, resourceName, &corev1.Secret{}) + expectRetainedArtifact(ctx, "delete-cluster-keepdb-rw", namespace, resourceName, &corev1.Secret{}) + expectRetainedArtifact(ctx, "delete-cluster-keepdb", namespace, resourceName, &cnpgv1.Database{}) + + expectDeletedArtifact(ctx, "delete-cluster-dropdb-config", namespace, &corev1.ConfigMap{}) + expectDeletedArtifact(ctx, "delete-cluster-dropdb-admin", namespace, &corev1.Secret{}) + expectDeletedArtifact(ctx, "delete-cluster-dropdb-rw", namespace, &corev1.Secret{}) + expectDeletedArtifact(ctx, "delete-cluster-dropdb", namespace, &cnpgv1.Database{}) + + updatedCluster := &enterprisev4.PostgresCluster{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: namespace}, updatedCluster)).To(Succeed()) + Expect(managedRoleNames(updatedCluster.Spec.ManagedRoles)).To(ConsistOf("keepdb_admin", "keepdb_rw")) - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, + current := &enterprisev4.PostgresDatabase{} + err = k8sClient.Get(ctx, requestName, current) + Expect(apierrors.IsNotFound(err) || !slices.Contains(current.Finalizers, postgresDatabaseFinalizer)).To(BeTrue()) }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. }) }) }) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 142a8720c..9356a011f 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -19,10 +19,13 @@ package controller import ( "context" "fmt" + "os/exec" "path/filepath" + "strings" "testing" "time" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/zap/zapcore" @@ -46,6 +49,14 @@ var k8sClient client.Client var testEnv *envtest.Environment var k8sManager ctrl.Manager +func resolveCNPGModuleDir() string { + cmd := exec.Command("go", "list", "-f", "{{.Dir}}", "-m", "github.com/cloudnative-pg/cloudnative-pg") + output, err := cmd.Output() + Expect(err).NotTo(HaveOccurred()) + + return strings.TrimSpace(string(output)) +} + func TestAPIs(t *testing.T) { RegisterFailHandler(Fail) @@ -61,8 +72,12 @@ var _ = BeforeSuite(func(ctx context.Context) { By("bootstrapping test environment") + cnpgModuleDir := resolveCNPGModuleDir() testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "config", "crd", "bases"), + filepath.Join(cnpgModuleDir, "config", "crd", "bases"), + }, ErrorIfCRDPathMissing: true, } @@ -76,6 +91,9 @@ var _ = BeforeSuite(func(ctx context.Context) { err = enterpriseApi.AddToScheme(clientgoscheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = cnpgv1.AddToScheme(clientgoscheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = enterpriseApiV3.AddToScheme(clientgoscheme.Scheme) Expect(err).NotTo(HaveOccurred()) @@ -152,7 +170,6 @@ var _ = BeforeSuite(func(ctx context.Context) { }).SetupWithManager(k8sManager); err != nil { Expect(err).NotTo(HaveOccurred()) } - go func() { err = k8sManager.Start(ctrl.SetupSignalHandler()) fmt.Printf("error %v", err.Error()) diff --git a/pkg/postgresql/cluster/core/cluster_unit_test.go b/pkg/postgresql/cluster/core/cluster_unit_test.go index e87173afb..e2466f54b 100644 --- a/pkg/postgresql/cluster/core/cluster_unit_test.go +++ b/pkg/postgresql/cluster/core/cluster_unit_test.go @@ -717,9 +717,8 @@ func TestPoolerExists(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.objects...).Build() - - got := poolerExists(context.Background(), c, cluster, "rw") - + got, err := poolerExists(context.Background(), c, cluster, "rw") + assert.NoError(t, err) assert.Equal(t, tt.expected, got) }) } diff --git a/pkg/postgresql/database/core/database.go b/pkg/postgresql/database/core/database.go index 483076774..9ad701b01 100644 --- a/pkg/postgresql/database/core/database.go +++ b/pkg/postgresql/database/core/database.go @@ -20,6 +20,7 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -397,7 +398,11 @@ func parseRoleNames(raw []byte) []string { func patchManagedRoles(ctx context.Context, c client.Client, postgresDB *enterprisev4.PostgresDatabase, cluster *enterprisev4.PostgresCluster) error { logger := log.FromContext(ctx) allRoles := buildManagedRoles(postgresDB.Name, postgresDB.Spec.Databases) - rolePatch := buildManagedRolesPatch(cluster, allRoles) + rolePatch, err := buildManagedRolesPatch(cluster, allRoles, c.Scheme()) + if err != nil { + logger.Error(err, "Failed to build managed roles patch", "postgresDatabase", postgresDB.Name) + return fmt.Errorf("building managed roles patch for PostgresDatabase %s: %w", postgresDB.Name, err) + } fieldManager := fieldManagerName(postgresDB.Name) if err := c.Patch(ctx, rolePatch, client.Apply, client.FieldOwner(fieldManager)); err != nil { logger.Error(err, "Failed to add users to PostgresCluster", "postgresDatabase", postgresDB.Name) @@ -710,20 +715,27 @@ func buildManagedRoles(postgresDBName string, databases []enterprisev4.DatabaseD return roles } -func buildManagedRolesPatch(cluster *enterprisev4.PostgresCluster, roles []enterprisev4.ManagedRole) *unstructured.Unstructured { +func buildManagedRolesPatch(cluster *enterprisev4.PostgresCluster, roles []enterprisev4.ManagedRole, scheme *runtime.Scheme) (*unstructured.Unstructured, error) { + gvk, err := apiutil.GVKForObject(cluster, scheme) + if err != nil { + return nil, fmt.Errorf("failed to get GVK for Cluster: %w", err) + } return &unstructured.Unstructured{ Object: map[string]any{ - "apiVersion": cluster.APIVersion, - "kind": cluster.Kind, + "apiVersion": gvk.GroupVersion().String(), + "kind": gvk.Kind, "metadata": map[string]any{"name": cluster.Name, "namespace": cluster.Namespace}, "spec": map[string]any{"managedRoles": roles}, }, - } + }, nil } func patchManagedRolesOnDeletion(ctx context.Context, c client.Client, postgresDB *enterprisev4.PostgresDatabase, cluster *enterprisev4.PostgresCluster, retained []enterprisev4.DatabaseDefinition) error { roles := buildManagedRoles(postgresDB.Name, retained) - rolePatch := buildManagedRolesPatch(cluster, roles) + rolePatch, err := buildManagedRolesPatch(cluster, roles, c.Scheme()) + if err != nil { + return fmt.Errorf("building managed roles patch: %w", err) + } if err := c.Patch(ctx, rolePatch, client.Apply, client.FieldOwner(fieldManagerName(postgresDB.Name))); err != nil { return fmt.Errorf("patching managed roles on deletion: %w", err) } diff --git a/pkg/postgresql/database/core/database_unit_test.go b/pkg/postgresql/database/core/database_unit_test.go index 0bde24a16..8d4da6c52 100644 --- a/pkg/postgresql/database/core/database_unit_test.go +++ b/pkg/postgresql/database/core/database_unit_test.go @@ -1289,6 +1289,7 @@ func TestBuildManagedRoles(t *testing.T) { } func TestBuildManagedRolesPatch(t *testing.T) { + scheme := testScheme(t) cluster := &enterprisev4.PostgresCluster{ TypeMeta: metav1.TypeMeta{ APIVersion: enterprisev4.GroupVersion.String(), @@ -1300,8 +1301,10 @@ func TestBuildManagedRolesPatch(t *testing.T) { }, } roles := buildManagedRoles("primary", []enterprisev4.DatabaseDefinition{{Name: "payments"}}) + c := testClient(t, scheme, cluster) - got := buildManagedRolesPatch(cluster, roles) + got, err := buildManagedRolesPatch(cluster, roles, c.Scheme()) + require.NoError(t, err) assert.Equal(t, cluster.APIVersion, got.Object["apiVersion"]) assert.Equal(t, cluster.Kind, got.Object["kind"])