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