From dbe6fbfa7bbacb9d7bb780ea6f73586b07276b8c Mon Sep 17 00:00:00 2001 From: Yusmen Zabanov Date: Thu, 9 Jan 2025 16:06:24 +0000 Subject: [PATCH] Support managed binding parameters * Introduce the optional `CFServiceBinding.Spec.Parameters` to reference a secret to store the binding parameters * The parameters secret is created by the binding repository. `kubectl` users should create it themselves if they want to provide such parameters * The binding parameters are sent to the broker on `bind`. If `Spec.Parameters` is not set, no parameters are sent to the broker fixes ##3549 Co-authored-by: Danail Branekov --- api/payloads/service_binding.go | 2 + api/payloads/service_binding_test.go | 147 +++++++++------- .../service_binding_repository.go | 56 +++++- .../service_binding_repository_test.go | 55 ++++-- .../api/v1alpha1/cfservicebinding_types.go | 4 + .../api/v1alpha1/zz_generated.deepcopy.go | 1 + .../services/bindings/controller_test.go | 92 ++++++++++ .../services/bindings/managed/controller.go | 26 +++ ...fi.cloudfoundry.org_cfservicebindings.yaml | 17 ++ tools/credentials.go | 30 ---- tools/credentials_test.go | 73 -------- tools/secrets.go | 58 +++++++ tools/secrets_test.go | 160 ++++++++++++++++++ 13 files changed, 539 insertions(+), 182 deletions(-) delete mode 100644 tools/credentials.go delete mode 100644 tools/credentials_test.go create mode 100644 tools/secrets.go create mode 100644 tools/secrets_test.go diff --git a/api/payloads/service_binding.go b/api/payloads/service_binding.go index 85b42abe7..9989b490d 100644 --- a/api/payloads/service_binding.go +++ b/api/payloads/service_binding.go @@ -13,6 +13,7 @@ type ServiceBindingCreate struct { Relationships *ServiceBindingRelationships `json:"relationships"` Type string `json:"type"` Name *string `json:"name"` + Parameters map[string]any `json:"parameters"` } func (p ServiceBindingCreate) ToMessage(spaceGUID string) repositories.CreateServiceBindingMessage { @@ -21,6 +22,7 @@ func (p ServiceBindingCreate) ToMessage(spaceGUID string) repositories.CreateSer ServiceInstanceGUID: p.Relationships.ServiceInstance.Data.GUID, AppGUID: p.Relationships.App.Data.GUID, SpaceGUID: spaceGUID, + Parameters: p.Parameters, } } diff --git a/api/payloads/service_binding_test.go b/api/payloads/service_binding_test.go index 593eaf3ac..67ed98b14 100644 --- a/api/payloads/service_binding_test.go +++ b/api/payloads/service_binding_test.go @@ -5,9 +5,10 @@ import ( "code.cloudfoundry.org/korifi/api/payloads" "code.cloudfoundry.org/korifi/api/repositories" "code.cloudfoundry.org/korifi/tools" + "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/onsi/gomega/gstruct" + . "github.com/onsi/gomega/gstruct" ) var _ = Describe("ServiceBindingList", func() { @@ -57,16 +58,11 @@ var _ = Describe("ServiceBindingList", func() { }) var _ = Describe("ServiceBindingCreate", func() { - var ( - createPayload payloads.ServiceBindingCreate - serviceBindingCreate *payloads.ServiceBindingCreate - validatorErr error - apiError errors.ApiError - ) + var createPayload payloads.ServiceBindingCreate BeforeEach(func() { - serviceBindingCreate = new(payloads.ServiceBindingCreate) createPayload = payloads.ServiceBindingCreate{ + Name: tools.PtrTo(uuid.NewString()), Relationships: &payloads.ServiceBindingRelationships{ App: &payloads.Relationship{ Data: &payloads.RelationshipData{ @@ -80,82 +76,117 @@ var _ = Describe("ServiceBindingCreate", func() { }, }, Type: "app", + Parameters: map[string]any{ + "p1": "p1-value", + }, } }) - JustBeforeEach(func() { - validatorErr = validator.DecodeAndValidateJSONPayload(createJSONRequest(createPayload), serviceBindingCreate) - apiError, _ = validatorErr.(errors.ApiError) - }) - - It("succeeds", func() { - Expect(validatorErr).NotTo(HaveOccurred()) - Expect(serviceBindingCreate).To(gstruct.PointTo(Equal(createPayload))) - }) + Describe("Validation", func() { + var ( + serviceBindingCreate *payloads.ServiceBindingCreate + validatorErr error + apiError errors.ApiError + ) - When(`the type is "key"`, func() { BeforeEach(func() { - createPayload.Type = "key" + serviceBindingCreate = new(payloads.ServiceBindingCreate) }) - It("fails", func() { - Expect(apiError).To(HaveOccurred()) - Expect(apiError.Detail()).To(ContainSubstring("type value must be one of: app")) + JustBeforeEach(func() { + validatorErr = validator.DecodeAndValidateJSONPayload(createJSONRequest(createPayload), serviceBindingCreate) + apiError, _ = validatorErr.(errors.ApiError) }) - }) - When("all relationships are missing", func() { - BeforeEach(func() { - createPayload.Relationships = nil + It("succeeds", func() { + Expect(validatorErr).NotTo(HaveOccurred()) + Expect(serviceBindingCreate).To(PointTo(Equal(createPayload))) }) - It("fails", func() { - Expect(apiError).To(HaveOccurred()) - Expect(apiError.Detail()).To(ContainSubstring("relationships is required")) - }) - }) + When(`the type is "key"`, func() { + BeforeEach(func() { + createPayload.Type = "key" + }) - When("app relationship is missing", func() { - BeforeEach(func() { - createPayload.Relationships.App = nil + It("fails", func() { + Expect(apiError).To(HaveOccurred()) + Expect(apiError.Detail()).To(ContainSubstring("type value must be one of: app")) + }) }) - It("fails", func() { - Expect(apiError).To(HaveOccurred()) - Expect(apiError.Detail()).To(ContainSubstring("relationships.app is required")) + When("all relationships are missing", func() { + BeforeEach(func() { + createPayload.Relationships = nil + }) + + It("fails", func() { + Expect(apiError).To(HaveOccurred()) + Expect(apiError.Detail()).To(ContainSubstring("relationships is required")) + }) }) - }) - When("the app GUID is blank", func() { - BeforeEach(func() { - createPayload.Relationships.App.Data.GUID = "" + When("app relationship is missing", func() { + BeforeEach(func() { + createPayload.Relationships.App = nil + }) + + It("fails", func() { + Expect(apiError).To(HaveOccurred()) + Expect(apiError.Detail()).To(ContainSubstring("relationships.app is required")) + }) }) - It("fails", func() { - Expect(apiError).To(HaveOccurred()) - Expect(apiError.Detail()).To(ContainSubstring("app.data.guid cannot be blank")) + When("the app GUID is blank", func() { + BeforeEach(func() { + createPayload.Relationships.App.Data.GUID = "" + }) + + It("fails", func() { + Expect(apiError).To(HaveOccurred()) + Expect(apiError.Detail()).To(ContainSubstring("app.data.guid cannot be blank")) + }) }) - }) - When("service instance relationship is missing", func() { - BeforeEach(func() { - createPayload.Relationships.ServiceInstance = nil + When("service instance relationship is missing", func() { + BeforeEach(func() { + createPayload.Relationships.ServiceInstance = nil + }) + + It("fails", func() { + Expect(apiError).To(HaveOccurred()) + Expect(apiError.Detail()).To(ContainSubstring("relationships.service_instance is required")) + }) }) - It("fails", func() { - Expect(apiError).To(HaveOccurred()) - Expect(apiError.Detail()).To(ContainSubstring("relationships.service_instance is required")) + When("the service instance GUID is blank", func() { + BeforeEach(func() { + createPayload.Relationships.ServiceInstance.Data.GUID = "" + }) + + It("fails", func() { + Expect(apiError).To(HaveOccurred()) + Expect(apiError.Detail()).To(ContainSubstring("relationships.service_instance.data.guid cannot be blank")) + }) }) }) - When("the service instance GUID is blank", func() { - BeforeEach(func() { - createPayload.Relationships.ServiceInstance.Data.GUID = "" + Describe("ToMessage", func() { + var createMessage repositories.CreateServiceBindingMessage + + JustBeforeEach(func() { + createMessage = createPayload.ToMessage("space-guid") }) - It("fails", func() { - Expect(apiError).To(HaveOccurred()) - Expect(apiError.Detail()).To(ContainSubstring("relationships.service_instance.data.guid cannot be blank")) + It("creates the message", func() { + Expect(createMessage).To(Equal(repositories.CreateServiceBindingMessage{ + Name: tools.PtrTo(*createPayload.Name), + ServiceInstanceGUID: createPayload.Relationships.ServiceInstance.Data.GUID, + AppGUID: createPayload.Relationships.App.Data.GUID, + SpaceGUID: "space-guid", + Parameters: map[string]any{ + "p1": "p1-value", + }, + })) }) }) }) @@ -185,7 +216,7 @@ var _ = Describe("ServiceBindingUpdate", func() { It("succeeds", func() { Expect(validatorErr).NotTo(HaveOccurred()) - Expect(serviceBindingPatch).To(gstruct.PointTo(Equal(patchPayload))) + Expect(serviceBindingPatch).To(PointTo(Equal(patchPayload))) }) When("metadata uses the cloudfoundry domain", func() { diff --git a/api/repositories/service_binding_repository.go b/api/repositories/service_binding_repository.go index a07669522..6732c0a1b 100644 --- a/api/repositories/service_binding_repository.go +++ b/api/repositories/service_binding_repository.go @@ -8,6 +8,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" "code.cloudfoundry.org/korifi/api/authorization" apierrors "code.cloudfoundry.org/korifi/api/errors" @@ -25,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) const ( @@ -87,6 +89,7 @@ type CreateServiceBindingMessage struct { ServiceInstanceGUID string AppGUID string SpaceGUID string + Parameters map[string]any } type DeleteServiceBindingMessage struct { @@ -106,8 +109,8 @@ func (m *ListServiceBindingsMessage) matches(serviceBinding korifiv1alpha1.CFSer tools.EmptyOrContains(m.PlanGUIDs, serviceBinding.Labels[korifiv1alpha1.PlanGUIDLabelKey]) } -func (m CreateServiceBindingMessage) toCFServiceBinding() *korifiv1alpha1.CFServiceBinding { - return &korifiv1alpha1.CFServiceBinding{ +func (m CreateServiceBindingMessage) toCFServiceBinding(instanceType korifiv1alpha1.InstanceType) *korifiv1alpha1.CFServiceBinding { + binding := &korifiv1alpha1.CFServiceBinding{ ObjectMeta: metav1.ObjectMeta{ Name: uuid.NewString(), Namespace: m.SpaceGUID, @@ -123,6 +126,12 @@ func (m CreateServiceBindingMessage) toCFServiceBinding() *korifiv1alpha1.CFServ AppRef: corev1.LocalObjectReference{Name: m.AppGUID}, }, } + + if instanceType == korifiv1alpha1.ManagedType { + binding.Spec.Parameters.Name = uuid.NewString() + } + + return binding } type UpdateServiceBindingMessage struct { @@ -136,10 +145,20 @@ func (r *ServiceBindingRepo) CreateServiceBinding(ctx context.Context, authInfo return ServiceBindingRecord{}, fmt.Errorf("failed to build user client: %w", err) } - cfServiceBinding := message.toCFServiceBinding() + cfServiceInstance := new(korifiv1alpha1.CFServiceInstance) + err = userClient.Get(ctx, types.NamespacedName{Name: message.ServiceInstanceGUID, Namespace: message.SpaceGUID}, cfServiceInstance) + if err != nil { + return ServiceBindingRecord{}, + apierrors.AsUnprocessableEntity( + apierrors.FromK8sError(err, ServiceBindingResourceType), + "Unable to bind to instance. Ensure that the instance exists and you have access to it.", + apierrors.ForbiddenError{}, + apierrors.NotFoundError{}, + ) + } cfApp := new(korifiv1alpha1.CFApp) - err = userClient.Get(ctx, types.NamespacedName{Name: cfServiceBinding.Spec.AppRef.Name, Namespace: cfServiceBinding.Namespace}, cfApp) + err = userClient.Get(ctx, types.NamespacedName{Name: message.AppGUID, Namespace: message.SpaceGUID}, cfApp) if err != nil { return ServiceBindingRecord{}, apierrors.AsUnprocessableEntity( @@ -150,6 +169,7 @@ func (r *ServiceBindingRepo) CreateServiceBinding(ctx context.Context, authInfo ) } + cfServiceBinding := message.toCFServiceBinding(cfServiceInstance.Spec.Type) err = userClient.Create(ctx, cfServiceBinding) if err != nil { if validationError, ok := validation.WebhookErrorToValidationError(err); ok { @@ -161,10 +181,11 @@ func (r *ServiceBindingRepo) CreateServiceBinding(ctx context.Context, authInfo return ServiceBindingRecord{}, apierrors.FromK8sError(err, ServiceBindingResourceType) } - cfServiceInstance := new(korifiv1alpha1.CFServiceInstance) - err = userClient.Get(ctx, types.NamespacedName{Name: cfServiceBinding.Spec.Service.Name, Namespace: cfServiceBinding.Namespace}, cfServiceInstance) - if err != nil { - return ServiceBindingRecord{}, fmt.Errorf("failed to get service instance: %w", err) + if cfServiceInstance.Spec.Type == korifiv1alpha1.ManagedType { + err = r.createParametersSecret(ctx, userClient, cfServiceBinding, message.Parameters) + if err != nil { + return ServiceBindingRecord{}, apierrors.FromK8sError(err, ServiceBindingResourceType) + } } if cfServiceInstance.Spec.Type == korifiv1alpha1.UserProvidedType { @@ -177,6 +198,25 @@ func (r *ServiceBindingRepo) CreateServiceBinding(ctx context.Context, authInfo return serviceBindingToRecord(*cfServiceBinding), nil } +func (r *ServiceBindingRepo) createParametersSecret(ctx context.Context, userClient client.Client, cfServiceBinding *korifiv1alpha1.CFServiceBinding, parameters map[string]any) error { + parametersData, err := tools.ToParametersSecretData(parameters) + if err != nil { + return err + } + + paramsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: cfServiceBinding.Namespace, + Name: cfServiceBinding.Spec.Parameters.Name, + }, + Data: parametersData, + } + + _ = controllerutil.SetOwnerReference(cfServiceBinding, paramsSecret, scheme.Scheme) + + return userClient.Create(ctx, paramsSecret) +} + func (r *ServiceBindingRepo) DeleteServiceBinding(ctx context.Context, authInfo authorization.Info, guid string) error { userClient, err := r.userClientFactory.BuildClient(authInfo) if err != nil { diff --git a/api/repositories/service_binding_repository_test.go b/api/repositories/service_binding_repository_test.go index 5f5c60049..604224244 100644 --- a/api/repositories/service_binding_repository_test.go +++ b/api/repositories/service_binding_repository_test.go @@ -273,7 +273,7 @@ var _ = Describe("ServiceBindingRepo", func() { }) }) - It("returns a forbidden error", func() { + It("returns a unprocessable entity error", func() { Expect(createErr).To(BeAssignableToTypeOf(apierrors.UnprocessableEntityError{})) }) @@ -523,6 +523,9 @@ var _ = Describe("ServiceBindingRepo", func() { ServiceInstanceGUID: cfServiceInstance.Name, AppGUID: appGUID, SpaceGUID: space.Name, + Parameters: map[string]any{ + "p1": "p1-value", + }, }) }) @@ -558,19 +561,45 @@ var _ = Describe("ServiceBindingRepo", func() { ).To(Succeed()) Expect(serviceBinding.Labels).To(HaveKeyWithValue("servicebinding.io/provisioned-service", "true")) - Expect(serviceBinding.Spec).To(Equal( - korifiv1alpha1.CFServiceBindingSpec{ - DisplayName: nil, - Service: corev1.ObjectReference{ - Kind: "CFServiceInstance", - APIVersion: korifiv1alpha1.SchemeGroupVersion.Identifier(), - Name: cfServiceInstance.Name, - }, - AppRef: corev1.LocalObjectReference{ - Name: appGUID, - }, + Expect(serviceBinding.Spec).To(MatchFields(IgnoreExtras, Fields{ + "DisplayName": BeNil(), + "Service": MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("CFServiceInstance"), + "Name": Equal(cfServiceInstance.Name), + }), + "AppRef": Equal(corev1.LocalObjectReference{ + Name: appGUID, + }), + "Parameters": MatchAllFields(Fields{ + "Name": Not(BeEmpty()), + }), + })) + }) + + It("creates the parameters secret", func() { + serviceBinding := new(korifiv1alpha1.CFServiceBinding) + Expect( + k8sClient.Get(ctx, types.NamespacedName{Name: serviceBindingRecord.GUID, Namespace: space.Name}, serviceBinding), + ).To(Succeed()) + + paramsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: serviceBinding.Namespace, + Name: serviceBinding.Spec.Parameters.Name, }, - )) + } + Expect( + k8sClient.Get(ctx, client.ObjectKeyFromObject(paramsSecret), paramsSecret), + ).To(Succeed()) + + Expect(paramsSecret.Data).To(Equal(map[string][]byte{ + tools.ParametersSecretKey: []byte(`{"p1":"p1-value"}`), + })) + + Expect(paramsSecret.OwnerReferences).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("CFServiceBinding"), + "Name": Equal(serviceBinding.Name), + }))) }) When("the app does not exist", func() { diff --git a/controllers/api/v1alpha1/cfservicebinding_types.go b/controllers/api/v1alpha1/cfservicebinding_types.go index c8b692f24..d2d21532b 100644 --- a/controllers/api/v1alpha1/cfservicebinding_types.go +++ b/controllers/api/v1alpha1/cfservicebinding_types.go @@ -46,6 +46,10 @@ type CFServiceBindingSpec struct { // A reference to the CFApp that owns this service binding. The CFApp must be in the same namespace AppRef v1.LocalObjectReference `json:"appRef"` + + // A reference to the secret that contains the service binding parameters. + // Only makes sense for bindings to managed service instances + Parameters v1.LocalObjectReference `json:"parameters"` } // CFServiceBindingStatus defines the observed state of CFServiceBinding diff --git a/controllers/api/v1alpha1/zz_generated.deepcopy.go b/controllers/api/v1alpha1/zz_generated.deepcopy.go index fda821d7b..50bcc6af9 100644 --- a/controllers/api/v1alpha1/zz_generated.deepcopy.go +++ b/controllers/api/v1alpha1/zz_generated.deepcopy.go @@ -1245,6 +1245,7 @@ func (in *CFServiceBindingSpec) DeepCopyInto(out *CFServiceBindingSpec) { } out.Service = in.Service out.AppRef = in.AppRef + out.Parameters = in.Parameters } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFServiceBindingSpec. diff --git a/controllers/controllers/services/bindings/controller_test.go b/controllers/controllers/services/bindings/controller_test.go index 0144202da..65db40a93 100644 --- a/controllers/controllers/services/bindings/controller_test.go +++ b/controllers/controllers/services/bindings/controller_test.go @@ -628,6 +628,98 @@ var _ = Describe("CFServiceBinding", func() { }).Should(Succeed()) }) + When("the binding has parameters", func() { + var paramsSecret *corev1.Secret + + BeforeEach(func() { + paramsSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: binding.Namespace, + Name: uuid.NewString(), + }, + Data: map[string][]byte{ + tools.ParametersSecretKey: []byte(`{"p1":"p1-value"}`), + }, + } + Expect(adminClient.Create(ctx, paramsSecret)).To(Succeed()) + + Expect(k8s.Patch(ctx, adminClient, binding, func() { + binding.Spec.Parameters.Name = paramsSecret.Name + })).To(Succeed()) + }) + + It("sends them to the broker on bind", func() { + Eventually(func(g Gomega) { + g.Expect(brokerClient.BindCallCount()).To(BeNumerically(">", 0)) + _, payload := brokerClient.BindArgsForCall(0) + g.Expect(payload.Parameters).To(Equal(map[string]any{ + "p1": "p1-value", + })) + }).Should(Succeed()) + }) + + When("the parameters secret does not exist", func() { + BeforeEach(func() { + Expect(k8s.PatchResource(ctx, adminClient, binding, func() { + binding.Spec.Parameters.Name = "not-valid" + })).To(Succeed()) + }) + + It("sets the ready 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(korifiv1alpha1.StatusConditionReady)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("InvalidParameters")), + ))) + }).Should(Succeed()) + }) + }) + + When("the parameters secret data is missing the parameters key", func() { + BeforeEach(func() { + Expect(k8s.PatchResource(ctx, adminClient, paramsSecret, func() { + paramsSecret.Data = map[string][]byte{ + "foo": []byte("bar"), + } + })).To(Succeed()) + }) + + It("sets the ready 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(korifiv1alpha1.StatusConditionReady)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("InvalidParameters")), + ))) + }).Should(Succeed()) + }) + }) + + When("the parameters are invalid", func() { + BeforeEach(func() { + Expect(k8s.PatchResource(ctx, adminClient, paramsSecret, func() { + paramsSecret.Data = map[string][]byte{ + tools.ParametersSecretKey: []byte("invalid-json"), + } + })).To(Succeed()) + }) + + It("sets the ready 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(korifiv1alpha1.StatusConditionReady)), + HasStatus(Equal(metav1.ConditionFalse)), + HasReason(Equal("InvalidParameters")), + ))) + }).Should(Succeed()) + }) + }) + }) + It("does not check for binding last operation", func() { Consistently(func(g Gomega) { g.Expect(brokerClient.GetServiceBindingLastOperationCallCount()).To(BeZero()) diff --git a/controllers/controllers/services/bindings/managed/controller.go b/controllers/controllers/services/bindings/managed/controller.go index f77354ae3..6f426342b 100644 --- a/controllers/controllers/services/bindings/managed/controller.go +++ b/controllers/controllers/services/bindings/managed/controller.go @@ -110,6 +110,11 @@ func (r *ManagedBindingsReconciler) bind( ) (osbapi.BindResponse, error) { log := logr.FromContextOrDiscard(ctx) + parameters, err := r.getParameters(ctx, cfServiceBinding) + if err != nil { + return osbapi.BindResponse{}, k8s.NewNotReadyError().WithReason("InvalidParameters") + } + bindResponse, err := osbapiClient.Bind(ctx, osbapi.BindPayload{ BindingID: cfServiceBinding.Name, InstanceID: assets.ServiceInstance.Name, @@ -120,6 +125,7 @@ func (r *ManagedBindingsReconciler) bind( BindResource: osbapi.BindResource{ AppGUID: cfServiceBinding.Spec.AppRef.Name, }, + Parameters: parameters, }, }) if err != nil { @@ -143,6 +149,26 @@ func (r *ManagedBindingsReconciler) bind( return bindResponse, nil } +func (r *ManagedBindingsReconciler) getParameters(ctx context.Context, cfServiceBinding *korifiv1alpha1.CFServiceBinding) (map[string]any, error) { + if cfServiceBinding.Spec.Parameters.Name == "" { + return nil, nil + } + + paramsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: cfServiceBinding.Namespace, + Name: cfServiceBinding.Spec.Parameters.Name, + }, + } + + err := r.k8sClient.Get(ctx, client.ObjectKeyFromObject(paramsSecret), paramsSecret) + if err != nil { + return nil, err + } + + return tools.FromParametersSecretData(paramsSecret.Data) +} + func (r *ManagedBindingsReconciler) processBindOperation( cfServiceBinding *korifiv1alpha1.CFServiceBinding, lastOperation osbapi.LastOperationResponse, diff --git a/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfservicebindings.yaml b/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfservicebindings.yaml index aad66dbdd..6c7130f88 100644 --- a/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfservicebindings.yaml +++ b/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfservicebindings.yaml @@ -65,6 +65,22 @@ spec: description: The mutable, user-friendly name of the service binding. Unlike metadata.name, the user can change this field type: string + parameters: + description: |- + A reference to the secret that contains the service binding parameters. + Only makes sense for bindings to managed service instances + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic service: description: The Service this binding uses. When created by the korifi API, this will refer to a CFServiceInstance @@ -111,6 +127,7 @@ spec: x-kubernetes-map-type: atomic required: - appRef + - parameters - service type: object status: diff --git a/tools/credentials.go b/tools/credentials.go deleted file mode 100644 index 87ba6bda7..000000000 --- a/tools/credentials.go +++ /dev/null @@ -1,30 +0,0 @@ -package tools - -import ( - "encoding/json" - "errors" -) - -const CredentialsSecretKey = "credentials" - -func ToCredentialsSecretData(credentials any) (map[string][]byte, error) { - var credentialBytes []byte - credentialBytes, err := json.Marshal(credentials) - if err != nil { - return nil, errors.New("failed to marshal credentials for service instance") - } - - return map[string][]byte{ - CredentialsSecretKey: credentialBytes, - }, nil -} - -func FromCredentialsSecretData(data map[string][]byte) (map[string]any, error) { - var credentials map[string]any - err := json.Unmarshal(data[CredentialsSecretKey], &credentials) - if err != nil { - return nil, errors.New("failed to unmarshal credentials for service instance") - } - - return credentials, nil -} diff --git a/tools/credentials_test.go b/tools/credentials_test.go deleted file mode 100644 index 8739b7c7d..000000000 --- a/tools/credentials_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package tools_test - -import ( - "code.cloudfoundry.org/korifi/tools" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "github.com/onsi/gomega/gstruct" -) - -type credsType struct { - Foo string - Bar struct { - InBar string - } -} - -var _ = Describe("Credentials", func() { - var ( - credsObject any - err error - ) - - BeforeEach(func() { - credsObject = credsType{ - Foo: "foo", - Bar: struct{ InBar string }{ - InBar: "in-bar", - }, - } - }) - - Describe("ToCredentialsSecretData", func() { - var secretData map[string][]byte - - BeforeEach(func() { - secretData, err = tools.ToCredentialsSecretData(credsObject) - }) - - It("successfully creates credentials secret data", func() { - Expect(err).NotTo(HaveOccurred()) - Expect(secretData).To(MatchAllKeys(Keys{ - tools.CredentialsSecretKey: MatchJSON(`{ - "Foo": "foo", - "Bar": { - "InBar": "in-bar" - } - }`), - })) - }) - }) - - Describe("FromCredentialsSecretData", func() { - var ( - decodedCredentials map[string]any - secretData map[string][]byte - ) - BeforeEach(func() { - secretData, err = tools.ToCredentialsSecretData(credsObject) - Expect(err).NotTo(HaveOccurred()) - }) - - JustBeforeEach(func() { - decodedCredentials, err = tools.FromCredentialsSecretData(secretData) - Expect(err).NotTo(HaveOccurred()) - }) - - It("successfully decodes credentials from secret data", func() { - Expect(decodedCredentials).To(HaveKeyWithValue("Foo", "foo")) - Expect(decodedCredentials).To(HaveKey("Bar")) - Expect(decodedCredentials["Bar"]).To(HaveKeyWithValue("InBar", "in-bar")) - }) - }) -}) diff --git a/tools/secrets.go b/tools/secrets.go new file mode 100644 index 000000000..7cdb0799b --- /dev/null +++ b/tools/secrets.go @@ -0,0 +1,58 @@ +package tools + +import ( + "encoding/json" + "errors" + "fmt" +) + +const ( + CredentialsSecretKey = "credentials" + ParametersSecretKey = "parameters" +) + +func ToCredentialsSecretData(credentials any) (map[string][]byte, error) { + return toSecretData(CredentialsSecretKey, credentials) +} + +func ToParametersSecretData(credentials any) (map[string][]byte, error) { + return toSecretData(ParametersSecretKey, credentials) +} + +func toSecretData(key string, value any) (map[string][]byte, error) { + var valueBytes []byte + valueBytes, err := json.Marshal(value) + if err != nil { + return nil, errors.New("failed to marshal secret value") + } + + return map[string][]byte{ + key: valueBytes, + }, nil +} + +func FromCredentialsSecretData(data map[string][]byte) (map[string]any, error) { + value, err := fromSecretData(CredentialsSecretKey, data) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %v", err) + } + return value, nil +} + +func FromParametersSecretData(data map[string][]byte) (map[string]any, error) { + value, err := fromSecretData(ParametersSecretKey, data) + if err != nil { + return nil, fmt.Errorf("failed to get parameters: %v", err) + } + return value, nil +} + +func fromSecretData(key string, data map[string][]byte) (map[string]any, error) { + var value map[string]any + err := json.Unmarshal(data[key], &value) + if err != nil { + return nil, err + } + + return value, nil +} diff --git a/tools/secrets_test.go b/tools/secrets_test.go new file mode 100644 index 000000000..b1dc694f4 --- /dev/null +++ b/tools/secrets_test.go @@ -0,0 +1,160 @@ +package tools_test + +import ( + "code.cloudfoundry.org/korifi/tools" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" +) + +type dataType struct { + Foo string + Bar struct { + InBar string + } +} + +var _ = Describe("Secrets", func() { + Describe("Credentials", func() { + var ( + credsObject any + secretData map[string][]byte + ) + + BeforeEach(func() { + credsObject = dataType{ + Foo: "foo", + Bar: struct{ InBar string }{ + InBar: "in-bar", + }, + } + }) + + Describe("ToCredentialsSecretData", func() { + JustBeforeEach(func() { + var err error + secretData, err = tools.ToCredentialsSecretData(credsObject) + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates credentials secret data", func() { + Expect(secretData).To(MatchAllKeys(Keys{ + tools.CredentialsSecretKey: MatchJSON(`{ + "Foo": "foo", + "Bar": { + "InBar": "in-bar" + } + }`), + })) + }) + }) + + Describe("FromCredentialsSecretData", func() { + var ( + decodedCredentials map[string]any + decodeErr error + ) + + BeforeEach(func() { + var err error + secretData, err = tools.ToCredentialsSecretData(credsObject) + Expect(err).NotTo(HaveOccurred()) + }) + + JustBeforeEach(func() { + decodedCredentials, decodeErr = tools.FromCredentialsSecretData(secretData) + }) + + It("successfully decodes credentials from secret data", func() { + Expect(decodeErr).NotTo(HaveOccurred()) + + Expect(decodedCredentials).To(HaveKeyWithValue("Foo", "foo")) + Expect(decodedCredentials).To(HaveKey("Bar")) + Expect(decodedCredentials["Bar"]).To(HaveKeyWithValue("InBar", "in-bar")) + }) + + When("the secret data is missing the credentials key", func() { + BeforeEach(func() { + secretData = map[string][]byte{ + "foo": {}, + } + }) + + It("returns an error", func() { + Expect(decodeErr).To(MatchError(ContainSubstring("failed to get credentials"))) + }) + }) + }) + }) + + Describe("Parameters", func() { + var ( + paramsObject any + secretData map[string][]byte + ) + BeforeEach(func() { + paramsObject = dataType{ + Foo: "foo", + Bar: struct{ InBar string }{ + InBar: "in-bar", + }, + } + }) + + Describe("ToParametersSecretData", func() { + JustBeforeEach(func() { + var err error + secretData, err = tools.ToParametersSecretData(paramsObject) + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates parameters secret data", func() { + Expect(secretData).To(MatchAllKeys(Keys{ + tools.ParametersSecretKey: MatchJSON(`{ + "Foo": "foo", + "Bar": { + "InBar": "in-bar" + } + }`), + })) + }) + }) + + Describe("FromCredentialsSecretData", func() { + var ( + decodedParameters map[string]any + decodeErr error + ) + + BeforeEach(func() { + var err error + secretData, err = tools.ToParametersSecretData(paramsObject) + Expect(err).NotTo(HaveOccurred()) + }) + + JustBeforeEach(func() { + decodedParameters, decodeErr = tools.FromParametersSecretData(secretData) + }) + + It("successfully decodes parameters from secret data", func() { + Expect(decodeErr).NotTo(HaveOccurred()) + + Expect(decodedParameters).To(HaveKeyWithValue("Foo", "foo")) + Expect(decodedParameters).To(HaveKey("Bar")) + Expect(decodedParameters["Bar"]).To(HaveKeyWithValue("InBar", "in-bar")) + }) + + When("the secret data is missing the parameters key", func() { + BeforeEach(func() { + secretData = map[string][]byte{ + "foo": {}, + } + }) + + It("returns an error", func() { + Expect(decodeErr).To(MatchError(ContainSubstring("failed to get parameters"))) + }) + }) + }) + }) +})