From 15ddf8f9e6c91e5a3aca11c550a80ad4a10017df Mon Sep 17 00:00:00 2001 From: Georgi Sabev Date: Mon, 15 Apr 2024 15:45:08 +0000 Subject: [PATCH] Refactor service binding/instance controllers tests Split tests into two suites. Each suite only installs the controller to be tested. This allows us to have better control on the test fixture and eliminate unnecessary noise from other controllers . Co-authored-by: Danail Branekov --- .../controller.go} | 2 +- .../services/bindings/controller_test.go | 293 +++++++++++++++ .../services/{ => bindings}/suite_test.go | 19 +- .../cfservicebinding_controller_test.go | 353 ------------------ .../cfserviceinstance_controller_test.go | 276 -------------- .../controller.go} | 5 +- .../services/instances/controller_test.go | 280 ++++++++++++++ .../services/instances/suite_test.go | 92 +++++ .../workloads/testutils/shared_test_utils.go | 8 - controllers/main.go | 7 +- tests/matchers/conditions.go | 42 +++ 11 files changed, 721 insertions(+), 656 deletions(-) rename controllers/controllers/services/{cfservicebinding_controller.go => bindings/controller.go} (99%) create mode 100644 controllers/controllers/services/bindings/controller_test.go rename controllers/controllers/services/{ => bindings}/suite_test.go (82%) delete mode 100644 controllers/controllers/services/cfservicebinding_controller_test.go delete mode 100644 controllers/controllers/services/cfserviceinstance_controller_test.go rename controllers/controllers/services/{cfserviceinstance_controller.go => instances/controller.go} (97%) create mode 100644 controllers/controllers/services/instances/controller_test.go create mode 100644 controllers/controllers/services/instances/suite_test.go create mode 100644 tests/matchers/conditions.go diff --git a/controllers/controllers/services/cfservicebinding_controller.go b/controllers/controllers/services/bindings/controller.go similarity index 99% rename from controllers/controllers/services/cfservicebinding_controller.go rename to controllers/controllers/services/bindings/controller.go index 172601de8..bc9f4f195 100644 --- a/controllers/controllers/services/cfservicebinding_controller.go +++ b/controllers/controllers/services/bindings/controller.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package services +package bindings import ( "context" diff --git a/controllers/controllers/services/bindings/controller_test.go b/controllers/controllers/services/bindings/controller_test.go new file mode 100644 index 000000000..41b3bdafe --- /dev/null +++ b/controllers/controllers/services/bindings/controller_test.go @@ -0,0 +1,293 @@ +package bindings_test + +import ( + "encoding/json" + "fmt" + + korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/controllers/controllers/services/bindings" + . "code.cloudfoundry.org/korifi/controllers/controllers/workloads/testutils" + . "code.cloudfoundry.org/korifi/tests/matchers" + "code.cloudfoundry.org/korifi/tools" + "code.cloudfoundry.org/korifi/tools/k8s" + + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + servicebindingv1beta1 "github.com/servicebinding/runtime/apis/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("CFServiceBinding", func() { + var ( + testNamespace string + cfApp *korifiv1alpha1.CFApp + instance *korifiv1alpha1.CFServiceInstance + binding *korifiv1alpha1.CFServiceBinding + instanceCredentialsSecret *corev1.Secret + ) + + BeforeEach(func() { + testNamespace = uuid.NewString() + Expect(adminClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + })).To(Succeed()) + + cfApp = BuildCFAppCRObject(uuid.NewString(), testNamespace) + Expect( + adminClient.Create(ctx, cfApp), + ).To(Succeed()) + + Expect(k8s.Patch(ctx, adminClient, cfApp, func() { + cfApp.Status = korifiv1alpha1.CFAppStatus{ + VCAPServicesSecretName: "foo", + } + })).To(Succeed()) + + credentialsBytes, err := json.Marshal(map[string]any{ + "type": "my-type", + "provider": "my-provider", + "obj": map[string]any{ + "foo": "bar", + }, + }) + Expect(err).NotTo(HaveOccurred()) + instanceCredentialsSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: testNamespace, + }, + Data: map[string][]byte{ + korifiv1alpha1.CredentialsSecretKey: credentialsBytes, + }, + } + + Expect(adminClient.Create(ctx, instanceCredentialsSecret)).To(Succeed()) + + instance = &korifiv1alpha1.CFServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: testNamespace, + }, + Spec: korifiv1alpha1.CFServiceInstanceSpec{ + DisplayName: "mongodb-service-instance-name", + Type: "user-provided", + Tags: []string{}, + }, + } + Expect(adminClient.Create(ctx, instance)).To(Succeed()) + Expect(k8s.Patch(ctx, adminClient, instance, func() { + instance.Status.Credentials.Name = instanceCredentialsSecret.Name + })).To(Succeed()) + + binding = &korifiv1alpha1.CFServiceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: testNamespace, + }, + Spec: korifiv1alpha1.CFServiceBindingSpec{ + Service: corev1.ObjectReference{ + Kind: "ServiceInstance", + Name: instance.Name, + APIVersion: "korifi.cloudfoundry.org/v1alpha1", + }, + AppRef: corev1.LocalObjectReference{ + Name: cfApp.Name, + }, + }, + } + Expect(adminClient.Create(ctx, binding)).To(Succeed()) + }) + + It("sets the ObservedGeneration status field", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.ObservedGeneration).To(Equal(binding.Generation)) + }).Should(Succeed()) + }) + + It("sets an owner reference from the instance to the binding", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.OwnerReferences).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Name": Equal(instance.Name), + }))) + }).Should(Succeed()) + }) + + It("sets the BindingSecretAvailable condition to true", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(meta.IsStatusConditionTrue(binding.Status.Conditions, bindings.BindingSecretAvailableCondition)).To(BeTrue()) + }).Should(Succeed()) + }) + + It("sets the binding status credentials name to the instance credentials secret", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Credentials.Name).To(Equal(instanceCredentialsSecret.Name)) + }).Should(Succeed()) + }) + + It("creates the binding secret", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Binding.Name).To(Equal(binding.Name)) + + bindingSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: binding.Namespace, + Name: binding.Status.Binding.Name, + }, + } + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(bindingSecret), bindingSecret)).To(Succeed()) + g.Expect(bindingSecret.Type).To(BeEquivalentTo("servicebinding.io/my-type")) + g.Expect(bindingSecret.Data).To(MatchAllKeys(Keys{ + "type": Equal([]byte("my-type")), + "provider": Equal([]byte("my-provider")), + "obj": Equal([]byte(`{"foo":"bar"}`)), + })) + }).Should(Succeed()) + }) + + It("sets the binding status binding name to the binding secret name", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Binding.Name).To(Equal(binding.Name)) + }).Should(Succeed()) + }) + + It("creates a servicebinding.io ServiceBinding", func() { + Eventually(func(g Gomega) { + sbServiceBinding := &servicebindingv1beta1.ServiceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testNamespace, + Name: fmt.Sprintf("cf-binding-%s", binding.Name), + }, + } + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(sbServiceBinding), sbServiceBinding)).To(Succeed()) + + g.Expect(sbServiceBinding.Spec.Name).To(Equal(binding.Name)) + g.Expect(sbServiceBinding.Spec.Type).To(Equal("my-type")) + + g.Expect(sbServiceBinding.Labels).To(SatisfyAll( + HaveKeyWithValue(bindings.ServiceBindingGUIDLabel, binding.Name), + HaveKeyWithValue(korifiv1alpha1.CFAppGUIDLabelKey, cfApp.Name), + HaveKeyWithValue(bindings.ServiceCredentialBindingTypeLabel, "app"), + )) + + g.Expect(sbServiceBinding.OwnerReferences).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("CFServiceBinding"), + "Name": Equal(binding.Name), + }))) + + g.Expect(sbServiceBinding.Spec.Workload).To(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal("apps/v1"), + "Kind": Equal("StatefulSet"), + "Selector": PointTo(Equal(metav1.LabelSelector{ + MatchLabels: map[string]string{ + korifiv1alpha1.CFAppGUIDLabelKey: cfApp.Name, + }, + })), + })) + + g.Expect(sbServiceBinding.Spec.Service).To(MatchFields(IgnoreExtras, Fields{ + "APIVersion": Equal("korifi.cloudfoundry.org/v1alpha1"), + "Kind": Equal("CFServiceBinding"), + "Name": Equal(binding.Name), + })) + }).Should(Succeed()) + }) + + When("the credentials secret is not available", func() { + BeforeEach(func() { + Expect(k8s.Patch(ctx, adminClient, instance, func() { + instance.Status.Credentials.Name = "" + })).To(Succeed()) + }) + + It("sets the BindingSecretAvailable condition to false", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(bindings.BindingSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("CredentialsSecretNotAvailable")), + ))) + }).Should(Succeed()) + }) + }) + + When("the CFServiceBinding has a displayName set", func() { + BeforeEach(func() { + Expect(k8s.PatchResource(ctx, adminClient, binding, func() { + binding.Spec.DisplayName = tools.PtrTo("a-custom-binding-name") + })).To(Succeed()) + }) + + It("sets the displayName as the name on the servicebinding.io ServiceBinding", func() { + Eventually(func(g Gomega) { + sbServiceBinding := &servicebindingv1beta1.ServiceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testNamespace, + Name: fmt.Sprintf("cf-binding-%s", binding.Name), + }, + } + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(sbServiceBinding), sbServiceBinding)).To(Succeed()) + g.Expect(sbServiceBinding.Spec.Name).To(Equal("a-custom-binding-name")) + }).Should(Succeed()) + }) + }) + + When("the binding references a 'legacy' instance credentials secret", func() { + JustBeforeEach(func() { + Expect(k8s.Patch(ctx, adminClient, instance, func() { + instance.Spec.SecretName = instance.Name + instance.Status.Credentials.Name = instance.Name + })).To(Succeed()) + + Expect(k8s.Patch(ctx, adminClient, binding, func() { + binding.Status.Binding.Name = instance.Name + })).To(Succeed()) + }) + + It("sets credentials secret not available condition", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(bindings.BindingSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("FailedReconcilingCredentialsSecret")), + ))) + }).Should(Succeed()) + }) + + When("the referenced legacy binding secret exists", func() { + BeforeEach(func() { + Expect(adminClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name, + Namespace: testNamespace, + }, + })).To(Succeed()) + }) + + It("does not update the binding status", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Binding.Name).To(Equal(instance.Name)) + }).Should(Succeed()) + Consistently(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(binding), binding)).To(Succeed()) + g.Expect(binding.Status.Binding.Name).To(Equal(instance.Name)) + }).Should(Succeed()) + }) + }) + }) +}) diff --git a/controllers/controllers/services/suite_test.go b/controllers/controllers/services/bindings/suite_test.go similarity index 82% rename from controllers/controllers/services/suite_test.go rename to controllers/controllers/services/bindings/suite_test.go index 79bc2bb36..76d151ccb 100644 --- a/controllers/controllers/services/suite_test.go +++ b/controllers/controllers/services/bindings/suite_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package services_test +package bindings_test import ( "context" @@ -23,7 +23,7 @@ import ( "time" korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" - . "code.cloudfoundry.org/korifi/controllers/controllers/services" + "code.cloudfoundry.org/korifi/controllers/controllers/services/bindings" "code.cloudfoundry.org/korifi/controllers/controllers/shared" "code.cloudfoundry.org/korifi/tests/helpers" @@ -51,7 +51,7 @@ func TestAPIs(t *testing.T) { SetDefaultEventuallyPollingInterval(250 * time.Millisecond) RegisterFailHandler(Fail) - RunSpecs(t, "Services Controllers Integration Suite") + RunSpecs(t, "Service Binding Controller Integration Suite") } var _ = BeforeSuite(func() { @@ -61,8 +61,8 @@ var _ = BeforeSuite(func() { testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{ - filepath.Join("..", "..", "..", "helm", "korifi", "controllers", "crds"), - filepath.Join("..", "..", "..", "tests", "vendor", "service-binding"), + filepath.Join("..", "..", "..", "..", "helm", "korifi", "controllers", "crds"), + filepath.Join("..", "..", "..", "..", "tests", "vendor", "service-binding"), }, ErrorIfCRDPathMissing: true, } @@ -78,20 +78,13 @@ var _ = BeforeSuite(func() { adminClient, stopClientCache = helpers.NewCachedClient(testEnv.Config) - err = (NewCFServiceBindingReconciler( + err = (bindings.NewCFServiceBindingReconciler( k8sManager.GetClient(), k8sManager.GetScheme(), ctrl.Log.WithName("controllers").WithName("CFServiceBinding"), )).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) - err = (NewCFServiceInstanceReconciler( - k8sManager.GetClient(), - k8sManager.GetScheme(), - ctrl.Log.WithName("controllers").WithName("CFServiceInstance"), - )).SetupWithManager(k8sManager) - Expect(err).ToNot(HaveOccurred()) - stopManager = helpers.StartK8sManager(k8sManager) }) diff --git a/controllers/controllers/services/cfservicebinding_controller_test.go b/controllers/controllers/services/cfservicebinding_controller_test.go deleted file mode 100644 index e5ba12ae9..000000000 --- a/controllers/controllers/services/cfservicebinding_controller_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package services_test - -import ( - "encoding/json" - "fmt" - - korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" - "code.cloudfoundry.org/korifi/controllers/controllers/services" - . "code.cloudfoundry.org/korifi/controllers/controllers/workloads/testutils" - "code.cloudfoundry.org/korifi/tools/k8s" - - "github.com/google/uuid" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "github.com/onsi/gomega/gstruct" - servicebindingv1beta1 "github.com/servicebinding/runtime/apis/v1beta1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -var _ = Describe("CFServiceBinding", func() { - var ( - namespace *corev1.Namespace - cfAppGUID string - desiredCFApp *korifiv1alpha1.CFApp - cfServiceInstance *korifiv1alpha1.CFServiceInstance - credentialsSecret *corev1.Secret - cfServiceBinding *korifiv1alpha1.CFServiceBinding - cfServiceBindingGUID string - credentialsData map[string]any - ) - - BeforeEach(func() { - namespace = BuildNamespaceObject(GenerateGUID()) - Expect( - adminClient.Create(ctx, namespace), - ).To(Succeed()) - - cfAppGUID = GenerateGUID() - desiredCFApp = BuildCFAppCRObject(cfAppGUID, namespace.Name) - Expect( - adminClient.Create(ctx, desiredCFApp), - ).To(Succeed()) - - Expect(k8s.Patch(ctx, adminClient, desiredCFApp, func() { - desiredCFApp.Status = korifiv1alpha1.CFAppStatus{ - Conditions: nil, - VCAPServicesSecretName: "foo", - VCAPApplicationSecretName: "bar", - } - meta.SetStatusCondition(&desiredCFApp.Status.Conditions, metav1.Condition{ - Type: "Ready", - Status: metav1.ConditionTrue, - Reason: "testing", - }) - })).To(Succeed()) - - credentialsData = map[string]any{ - "type": "my-type", - "provider": "my-provider", - "obj": map[string]any{ - "foo": "bar", - }, - } - credentialsSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: uuid.NewString(), - Namespace: namespace.Name, - }, - } - - cfServiceInstance = &korifiv1alpha1.CFServiceInstance{ - ObjectMeta: metav1.ObjectMeta{ - Name: uuid.NewString(), - Namespace: namespace.Name, - }, - Spec: korifiv1alpha1.CFServiceInstanceSpec{ - DisplayName: "mongodb-service-instance-name", - SecretName: credentialsSecret.Name, - Type: "user-provided", - Tags: []string{}, - }, - } - - cfServiceBindingGUID = GenerateGUID() - cfServiceBinding = &korifiv1alpha1.CFServiceBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: cfServiceBindingGUID, - Namespace: namespace.Name, - }, - Spec: korifiv1alpha1.CFServiceBindingSpec{ - Service: corev1.ObjectReference{ - Kind: "ServiceInstance", - Name: cfServiceInstance.Name, - APIVersion: "korifi.cloudfoundry.org/v1alpha1", - }, - AppRef: corev1.LocalObjectReference{ - Name: cfAppGUID, - }, - }, - } - }) - - JustBeforeEach(func() { - Expect(adminClient.Create(ctx, cfServiceInstance)).To(Succeed()) - Expect(adminClient.Create(ctx, cfServiceBinding)).To(Succeed()) - }) - - It("sets binding secret not available condition", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(cfServiceBinding.Status.Conditions).To(ContainElement(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(services.BindingSecretAvailableCondition), - "Status": Equal(metav1.ConditionFalse), - "Reason": Equal("CredentialsSecretNotAvailable"), - "ObservedGeneration": Equal(cfServiceBinding.Generation), - }))) - }).Should(Succeed()) - }) - - When("the credentials secret is available", func() { - BeforeEach(func() { - credentialsBytes, err := json.Marshal(credentialsData) - Expect(err).NotTo(HaveOccurred()) - credentialsSecret.Data = map[string][]byte{ - korifiv1alpha1.CredentialsSecretKey: credentialsBytes, - } - }) - - JustBeforeEach(func() { - Expect(adminClient.Create(ctx, credentialsSecret)).To(Succeed()) - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - g.Expect(meta.IsStatusConditionTrue(cfServiceInstance.Status.Conditions, services.CredentialsSecretAvailableCondition)).To(BeTrue()) - }).Should(Succeed()) - }) - - It("sets an owner reference from the service instance to the service binding", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - fmt.Fprintf(GinkgoWriter, "cfServiceInstance = %+v\n", cfServiceInstance) - - g.Expect(cfServiceBinding.OwnerReferences).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ - "Name": Equal(cfServiceInstance.Name), - }))) - }).Should(Succeed()) - }) - - It("reconciles the service instance credentials secret", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(meta.IsStatusConditionTrue(cfServiceBinding.Status.Conditions, services.BindingSecretAvailableCondition)).To(BeTrue()) - - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - g.Expect(cfServiceInstance.Status.Credentials.Name).NotTo(BeEmpty()) - }).Should(Succeed()) - - Expect(cfServiceBinding.Status.Credentials.Name).To(Equal(cfServiceInstance.Status.Credentials.Name)) - - Expect(cfServiceBinding.Status.Binding.Name).NotTo(BeEmpty()) - - bindingSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: cfServiceBinding.Namespace, - Name: cfServiceBinding.Status.Binding.Name, - }, - } - Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(bindingSecret), bindingSecret)).To(Succeed()) - Expect(bindingSecret.Type).To(BeEquivalentTo(services.ServiceBindingSecretTypePrefix + "my-type")) - Expect(bindingSecret.Data).To(MatchAllKeys(Keys{ - "type": Equal([]byte("my-type")), - "provider": Equal([]byte("my-provider")), - "obj": Equal([]byte(`{"foo":"bar"}`)), - })) - }) - - It("creates a servicebinding.io ServiceBinding", func() { - Eventually(func(g Gomega) { - sbServiceBinding := servicebindingv1beta1.ServiceBinding{} - g.Expect(adminClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("cf-binding-%s", cfServiceBindingGUID), Namespace: namespace.Name}, &sbServiceBinding)).To(Succeed()) - g.Expect(sbServiceBinding).To(MatchFields(IgnoreExtras, Fields{ - "ObjectMeta": MatchFields(IgnoreExtras, Fields{ - "Name": Equal(fmt.Sprintf("cf-binding-%s", cfServiceBindingGUID)), - "Namespace": Equal(namespace.Name), - "Labels": MatchKeys(IgnoreExtras, Keys{ - services.ServiceBindingGUIDLabel: Equal(cfServiceBindingGUID), - korifiv1alpha1.CFAppGUIDLabelKey: Equal(cfAppGUID), - services.ServiceCredentialBindingTypeLabel: Equal("app"), - }), - "OwnerReferences": ContainElement(MatchFields(IgnoreExtras, Fields{ - "APIVersion": Equal("korifi.cloudfoundry.org/v1alpha1"), - "Kind": Equal("CFServiceBinding"), - "Name": Equal(cfServiceBindingGUID), - })), - }), - "Spec": MatchFields(IgnoreExtras, Fields{ - "Name": Equal(cfServiceBinding.Name), - "Type": Equal("my-type"), - "Provider": Equal("my-provider"), - "Workload": MatchFields(IgnoreExtras, Fields{ - "APIVersion": Equal("apps/v1"), - "Kind": Equal("StatefulSet"), - "Selector": PointTo(MatchFields(IgnoreExtras, Fields{ - "MatchLabels": MatchKeys(IgnoreExtras, Keys{ - korifiv1alpha1.CFAppGUIDLabelKey: Equal(cfAppGUID), - }), - })), - }), - "Service": MatchFields(IgnoreExtras, Fields{ - "APIVersion": Equal("korifi.cloudfoundry.org/v1alpha1"), - "Kind": Equal("CFServiceBinding"), - "Name": Equal(cfServiceBindingGUID), - }), - }), - })) - }).Should(Succeed()) - }) - - It("sets the ObservedGeneration status field", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(cfServiceBinding.Status.ObservedGeneration).To(Equal(cfServiceBinding.Generation)) - }).Should(Succeed()) - }) - - When("the CFServiceBinding has a displayName set", func() { - var bindingName string - - BeforeEach(func() { - cfServiceBindingGUID = GenerateGUID() - bindingName = "a-custom-binding-name" - cfServiceBinding = &korifiv1alpha1.CFServiceBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: cfServiceBindingGUID, - Namespace: namespace.Name, - }, - Spec: korifiv1alpha1.CFServiceBindingSpec{ - DisplayName: &bindingName, - Service: corev1.ObjectReference{ - Kind: "ServiceInstance", - Name: cfServiceInstance.Name, - APIVersion: "korifi.cloudfoundry.org/v1alpha1", - }, - AppRef: corev1.LocalObjectReference{ - Name: cfAppGUID, - }, - }, - } - }) - - It("sets the displayName as the name on the servicebinding.io ServiceBinding", func() { - Eventually(func(g Gomega) { - sbServiceBinding := servicebindingv1beta1.ServiceBinding{} - g.Expect(adminClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("cf-binding-%s", cfServiceBindingGUID), Namespace: namespace.Name}, &sbServiceBinding)).To(Succeed()) - g.Expect(sbServiceBinding).To(MatchFields(IgnoreExtras, Fields{ - "Spec": MatchFields(IgnoreExtras, Fields{ - "Name": Equal(bindingName), - }), - })) - }).Should(Succeed()) - }) - }) - - When("the credentials secret does not exist", func() { - BeforeEach(func() { - cfServiceBinding.Spec.Service.Name = "does-not-exist" - }) - - It("does not set binding name in the service binding status", func() { - Consistently(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(cfServiceBinding.Status.Binding.Name).To(BeEmpty()) - }).Should(Succeed()) - }) - }) - - When("the credentials change", func() { - JustBeforeEach(func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(cfServiceBinding.Status.Binding.Name).To(Equal(cfServiceBinding.Name)) - }).Should(Succeed()) - Expect(k8s.Patch(ctx, adminClient, credentialsSecret, func() { - credentialsSecret.Data = map[string][]byte{ - korifiv1alpha1.CredentialsSecretKey: []byte(`{"type":"my-type","provider": "your-provider"}`), - } - })).To(Succeed()) - }) - - It("updates the binding secret", func() { - Eventually(func(g Gomega) { - bindingSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: cfServiceBinding.Namespace, - Name: cfServiceBinding.Name, - }, - } - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(bindingSecret), bindingSecret)).To(Succeed()) - g.Expect(bindingSecret.Data).To(MatchAllKeys(Keys{ - "type": Equal([]byte("my-type")), - "provider": Equal([]byte("your-provider")), - })) - }).Should(Succeed()) - }) - }) - - When("the service binding references the legacy service instance creadentials secret", func() { - BeforeEach(func() { - credentialsSecret.Name = cfServiceInstance.Name - cfServiceInstance.Spec.SecretName = cfServiceInstance.Name - }) - - JustBeforeEach(func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(cfServiceBinding.Status.Binding.Name).NotTo(BeEmpty()) - }).Should(Succeed()) - Expect(k8s.Patch(ctx, adminClient, cfServiceBinding, func() { - cfServiceBinding.Status.Binding.Name = cfServiceInstance.Name - })).To(Succeed()) - }) - - It("does not create a new binding secret", func() { - Consistently(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - g.Expect(cfServiceBinding.Status.Binding.Name).To(Equal(cfServiceInstance.Name)) - }).Should(Succeed()) - }) - - When("the referenced legacy binding secret cannot be found", func() { - JustBeforeEach(func() { - Expect(adminClient.Delete(ctx, credentialsSecret)).To(Succeed()) - }) - - It("sets credentials secret not available condition", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceBinding), cfServiceBinding)).To(Succeed()) - - g.Expect(cfServiceBinding.Status.Conditions).To(ContainElement(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(services.BindingSecretAvailableCondition), - "Status": Equal(metav1.ConditionFalse), - "Reason": Equal("FailedReconcilingCredentialsSecret"), - "ObservedGeneration": Equal(cfServiceBinding.Generation), - }))) - }).Should(Succeed()) - }) - }) - }) - }) -}) diff --git a/controllers/controllers/services/cfserviceinstance_controller_test.go b/controllers/controllers/services/cfserviceinstance_controller_test.go deleted file mode 100644 index 29e3c0884..000000000 --- a/controllers/controllers/services/cfserviceinstance_controller_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package services_test - -import ( - "context" - "encoding/json" - - . "github.com/onsi/gomega/gstruct" - "sigs.k8s.io/controller-runtime/pkg/client" - - korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" - "code.cloudfoundry.org/korifi/controllers/controllers/services" - . "code.cloudfoundry.org/korifi/controllers/controllers/workloads/testutils" - "code.cloudfoundry.org/korifi/tools/k8s" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -var _ = Describe("CFServiceInstance", func() { - var ( - namespace *corev1.Namespace - credentialsSecret *corev1.Secret - cfServiceInstance *korifiv1alpha1.CFServiceInstance - ) - - BeforeEach(func() { - namespace = BuildNamespaceObject(GenerateGUID()) - Expect( - adminClient.Create(context.Background(), namespace), - ).To(Succeed()) - - credentialsSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret-name", - Namespace: namespace.Name, - }, - Data: map[string][]byte{ - korifiv1alpha1.CredentialsSecretKey: []byte(`{"foo": "bar"}`), - }, - } - - cfServiceInstance = &korifiv1alpha1.CFServiceInstance{ - ObjectMeta: metav1.ObjectMeta{ - Name: "service-instance-guid", - Namespace: namespace.Name, - }, - Spec: korifiv1alpha1.CFServiceInstanceSpec{ - DisplayName: "service-instance-name", - Type: "user-provided", - Tags: []string{}, - SecretName: credentialsSecret.Name, - }, - } - }) - - AfterEach(func() { - Expect(adminClient.Delete(context.Background(), namespace)).To(Succeed()) - }) - - JustBeforeEach(func() { - Expect(adminClient.Create(ctx, credentialsSecret)).To(Succeed()) - Expect(adminClient.Create(context.Background(), cfServiceInstance)).To(Succeed()) - }) - - It("sets the CredentialsSecretAvailable condition to true in the CFServiceInstance status", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(context.Background(), client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - - g.Expect(cfServiceInstance.Status.Conditions).To(ContainElement(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(services.CredentialsSecretAvailableCondition), - "Status": Equal(metav1.ConditionTrue), - "Reason": Equal("SecretFound"), - "Message": Equal(""), - "ObservedGeneration": Equal(cfServiceInstance.Generation), - }))) - g.Expect(cfServiceInstance.Status.Credentials.Name).To(Equal(cfServiceInstance.Spec.SecretName)) - g.Expect(cfServiceInstance.Status.CredentialsObservedVersion).NotTo(BeEmpty()) - }).Should(Succeed()) - }) - - It("sets the ObservedGeneration status field", func() { - Eventually(func(g Gomega) { - updatedCFServiceInstance := new(korifiv1alpha1.CFServiceInstance) - serviceInstanceNamespacedName := client.ObjectKeyFromObject(cfServiceInstance) - g.Expect(adminClient.Get(context.Background(), serviceInstanceNamespacedName, updatedCFServiceInstance)).To(Succeed()) - g.Expect(updatedCFServiceInstance.Status.ObservedGeneration).To(Equal(cfServiceInstance.Generation)) - }).Should(Succeed()) - }) - - When("the credentials secret is invalid", func() { - BeforeEach(func() { - credentialsSecret.Data = map[string][]byte{} - }) - - It("sets credentials secret available condition to false", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(context.Background(), client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - - g.Expect(cfServiceInstance.Status.Conditions).To(ContainElement(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(services.CredentialsSecretAvailableCondition), - "Status": Equal(metav1.ConditionFalse), - "Reason": Equal("SecretInvalid"), - "ObservedGeneration": Equal(cfServiceInstance.Generation), - }))) - }).Should(Succeed()) - }) - }) - - When("the credentials secret changes", func() { - var secretVersion string - - JustBeforeEach(func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(context.Background(), client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - g.Expect(cfServiceInstance.Status.Conditions).To(ContainElement(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(services.CredentialsSecretAvailableCondition), - "Status": Equal(metav1.ConditionTrue), - "Reason": Equal("SecretFound"), - "Message": Equal(""), - }))) - secretVersion = cfServiceInstance.Status.CredentialsObservedVersion - }).Should(Succeed()) - - Expect(k8s.Patch(ctx, adminClient, credentialsSecret, func() { - credentialsSecret.StringData = map[string]string{"f": "b"} - })).To(Succeed()) - }) - - It("updates the credentials secret observed version", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(context.Background(), client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - g.Expect(cfServiceInstance.Status.CredentialsObservedVersion).NotTo(Equal(secretVersion)) - }).Should(Succeed()) - }) - }) - - When("the credentials secret does not exist", func() { - BeforeEach(func() { - cfServiceInstance.Spec.SecretName = "other-secret-name" - }) - - It("sets the CredentialsSecretAvailable condition to false in the CFServiceInstance status", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get( - ctx, - client.ObjectKeyFromObject(cfServiceInstance), - cfServiceInstance, - )).To(Succeed()) - g.Expect(meta.IsStatusConditionFalse( - cfServiceInstance.Status.Conditions, - services.CredentialsSecretAvailableCondition, - )).To(BeTrue()) - }).Should(Succeed()) - }) - }) - - When("the credentials secret gets deleted", func() { - var lastObservedVersion string - - JustBeforeEach(func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get( - ctx, - client.ObjectKeyFromObject(cfServiceInstance), - cfServiceInstance, - )).To(Succeed()) - g.Expect(meta.IsStatusConditionTrue( - cfServiceInstance.Status.Conditions, - services.CredentialsSecretAvailableCondition, - )).To(BeTrue()) - }).Should(Succeed()) - lastObservedVersion = cfServiceInstance.Status.CredentialsObservedVersion - - Expect(adminClient.Delete(ctx, credentialsSecret)).To(Succeed()) - }) - - It("does not change observed credentials secret", func() { - Consistently(func(g Gomega) { - g.Expect(adminClient.Get( - ctx, - client.ObjectKeyFromObject(cfServiceInstance), - cfServiceInstance, - )).To(Succeed()) - g.Expect(cfServiceInstance.Status.Credentials.Name).To(Equal(credentialsSecret.Name)) - g.Expect(cfServiceInstance.Status.CredentialsObservedVersion).To(Equal(lastObservedVersion)) - }).Should(Succeed()) - }) - }) - - Describe("legacy credentials secret reconciliation", func() { - BeforeEach(func() { - credentialsSecret = &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret-name", - Namespace: namespace.Name, - }, - Type: corev1.SecretType( - services.ServiceBindingSecretTypePrefix + "legacy", - ), - StringData: map[string]string{ - "foo": "bar", - }, - } - }) - - It("migrates the secret", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - g.Expect(cfServiceInstance.Status.Credentials.Name).To(Equal(cfServiceInstance.Name + "-migrated")) - - migratedSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: cfServiceInstance.Name + "-migrated", - Namespace: namespace.Name, - }, - } - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(migratedSecret), migratedSecret)).To(Succeed()) - g.Expect(cfServiceInstance.Status.CredentialsObservedVersion).To(Equal(migratedSecret.ResourceVersion)) - g.Expect(migratedSecret.Type).To(Equal(corev1.SecretTypeOpaque)) - g.Expect(migratedSecret.Data).To(MatchAllKeys(Keys{ - korifiv1alpha1.CredentialsSecretKey: Not(BeEmpty()), - })) - g.Expect(migratedSecret.OwnerReferences).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ - "Kind": Equal("CFServiceInstance"), - "Name": Equal(cfServiceInstance.Name), - }))) - - credentials := map[string]any{} - g.Expect(json.Unmarshal(migratedSecret.Data[korifiv1alpha1.CredentialsSecretKey], &credentials)).To(Succeed()) - g.Expect(credentials).To(MatchAllKeys(Keys{ - "foo": Equal("bar"), - })) - }).Should(Succeed()) - }) - - It("does not change the original credentials secret", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - g.Expect(cfServiceInstance.Status.Credentials.Name).NotTo(BeEmpty()) - - g.Expect(cfServiceInstance.Spec.SecretName).To(Equal(credentialsSecret.Name)) - - previousCredentialsVersion := credentialsSecret.ResourceVersion - g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(credentialsSecret), credentialsSecret)).To(Succeed()) - g.Expect(credentialsSecret.ResourceVersion).To(Equal(previousCredentialsVersion)) - }).Should(Succeed()) - }) - - When("legacy secret cannot be migrated", func() { - BeforeEach(func() { - Expect(adminClient.Create(ctx, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: cfServiceInstance.Name + "-migrated", - Namespace: cfServiceInstance.Namespace, - }, - Type: corev1.SecretType("legacy"), - })).To(Succeed()) - }) - - It("sets credentials secret not available status condition", func() { - Eventually(func(g Gomega) { - g.Expect(adminClient.Get(context.Background(), client.ObjectKeyFromObject(cfServiceInstance), cfServiceInstance)).To(Succeed()) - - g.Expect(cfServiceInstance.Status.Conditions).To(ContainElement(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(services.CredentialsSecretAvailableCondition), - "Status": Equal(metav1.ConditionFalse), - "Reason": Equal("FailedReconcilingCredentialsSecret"), - "ObservedGeneration": Equal(cfServiceInstance.Generation), - }))) - }).Should(Succeed()) - }) - }) - }) -}) diff --git a/controllers/controllers/services/cfserviceinstance_controller.go b/controllers/controllers/services/instances/controller.go similarity index 97% rename from controllers/controllers/services/cfserviceinstance_controller.go rename to controllers/controllers/services/instances/controller.go index 16ebbbffc..a80f1c857 100644 --- a/controllers/controllers/services/cfserviceinstance_controller.go +++ b/controllers/controllers/services/instances/controller.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package services +package instances import ( "context" @@ -23,6 +23,7 @@ import ( "time" korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/controllers/controllers/services/bindings" "code.cloudfoundry.org/korifi/controllers/controllers/shared" "code.cloudfoundry.org/korifi/tools/k8s" @@ -160,7 +161,7 @@ func (r *CFServiceInstanceReconciler) ReconcileResource(ctx context.Context, cfS } func (r *CFServiceInstanceReconciler) reconcileCredentials(ctx context.Context, credentialsSecret *corev1.Secret, cfServiceInstance *korifiv1alpha1.CFServiceInstance) (*corev1.Secret, error) { - if !strings.HasPrefix(string(credentialsSecret.Type), ServiceBindingSecretTypePrefix) { + if !strings.HasPrefix(string(credentialsSecret.Type), bindings.ServiceBindingSecretTypePrefix) { return credentialsSecret, nil } diff --git a/controllers/controllers/services/instances/controller_test.go b/controllers/controllers/services/instances/controller_test.go new file mode 100644 index 000000000..160a4d9f6 --- /dev/null +++ b/controllers/controllers/services/instances/controller_test.go @@ -0,0 +1,280 @@ +package instances_test + +import ( + "encoding/json" + + "github.com/google/uuid" + . "github.com/onsi/gomega/gstruct" + "sigs.k8s.io/controller-runtime/pkg/client" + + korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/controllers/controllers/services/instances" + . "code.cloudfoundry.org/korifi/tests/matchers" + "code.cloudfoundry.org/korifi/tools/k8s" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("CFServiceInstance", func() { + var ( + testNamespace string + instance *korifiv1alpha1.CFServiceInstance + ) + + BeforeEach(func() { + testNamespace = uuid.NewString() + Expect(adminClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + })).To(Succeed()) + + instance = &korifiv1alpha1.CFServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: testNamespace, + }, + Spec: korifiv1alpha1.CFServiceInstanceSpec{ + DisplayName: "service-instance-name", + Type: "user-provided", + Tags: []string{}, + }, + } + Expect(adminClient.Create(ctx, instance)).To(Succeed()) + }) + + It("sets the ObservedGeneration status field", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.ObservedGeneration).To(Equal(instance.Generation)) + }).Should(Succeed()) + }) + + It("sets the CredentialsSecretAvailable condition to false", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(instances.CredentialsSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("CredentialsSecretNotAvailable")), + ))) + }).Should(Succeed()) + }) + + When("the credentials secret gets created", func() { + var credentialsSecret *corev1.Secret + + BeforeEach(func() { + credentialsSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: testNamespace, + }, + Data: map[string][]byte{ + korifiv1alpha1.CredentialsSecretKey: []byte(`{"foo": "bar"}`), + }, + } + Expect(adminClient.Create(ctx, credentialsSecret)).To(Succeed()) + + Expect(k8s.PatchResource(ctx, adminClient, instance, func() { + instance.Spec.SecretName = credentialsSecret.Name + })).To(Succeed()) + }) + + It("sets the CredentialsSecretAvailable condition to true", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(instances.CredentialsSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionTrue)), + ))) + }).Should(Succeed()) + }) + + It("sets the instance credentials secret name and observed version", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Credentials.Name).To(Equal(instance.Spec.SecretName)) + g.Expect(instance.Status.CredentialsObservedVersion).NotTo(BeEmpty()) + }).Should(Succeed()) + }) + + When("the credentials secret is invalid", func() { + BeforeEach(func() { + Expect(k8s.PatchResource(ctx, adminClient, credentialsSecret, func() { + credentialsSecret.Data = map[string][]byte{} + })).To(Succeed()) + }) + + It("sets credentials secret available condition to false", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(instances.CredentialsSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("SecretInvalid")), + ))) + }).Should(Succeed()) + }) + }) + + When("the credentials secret changes", func() { + var secretVersion string + + BeforeEach(func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(instances.CredentialsSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionTrue)), + ))) + secretVersion = instance.Status.CredentialsObservedVersion + }).Should(Succeed()) + + Expect(k8s.Patch(ctx, adminClient, credentialsSecret, func() { + credentialsSecret.StringData = map[string]string{"f": "b"} + })).To(Succeed()) + }) + + It("updates the credentials secret observed version", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.CredentialsObservedVersion).NotTo(Equal(secretVersion)) + }).Should(Succeed()) + }) + }) + + When("the credentials secret gets deleted", func() { + var lastObservedVersion string + + BeforeEach(func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(instances.CredentialsSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionTrue)), + ))) + lastObservedVersion = instance.Status.CredentialsObservedVersion + }).Should(Succeed()) + + Expect(adminClient.Delete(ctx, credentialsSecret)).To(Succeed()) + }) + + It("does not change the instance credentials secret name and observed version", func() { + Consistently(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Credentials.Name).To(Equal(credentialsSecret.Name)) + g.Expect(instance.Status.CredentialsObservedVersion).To(Equal(lastObservedVersion)) + }).Should(Succeed()) + }) + }) + }) + + When("the instance credentials secret is in the 'legacy' format", func() { + var credentialsSecret *corev1.Secret + + getMigratedSecret := func() *corev1.Secret { + migratedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name + "-migrated", + Namespace: testNamespace, + }, + } + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(migratedSecret), migratedSecret)).To(Succeed()) + }).Should(Succeed()) + + return migratedSecret + } + + JustBeforeEach(func() { + credentialsSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: testNamespace, + }, + Type: corev1.SecretType("servicebinding.io/legacy"), + StringData: map[string]string{ + "foo": "bar", + }, + } + Expect(adminClient.Create(ctx, credentialsSecret)).To(Succeed()) + + Expect(k8s.PatchResource(ctx, adminClient, instance, func() { + instance.Spec.SecretName = credentialsSecret.Name + })).To(Succeed()) + }) + + It("creates a derived secret in the new format", func() { + Eventually(func(g Gomega) { + migratedSecret := getMigratedSecret() + g.Expect(migratedSecret.Type).To(Equal(corev1.SecretTypeOpaque)) + g.Expect(migratedSecret.Data).To(MatchAllKeys(Keys{ + korifiv1alpha1.CredentialsSecretKey: Not(BeEmpty()), + })) + + credentials := map[string]any{} + g.Expect(json.Unmarshal(migratedSecret.Data[korifiv1alpha1.CredentialsSecretKey], &credentials)).To(Succeed()) + g.Expect(credentials).To(MatchAllKeys(Keys{ + "foo": Equal("bar"), + })) + }).Should(Succeed()) + }) + + It("sets an owner reference from the service instance to the migrated secret", func() { + Eventually(func(g Gomega) { + migratedSecret := getMigratedSecret() + g.Expect(migratedSecret.OwnerReferences).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("CFServiceInstance"), + "Name": Equal(instance.Name), + }))) + }).Should(Succeed()) + }) + + It("sets the instance credentials secret name and observed version to the migrated secret name and version", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Credentials.Name).To(Equal(instance.Name + "-migrated")) + g.Expect(instance.Status.CredentialsObservedVersion).To(Equal(getMigratedSecret().ResourceVersion)) + }).Should(Succeed()) + }) + + It("does not change the original credentials secret", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Credentials.Name).NotTo(BeEmpty()) + + g.Expect(instance.Spec.SecretName).To(Equal(credentialsSecret.Name)) + + previousCredentialsVersion := credentialsSecret.ResourceVersion + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(credentialsSecret), credentialsSecret)).To(Succeed()) + g.Expect(credentialsSecret.ResourceVersion).To(Equal(previousCredentialsVersion)) + }).Should(Succeed()) + }) + + When("legacy secret cannot be migrated", func() { + BeforeEach(func() { + Expect(adminClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name + "-migrated", + Namespace: instance.Namespace, + }, + Type: corev1.SecretType("will-clash-with-migrated-secret-type"), + })).To(Succeed()) + }) + + It("sets the CredentialSecretAvailable condition to false", func() { + Eventually(func(g Gomega) { + g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed()) + g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll( + HasType(Equal(instances.CredentialsSecretAvailableCondition)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("FailedReconcilingCredentialsSecret")), + ))) + }).Should(Succeed()) + }) + }) + }) +}) diff --git a/controllers/controllers/services/instances/suite_test.go b/controllers/controllers/services/instances/suite_test.go new file mode 100644 index 000000000..2909d20f5 --- /dev/null +++ b/controllers/controllers/services/instances/suite_test.go @@ -0,0 +1,92 @@ +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package instances_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/controllers/controllers/services/instances" + "code.cloudfoundry.org/korifi/controllers/controllers/shared" + "code.cloudfoundry.org/korifi/tests/helpers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var ( + ctx context.Context + stopManager context.CancelFunc + stopClientCache context.CancelFunc + testEnv *envtest.Environment + adminClient client.Client +) + +func TestAPIs(t *testing.T) { + SetDefaultEventuallyTimeout(30 * time.Second) + SetDefaultEventuallyPollingInterval(250 * time.Millisecond) + + RegisterFailHandler(Fail) + RunSpecs(t, "Services Instance Controller Integration Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, stopManager = context.WithCancel(context.TODO()) + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "..", "helm", "korifi", "controllers", "crds"), + }, + ErrorIfCRDPathMissing: true, + } + + _, err := testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + + Expect(korifiv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + + k8sManager := helpers.NewK8sManager(testEnv, filepath.Join("helm", "korifi", "controllers", "role.yaml")) + Expect(shared.SetupIndexWithManager(k8sManager)).To(Succeed()) + + adminClient, stopClientCache = helpers.NewCachedClient(testEnv.Config) + + err = (instances.NewCFServiceInstanceReconciler( + k8sManager.GetClient(), + k8sManager.GetScheme(), + ctrl.Log.WithName("controllers").WithName("CFServiceInstance"), + )).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + stopManager = helpers.StartK8sManager(k8sManager) +}) + +var _ = AfterSuite(func() { + stopClientCache() + stopManager() + Expect(testEnv.Stop()).To(Succeed()) +}) diff --git a/controllers/controllers/workloads/testutils/shared_test_utils.go b/controllers/controllers/workloads/testutils/shared_test_utils.go index 002937650..f42a5e45d 100644 --- a/controllers/controllers/workloads/testutils/shared_test_utils.go +++ b/controllers/controllers/workloads/testutils/shared_test_utils.go @@ -30,14 +30,6 @@ func PrefixedGUID(prefix string) string { return prefix + "-" + uuid.NewString()[:8] } -func BuildNamespaceObject(name string) *corev1.Namespace { - return &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - } -} - func BuildCFAppCRObject(appGUID string, spaceGUID string) *korifiv1alpha1.CFApp { return &korifiv1alpha1.CFApp{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controllers/main.go b/controllers/main.go index 58cbfdbd3..180158098 100644 --- a/controllers/main.go +++ b/controllers/main.go @@ -28,7 +28,8 @@ import ( "code.cloudfoundry.org/korifi/controllers/cleanup" "code.cloudfoundry.org/korifi/controllers/config" networkingcontrollers "code.cloudfoundry.org/korifi/controllers/controllers/networking" - servicescontrollers "code.cloudfoundry.org/korifi/controllers/controllers/services" + "code.cloudfoundry.org/korifi/controllers/controllers/services/bindings" + "code.cloudfoundry.org/korifi/controllers/controllers/services/instances" "code.cloudfoundry.org/korifi/controllers/controllers/shared" workloadscontrollers "code.cloudfoundry.org/korifi/controllers/controllers/workloads" "code.cloudfoundry.org/korifi/controllers/controllers/workloads/env" @@ -209,7 +210,7 @@ func main() { os.Exit(1) } - if err = (servicescontrollers.NewCFServiceInstanceReconciler( + if err = (instances.NewCFServiceInstanceReconciler( mgr.GetClient(), mgr.GetScheme(), ctrl.Log.WithName("controllers").WithName("CFServiceInstance"), @@ -218,7 +219,7 @@ func main() { os.Exit(1) } - if err = (servicescontrollers.NewCFServiceBindingReconciler( + if err = (bindings.NewCFServiceBindingReconciler( mgr.GetClient(), mgr.GetScheme(), ctrl.Log.WithName("controllers").WithName("CFServiceBinding"), diff --git a/tests/matchers/conditions.go b/tests/matchers/conditions.go new file mode 100644 index 000000000..3a139c827 --- /dev/null +++ b/tests/matchers/conditions.go @@ -0,0 +1,42 @@ +package matchers + +import ( + "fmt" + + . "github.com/onsi/gomega" //lint:ignore ST1001 this is a test file + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" +) + +func HasType(matcher types.GomegaMatcher) types.GomegaMatcher { + return &conditionMatcher{field: "Type", matcher: matcher} +} + +func HasStatus(matcher types.GomegaMatcher) types.GomegaMatcher { + return &conditionMatcher{field: "Status", matcher: matcher} +} + +func HasReason(matcher types.GomegaMatcher) types.GomegaMatcher { + return &conditionMatcher{field: "Reason", matcher: matcher} +} + +func HasObservedGeneration(matcher types.GomegaMatcher) types.GomegaMatcher { + return &conditionMatcher{field: "ObservedGeneration", matcher: matcher} +} + +type conditionMatcher struct { + field string + matcher types.GomegaMatcher +} + +func (m *conditionMatcher) Match(actual interface{}) (bool, error) { + return HaveField(m.field, m.matcher).Match(actual) +} + +func (m *conditionMatcher) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, fmt.Sprintf("to have field %q ", m.field), m.matcher.FailureMessage(actual)) +} + +func (m *conditionMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, fmt.Sprintf("not to have field %q ", m.field), m.matcher.NegatedFailureMessage(actual)) +}