diff --git a/api/handlers/fake/cfservice_plan_repository.go b/api/handlers/fake/cfservice_plan_repository.go new file mode 100644 index 000000000..246a4a562 --- /dev/null +++ b/api/handlers/fake/cfservice_plan_repository.go @@ -0,0 +1,121 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fake + +import ( + "context" + "sync" + + "code.cloudfoundry.org/korifi/api/authorization" + "code.cloudfoundry.org/korifi/api/handlers" + "code.cloudfoundry.org/korifi/api/repositories" +) + +type CFServicePlanRepository struct { + ListPlansStub func(context.Context, authorization.Info) ([]repositories.ServicePlanResource, error) + listPlansMutex sync.RWMutex + listPlansArgsForCall []struct { + arg1 context.Context + arg2 authorization.Info + } + listPlansReturns struct { + result1 []repositories.ServicePlanResource + result2 error + } + listPlansReturnsOnCall map[int]struct { + result1 []repositories.ServicePlanResource + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *CFServicePlanRepository) ListPlans(arg1 context.Context, arg2 authorization.Info) ([]repositories.ServicePlanResource, error) { + fake.listPlansMutex.Lock() + ret, specificReturn := fake.listPlansReturnsOnCall[len(fake.listPlansArgsForCall)] + fake.listPlansArgsForCall = append(fake.listPlansArgsForCall, struct { + arg1 context.Context + arg2 authorization.Info + }{arg1, arg2}) + stub := fake.ListPlansStub + fakeReturns := fake.listPlansReturns + fake.recordInvocation("ListPlans", []interface{}{arg1, arg2}) + fake.listPlansMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *CFServicePlanRepository) ListPlansCallCount() int { + fake.listPlansMutex.RLock() + defer fake.listPlansMutex.RUnlock() + return len(fake.listPlansArgsForCall) +} + +func (fake *CFServicePlanRepository) ListPlansCalls(stub func(context.Context, authorization.Info) ([]repositories.ServicePlanResource, error)) { + fake.listPlansMutex.Lock() + defer fake.listPlansMutex.Unlock() + fake.ListPlansStub = stub +} + +func (fake *CFServicePlanRepository) ListPlansArgsForCall(i int) (context.Context, authorization.Info) { + fake.listPlansMutex.RLock() + defer fake.listPlansMutex.RUnlock() + argsForCall := fake.listPlansArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *CFServicePlanRepository) ListPlansReturns(result1 []repositories.ServicePlanResource, result2 error) { + fake.listPlansMutex.Lock() + defer fake.listPlansMutex.Unlock() + fake.ListPlansStub = nil + fake.listPlansReturns = struct { + result1 []repositories.ServicePlanResource + result2 error + }{result1, result2} +} + +func (fake *CFServicePlanRepository) ListPlansReturnsOnCall(i int, result1 []repositories.ServicePlanResource, result2 error) { + fake.listPlansMutex.Lock() + defer fake.listPlansMutex.Unlock() + fake.ListPlansStub = nil + if fake.listPlansReturnsOnCall == nil { + fake.listPlansReturnsOnCall = make(map[int]struct { + result1 []repositories.ServicePlanResource + result2 error + }) + } + fake.listPlansReturnsOnCall[i] = struct { + result1 []repositories.ServicePlanResource + result2 error + }{result1, result2} +} + +func (fake *CFServicePlanRepository) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.listPlansMutex.RLock() + defer fake.listPlansMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *CFServicePlanRepository) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ handlers.CFServicePlanRepository = new(CFServicePlanRepository) diff --git a/api/handlers/service_offering.go b/api/handlers/service_offering.go index 18ff21e66..6a497d068 100644 --- a/api/handlers/service_offering.go +++ b/api/handlers/service_offering.go @@ -1,3 +1,4 @@ +// nolint:dupl package handlers import ( diff --git a/api/handlers/service_offering_test.go b/api/handlers/service_offering_test.go index 9d08b9578..64c8a7049 100644 --- a/api/handlers/service_offering_test.go +++ b/api/handlers/service_offering_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "errors" "net/http" . "code.cloudfoundry.org/korifi/api/handlers" @@ -67,5 +68,15 @@ var _ = Describe("ServiceOffering", func() { MatchJSONPath("$.resources[0].links.service_broker.href", "https://api.example.org/v3/service_brokers/broker-guid"), ))) }) + + When("listing the offerings fails", func() { + BeforeEach(func() { + serviceOfferingRepo.ListOfferingsReturns(nil, errors.New("list-err")) + }) + + It("returns an error", func() { + expectUnknownError() + }) + }) }) }) diff --git a/api/handlers/service_plan.go b/api/handlers/service_plan.go new file mode 100644 index 000000000..05dc3caf4 --- /dev/null +++ b/api/handlers/service_plan.go @@ -0,0 +1,61 @@ +// nolint:dupl +package handlers + +import ( + "context" + "net/http" + "net/url" + + "code.cloudfoundry.org/korifi/api/authorization" + apierrors "code.cloudfoundry.org/korifi/api/errors" + "code.cloudfoundry.org/korifi/api/presenter" + "code.cloudfoundry.org/korifi/api/repositories" + "code.cloudfoundry.org/korifi/api/routing" + "github.com/go-logr/logr" +) + +const ( + ServicePlansPath = "/v3/service_plans" +) + +//counterfeiter:generate -o fake -fake-name CFServicePlanRepository . CFServicePlanRepository +type CFServicePlanRepository interface { + ListPlans(context.Context, authorization.Info) ([]repositories.ServicePlanResource, error) +} + +type ServicePlan struct { + serverURL url.URL + servicePlanRepo CFServicePlanRepository +} + +func NewServicePlan( + serverURL url.URL, + servicePlanRepo CFServicePlanRepository, +) *ServicePlan { + return &ServicePlan{ + serverURL: serverURL, + servicePlanRepo: servicePlanRepo, + } +} + +func (h *ServicePlan) list(r *http.Request) (*routing.Response, error) { + authInfo, _ := authorization.InfoFromContext(r.Context()) + logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.service-plan.list") + + servicePlanList, err := h.servicePlanRepo.ListPlans(r.Context(), authInfo) + if err != nil { + return nil, apierrors.LogAndReturn(logger, err, "Failed to list service plans") + } + + return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForServicePlan, servicePlanList, h.serverURL, *r.URL)), nil +} + +func (h *ServicePlan) UnauthenticatedRoutes() []routing.Route { + return nil +} + +func (h *ServicePlan) AuthenticatedRoutes() []routing.Route { + return []routing.Route{ + {Method: "GET", Pattern: ServicePlansPath, Handler: h.list}, + } +} diff --git a/api/handlers/service_plan_test.go b/api/handlers/service_plan_test.go new file mode 100644 index 000000000..c09ed6acb --- /dev/null +++ b/api/handlers/service_plan_test.go @@ -0,0 +1,79 @@ +package handlers_test + +import ( + "errors" + "net/http" + + . "code.cloudfoundry.org/korifi/api/handlers" + "code.cloudfoundry.org/korifi/api/handlers/fake" + "code.cloudfoundry.org/korifi/api/repositories" + "code.cloudfoundry.org/korifi/model" + . "code.cloudfoundry.org/korifi/tests/matchers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ServicePlan", func() { + var servicePlanRepo *fake.CFServicePlanRepository + + BeforeEach(func() { + servicePlanRepo = new(fake.CFServicePlanRepository) + + apiHandler := NewServicePlan( + *serverURL, + servicePlanRepo, + ) + routerBuilder.LoadRoutes(apiHandler) + }) + + Describe("GET /v3/service_plans", func() { + BeforeEach(func() { + servicePlanRepo.ListPlansReturns([]repositories.ServicePlanResource{{ + CFResource: model.CFResource{ + GUID: "plan-guid", + }, + Relationships: repositories.ServicePlanRelationships{ + ServiceOffering: model.ToOneRelationship{ + Data: model.Relationship{ + GUID: "service-offering-guid", + }, + }, + }, + }}, nil) + }) + + JustBeforeEach(func() { + req, err := http.NewRequestWithContext(ctx, "GET", "/v3/service_plans", nil) + Expect(err).NotTo(HaveOccurred()) + + routerBuilder.Build().ServeHTTP(rr, req) + }) + + It("lists the service plans", func() { + Expect(servicePlanRepo.ListPlansCallCount()).To(Equal(1)) + _, actualAuthInfo := servicePlanRepo.ListPlansArgsForCall(0) + Expect(actualAuthInfo).To(Equal(authInfo)) + + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.pagination.total_results", BeEquivalentTo(1)), + MatchJSONPath("$.pagination.first.href", "https://api.example.org/v3/service_plans"), + MatchJSONPath("$.resources[0].guid", "plan-guid"), + MatchJSONPath("$.resources[0].links.self.href", "https://api.example.org/v3/service_plans/plan-guid"), + MatchJSONPath("$.resources[0].links.service_offering.href", "https://api.example.org/v3/service_offerings/service-offering-guid"), + ))) + }) + + When("listing the plans fails", func() { + BeforeEach(func() { + servicePlanRepo.ListPlansReturns(nil, errors.New("list-err")) + }) + + It("returns an error", func() { + expectUnknownError() + }) + }) + }) +}) diff --git a/api/main.go b/api/main.go index 7c6d5881c..0ae8ac5ae 100644 --- a/api/main.go +++ b/api/main.go @@ -228,6 +228,7 @@ func main() { metricsRepo := repositories.NewMetricsRepo(userClientFactory) serviceBrokerRepo := repositories.NewServiceBrokerRepo(userClientFactory, cfg.RootNamespace) serviceOfferingRepo := repositories.NewServiceOfferingRepo(userClientFactory, cfg.RootNamespace) + servicePlanRepo := repositories.NewServicePlanRepo(userClientFactory, cfg.RootNamespace) processStats := actions.NewProcessStats(processRepo, appRepo, metricsRepo) manifest := actions.NewManifest( @@ -420,6 +421,10 @@ func main() { *serverURL, serviceOfferingRepo, ), + handlers.NewServicePlan( + *serverURL, + servicePlanRepo, + ), } for _, handler := range apiHandlers { routerBuilder.LoadRoutes(handler) diff --git a/api/presenter/service_offering_test.go b/api/presenter/service_offering_test.go index a419de58e..c92bc0614 100644 --- a/api/presenter/service_offering_test.go +++ b/api/presenter/service_offering_test.go @@ -51,6 +51,14 @@ var _ = Describe("Service Offering", func() { GUID: "resource-guid", CreatedAt: time.UnixMilli(1000), UpdatedAt: tools.PtrTo(time.UnixMilli(2000)), + Metadata: model.Metadata{ + Labels: map[string]string{ + "label": "label-foo", + }, + Annotations: map[string]string{ + "annotation": "annotation-bar", + }, + }, }, Relationships: repositories.ServiceOfferingRelationships{ ServiceBroker: model.ToOneRelationship{ @@ -100,6 +108,14 @@ var _ = Describe("Service Offering", func() { "Status": 0, "Details": "" }, + "metadata": { + "labels": { + "label": "label-foo" + }, + "annotations": { + "annotation": "annotation-bar" + } + }, "relationships": { "service_broker": { "data": { diff --git a/api/presenter/service_plan.go b/api/presenter/service_plan.go new file mode 100644 index 000000000..e28d90f84 --- /dev/null +++ b/api/presenter/service_plan.go @@ -0,0 +1,37 @@ +package presenter + +import ( + "net/url" + + "code.cloudfoundry.org/korifi/api/repositories" +) + +type ServicePlanLinks struct { + Self Link `json:"self"` + ServiceOffering Link `json:"service_offering"` +} + +type ServicePlanResponse struct { + repositories.ServicePlanResource + Links ServicePlanLinks `json:"links"` +} + +func ForServicePlan(servicePlanResource repositories.ServicePlanResource, baseURL url.URL) ServicePlanResponse { + return ServicePlanResponse{ + ServicePlanResource: servicePlanResource, + Links: ServicePlanLinks{ + Self: Link{ + HRef: buildURL(baseURL).appendPath(servicePlansBase, servicePlanResource.GUID).build(), + }, + ServiceOffering: Link{ + HRef: buildURL(baseURL).appendPath(serviceOfferingsBase, servicePlanResource.Relationships.ServiceOffering.Data.GUID).build(), + }, + }, + } +} + +func ForServicePlanList(servicePlanResourceList []repositories.ServicePlanResource, baseURL, requestURL url.URL) ListResponse[ServicePlanResponse] { + return ForList(func(servicePlanResource repositories.ServicePlanResource, baseURL url.URL) ServicePlanResponse { + return ForServicePlan(servicePlanResource, baseURL) + }, servicePlanResourceList, baseURL, requestURL) +} diff --git a/api/presenter/service_plan_test.go b/api/presenter/service_plan_test.go new file mode 100644 index 000000000..24803462a --- /dev/null +++ b/api/presenter/service_plan_test.go @@ -0,0 +1,166 @@ +package presenter_test + +import ( + "encoding/json" + "net/url" + "time" + + "code.cloudfoundry.org/korifi/api/presenter" + "code.cloudfoundry.org/korifi/api/repositories" + "code.cloudfoundry.org/korifi/model" + "code.cloudfoundry.org/korifi/model/services" + "code.cloudfoundry.org/korifi/tools" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" +) + +var _ = Describe("Service Plan", func() { + var ( + baseURL *url.URL + output []byte + record repositories.ServicePlanResource + ) + + BeforeEach(func() { + var err error + baseURL, err = url.Parse("https://api.example.org") + Expect(err).NotTo(HaveOccurred()) + record = repositories.ServicePlanResource{ + ServicePlan: services.ServicePlan{ + BrokerServicePlan: services.BrokerServicePlan{ + Name: "my-service-plan", + Free: true, + Description: "service plan description", + BrokerCatalog: services.ServicePlanBrokerCatalog{ + ID: "broker-catalog-plan-guid", + Metadata: &runtime.RawExtension{ + Raw: []byte(`{"foo":"bar"}`), + }, + Features: services.ServicePlanFeatures{ + PlanUpdateable: true, + Bindable: true, + }, + }, + Schemas: services.ServicePlanSchemas{ + ServiceInstance: services.ServiceInstanceSchema{ + Create: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"create-param":"create-value"}`), + }, + }, + Update: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"update-param":"update-value"}`), + }, + }, + }, + ServiceBinding: services.ServiceBindingSchema{ + Create: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"binding-create-param":"binding-create-value"}`), + }, + }, + }, + }, + }, + }, + CFResource: model.CFResource{ + GUID: "resource-guid", + CreatedAt: time.UnixMilli(1000), + UpdatedAt: tools.PtrTo(time.UnixMilli(2000)), + Metadata: model.Metadata{ + Labels: map[string]string{ + "label": "label-foo", + }, + Annotations: map[string]string{ + "annotation": "annotation-bar", + }, + }, + }, + Relationships: repositories.ServicePlanRelationships{ + ServiceOffering: model.ToOneRelationship{ + Data: model.Relationship{ + GUID: "service-offering-guid", + }, + }, + }, + } + }) + + JustBeforeEach(func() { + response := presenter.ForServicePlan(record, *baseURL) + var err error + output, err = json.Marshal(response) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns the expected JSON", func() { + Expect(output).To(MatchJSON(`{ + "name": "my-service-plan", + "free": true, + "description": "service plan description", + "broker_catalog": { + "id": "broker-catalog-plan-guid", + "metadata": { + "foo": "bar" + }, + "features": { + "plan_updateable": true, + "bindable": true + } + }, + "schemas": { + "service_instance": { + "create": { + "parameters": { + "create-param": "create-value" + } + }, + "update": { + "parameters": { + "update-param": "update-value" + } + } + }, + "service_binding": { + "create": { + "parameters": { + "binding-create-param": "binding-create-value" + } + } + } + }, + "guid": "resource-guid", + "created_at": "1970-01-01T00:00:01Z", + "updated_at": "1970-01-01T00:00:02Z", + "state": { + "Status": 0, + "Details": "" + }, + "metadata": { + "labels": { + "label": "label-foo" + }, + "annotations": { + "annotation": "annotation-bar" + } + }, + "relationships": { + "service_offering": { + "data": { + "guid": "service-offering-guid" + } + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/service_plans/resource-guid" + }, + "service_offering": { + "href": "https://api.example.org/v3/service_offerings/service-offering-guid" + } + } + }`)) + }) +}) diff --git a/api/repositories/service_offering_repository.go b/api/repositories/service_offering_repository.go index 9d6419015..961753671 100644 --- a/api/repositories/service_offering_repository.go +++ b/api/repositories/service_offering_repository.go @@ -65,6 +65,10 @@ func (r *ServiceOfferingRepo) ListOfferings(ctx context.Context, authInfo author CFResource: model.CFResource{ GUID: offering.Name, CreatedAt: offering.CreationTimestamp.Time, + Metadata: model.Metadata{ + Labels: offering.Labels, + Annotations: offering.Annotations, + }, }, Relationships: ServiceOfferingRelationships{ ServiceBroker: model.ToOneRelationship{ diff --git a/api/repositories/service_offering_repository_test.go b/api/repositories/service_offering_repository_test.go index 74eca93ef..f7e11c08f 100644 --- a/api/repositories/service_offering_repository_test.go +++ b/api/repositories/service_offering_repository_test.go @@ -38,6 +38,9 @@ var _ = Describe("ServiceOfferingRepo", func() { Labels: map[string]string{ korifiv1alpha1.RelServiceBrokerLabel: "broker-guid", }, + Annotations: map[string]string{ + "annotation": "annotation-value", + }, }, Spec: korifiv1alpha1.CFServiceOfferingSpec{ ServiceOffering: services.ServiceOffering{ @@ -96,6 +99,10 @@ var _ = Describe("ServiceOfferingRepo", func() { "GUID": Equal(offeringGUID), "CreatedAt": Not(BeZero()), "UpdatedAt": BeNil(), + "Metadata": MatchAllFields(Fields{ + "Labels": HaveKeyWithValue(korifiv1alpha1.RelServiceBrokerLabel, "broker-guid"), + "Annotations": HaveKeyWithValue("annotation", "annotation-value"), + }), }), "Relationships": Equal(repositories.ServiceOfferingRelationships{ ServiceBroker: model.ToOneRelationship{ diff --git a/api/repositories/service_plan_repository.go b/api/repositories/service_plan_repository.go new file mode 100644 index 000000000..a0cae210b --- /dev/null +++ b/api/repositories/service_plan_repository.go @@ -0,0 +1,76 @@ +package repositories + +import ( + "context" + "fmt" + + "code.cloudfoundry.org/korifi/api/authorization" + apierrors "code.cloudfoundry.org/korifi/api/errors" + korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/model" + "code.cloudfoundry.org/korifi/model/services" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ServicePlanResourceType = "Service Plan" + +type ServicePlanResource struct { + services.ServicePlan + model.CFResource + Relationships ServicePlanRelationships `json:"relationships"` +} + +type ServicePlanRelationships struct { + ServiceOffering model.ToOneRelationship `json:"service_offering"` +} + +type ServicePlanRepo struct { + userClientFactory authorization.UserK8sClientFactory + rootNamespace string +} + +func NewServicePlanRepo( + userClientFactory authorization.UserK8sClientFactory, + rootNamespace string, +) *ServicePlanRepo { + return &ServicePlanRepo{ + userClientFactory: userClientFactory, + rootNamespace: rootNamespace, + } +} + +func (r *ServicePlanRepo) ListPlans(ctx context.Context, authInfo authorization.Info) ([]ServicePlanResource, error) { + userClient, err := r.userClientFactory.BuildClient(authInfo) + if err != nil { + return nil, fmt.Errorf("failed to build user client: %w", err) + } + + cfServicePlans := &korifiv1alpha1.CFServicePlanList{} + if err := userClient.List(ctx, cfServicePlans, client.InNamespace(r.rootNamespace)); err != nil { + return nil, apierrors.FromK8sError(err, ServicePlanResourceType) + } + + var result []ServicePlanResource + for _, plan := range cfServicePlans.Items { + result = append(result, ServicePlanResource{ + ServicePlan: plan.Spec.ServicePlan, + CFResource: model.CFResource{ + GUID: plan.Name, + CreatedAt: plan.CreationTimestamp.Time, + Metadata: model.Metadata{ + Labels: plan.Labels, + Annotations: plan.Annotations, + }, + }, + Relationships: ServicePlanRelationships{ + ServiceOffering: model.ToOneRelationship{ + Data: model.Relationship{ + GUID: plan.Labels[korifiv1alpha1.RelServiceOfferingLabel], + }, + }, + }, + }) + } + + return result, nil +} diff --git a/api/repositories/service_plan_repository_test.go b/api/repositories/service_plan_repository_test.go new file mode 100644 index 000000000..295c70521 --- /dev/null +++ b/api/repositories/service_plan_repository_test.go @@ -0,0 +1,152 @@ +package repositories_test + +import ( + "code.cloudfoundry.org/korifi/api/repositories" + korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/model" + "code.cloudfoundry.org/korifi/model/services" + . "github.com/onsi/gomega/gstruct" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ServicePlanRepo", func() { + var repo *repositories.ServicePlanRepo + + BeforeEach(func() { + repo = repositories.NewServicePlanRepo(userClientFactory, rootNamespace) + }) + + Describe("List", func() { + var ( + planGUID string + listedPlans []repositories.ServicePlanResource + listErr error + ) + + BeforeEach(func() { + planGUID = uuid.NewString() + Expect(k8sClient.Create(ctx, &korifiv1alpha1.CFServicePlan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: rootNamespace, + Name: planGUID, + Labels: map[string]string{ + korifiv1alpha1.RelServiceOfferingLabel: "offering-guid", + }, + Annotations: map[string]string{ + "annotation": "annotation-value", + }, + }, + Spec: korifiv1alpha1.CFServicePlanSpec{ + ServicePlan: services.ServicePlan{ + BrokerServicePlan: services.BrokerServicePlan{ + Name: "my-service-plan", + Free: true, + Description: "service plan description", + BrokerCatalog: services.ServicePlanBrokerCatalog{ + ID: "broker-plan-guid", + Metadata: &runtime.RawExtension{ + Raw: []byte(`{"foo":"bar"}`), + }, + Features: services.ServicePlanFeatures{ + PlanUpdateable: true, + Bindable: true, + }, + }, + Schemas: services.ServicePlanSchemas{ + ServiceInstance: services.ServiceInstanceSchema{ + Create: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"create-param":"create-value"}`), + }, + }, + Update: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"update-param":"update-value"}`), + }, + }, + }, + ServiceBinding: services.ServiceBindingSchema{ + Create: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"binding-create-param":"binding-create-value"}`), + }, + }, + }, + }, + }, + }, + }, + })).To(Succeed()) + }) + + JustBeforeEach(func() { + listedPlans, listErr = repo.ListPlans(ctx, authInfo) + }) + + It("lists service offerings", func() { + Expect(listErr).NotTo(HaveOccurred()) + Expect(listedPlans).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "ServicePlan": MatchFields(IgnoreExtras, Fields{ + "BrokerServicePlan": MatchFields(IgnoreExtras, Fields{ + "Name": Equal("my-service-plan"), + "Description": Equal("service plan description"), + "Free": BeTrue(), + "BrokerCatalog": MatchFields(IgnoreExtras, Fields{ + "ID": Equal("broker-plan-guid"), + "Metadata": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"foo": "bar"}`), + })), + + "Features": MatchFields(IgnoreExtras, Fields{ + "PlanUpdateable": BeTrue(), + "Bindable": BeTrue(), + }), + }), + "Schemas": MatchFields(IgnoreExtras, Fields{ + "ServiceInstance": MatchFields(IgnoreExtras, Fields{ + "Create": MatchFields(IgnoreExtras, Fields{ + "Parameters": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"create-param":"create-value"}`), + })), + }), + "Update": MatchFields(IgnoreExtras, Fields{ + "Parameters": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"update-param":"update-value"}`), + })), + }), + }), + "ServiceBinding": MatchFields(IgnoreExtras, Fields{ + "Create": MatchFields(IgnoreExtras, Fields{ + "Parameters": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"binding-create-param": "binding-create-value"}`), + })), + }), + }), + }), + }), + }), + "CFResource": MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(planGUID), + "CreatedAt": Not(BeZero()), + "UpdatedAt": BeNil(), + "Metadata": MatchAllFields(Fields{ + "Labels": HaveKeyWithValue(korifiv1alpha1.RelServiceOfferingLabel, "offering-guid"), + "Annotations": HaveKeyWithValue("annotation", "annotation-value"), + }), + }), + "Relationships": Equal(repositories.ServicePlanRelationships{ + ServiceOffering: model.ToOneRelationship{ + Data: model.Relationship{ + GUID: "offering-guid", + }, + }, + }), + }))) + }) + }) +}) diff --git a/controllers/api/v1alpha1/cfservice_offering_types.go b/controllers/api/v1alpha1/cfservice_offering_types.go index d1035c6e7..a3125b197 100644 --- a/controllers/api/v1alpha1/cfservice_offering_types.go +++ b/controllers/api/v1alpha1/cfservice_offering_types.go @@ -10,14 +10,7 @@ type CFServiceOfferingSpec struct { services.ServiceOffering `json:",inline"` } -// CFServiceOfferingStatus defines the observed state of CFServiceOffering -type CFServiceOfferingStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file -} - //+kubebuilder:object:root=true -//+kubebuilder:subresource:status //+kubebuilder:printcolumn:name="Offering",type=string,JSONPath=`.spec.name` //+kubebuilder:printcolumn:name="Description",type=string,JSONPath=`.spec.description` //+kubebuilder:printcolumn:name="Available",type=string,JSONPath=`.spec.available` @@ -28,8 +21,7 @@ type CFServiceOffering struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec CFServiceOfferingSpec `json:"spec,omitempty"` - Status CFServiceOfferingStatus `json:"status,omitempty"` + Spec CFServiceOfferingSpec `json:"spec,omitempty"` } //+kubebuilder:object:root=true diff --git a/controllers/api/v1alpha1/cfservice_plan_types.go b/controllers/api/v1alpha1/cfservice_plan_types.go new file mode 100644 index 000000000..48e1db9aa --- /dev/null +++ b/controllers/api/v1alpha1/cfservice_plan_types.go @@ -0,0 +1,33 @@ +package v1alpha1 + +import ( + "code.cloudfoundry.org/korifi/model/services" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type CFServicePlanSpec struct { + services.ServicePlan `json:",inline"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:printcolumn:name="Plan",type=string,JSONPath=`.spec.name` +// +kubebuilder:printcolumn:name="Available",type=string,JSONPath=`.spec.available` +// +kubebuilder:printcolumn:name="Free",type=string,JSONPath=`.spec.free` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=`.metadata.creationTimestamp` +type CFServicePlan struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CFServicePlanSpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true +type CFServicePlanList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CFServicePlan `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CFServicePlan{}, &CFServicePlanList{}) +} diff --git a/controllers/api/v1alpha1/shared_types.go b/controllers/api/v1alpha1/shared_types.go index 76185ff0d..118c91e2e 100644 --- a/controllers/api/v1alpha1/shared_types.go +++ b/controllers/api/v1alpha1/shared_types.go @@ -29,6 +29,7 @@ const ( RelationshipsLabelPrefix = "korifi.cloudfoundry.org/rel-" RelServiceBrokerLabel = RelationshipsLabelPrefix + "service_broker" + RelServiceOfferingLabel = RelationshipsLabelPrefix + "service_offering" ) type Lifecycle struct { diff --git a/controllers/api/v1alpha1/zz_generated.deepcopy.go b/controllers/api/v1alpha1/zz_generated.deepcopy.go index 329b3e71d..b6bc549ff 100644 --- a/controllers/api/v1alpha1/zz_generated.deepcopy.go +++ b/controllers/api/v1alpha1/zz_generated.deepcopy.go @@ -1492,7 +1492,6 @@ func (in *CFServiceOffering) DeepCopyInto(out *CFServiceOffering) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFServiceOffering. @@ -1562,16 +1561,75 @@ func (in *CFServiceOfferingSpec) DeepCopy() *CFServiceOfferingSpec { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CFServiceOfferingStatus) DeepCopyInto(out *CFServiceOfferingStatus) { +func (in *CFServicePlan) DeepCopyInto(out *CFServicePlan) { *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFServicePlan. +func (in *CFServicePlan) DeepCopy() *CFServicePlan { + if in == nil { + return nil + } + out := new(CFServicePlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CFServicePlan) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CFServicePlanList) DeepCopyInto(out *CFServicePlanList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CFServicePlan, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFServicePlanList. +func (in *CFServicePlanList) DeepCopy() *CFServicePlanList { + if in == nil { + return nil + } + out := new(CFServicePlanList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CFServicePlanList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CFServicePlanSpec) DeepCopyInto(out *CFServicePlanSpec) { + *out = *in + in.ServicePlan.DeepCopyInto(&out.ServicePlan) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFServiceOfferingStatus. -func (in *CFServiceOfferingStatus) DeepCopy() *CFServiceOfferingStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFServicePlanSpec. +func (in *CFServicePlanSpec) DeepCopy() *CFServicePlanSpec { if in == nil { return nil } - out := new(CFServiceOfferingStatus) + out := new(CFServicePlanSpec) in.DeepCopyInto(out) return out } diff --git a/controllers/controllers/services/brokers/controller.go b/controllers/controllers/services/brokers/controller.go index 69b80885e..2827160e0 100644 --- a/controllers/controllers/services/brokers/controller.go +++ b/controllers/controllers/services/brokers/controller.go @@ -100,10 +100,10 @@ func (r *Reconciler) secretToServiceBroker(ctx context.Context, o client.Object) //+kubebuilder:rbac:groups=korifi.cloudfoundry.org,resources=cfservicebrokers,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=korifi.cloudfoundry.org,resources=cfservicebrokers/status,verbs=get;update;patch //+kubebuilder:rbac:groups=korifi.cloudfoundry.org,resources=cfserviceofferings,verbs=get;list;watch;create;update;patch -//+kubebuilder:rbac:groups=korifi.cloudfoundry.org,resources=cfserviceofferings/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=korifi.cloudfoundry.org,resources=cfserviceplans,verbs=get;list;watch;create;update;patch func (r *Reconciler) ReconcileResource(ctx context.Context, cfServiceBroker *korifiv1alpha1.CFServiceBroker) (ctrl.Result, error) { - log := logr.FromContextOrDiscard(ctx) + log := logr.FromContextOrDiscard(ctx).WithValues("broker-id", cfServiceBroker.Name) cfServiceBroker.Status.ObservedGeneration = cfServiceBroker.Generation log.V(1).Info("set observed generation", "generation", cfServiceBroker.Status.ObservedGeneration) @@ -171,23 +171,20 @@ func (r *Reconciler) validateCredentials(credentialsSecret *corev1.Secret) error } func (r *Reconciler) reconcileCatalog(ctx context.Context, cfServiceBroker *korifiv1alpha1.CFServiceBroker, catalog *osbapi.Catalog) error { - log := logr.FromContextOrDiscard(ctx).WithName("reconcile-catalog") - for _, service := range catalog.Services { err := r.reconcileCatalogService(ctx, cfServiceBroker, service) if err != nil { - log.Error(err, "failed to reconcile service offering") return err } + } return nil } func (r *Reconciler) reconcileCatalogService(ctx context.Context, cfServiceBroker *korifiv1alpha1.CFServiceBroker, catalogService osbapi.Service) error { - log := logr.FromContextOrDiscard(ctx).WithName("reconcile-offering").WithValues("broker", cfServiceBroker.Name, "service-offering", catalogService.Name) serviceOffering := &korifiv1alpha1.CFServiceOffering{ ObjectMeta: metav1.ObjectMeta{ - Name: generateBrokerObjectUid(cfServiceBroker, catalogService.ID), + Name: tools.NamespacedUUID(cfServiceBroker.Name, catalogService.ID), Namespace: cfServiceBroker.Namespace, }, } @@ -203,15 +200,64 @@ func (r *Reconciler) reconcileCatalogService(ctx context.Context, cfServiceBroke return err }) if err != nil { - log.Error(err, "failed to reconcile service offering") - return err + return fmt.Errorf("failed to reconcile service offering %q : %w", catalogService.ID, err) + } + + for _, catalogPlan := range catalogService.Plans { + err = r.reconcileCatalogPlan(ctx, serviceOffering, catalogPlan) + if err != nil { + return fmt.Errorf("failed to reconcile service plan %q for service offering %q: %w", catalogPlan.ID, catalogService.ID, err) + } } return nil } -func generateBrokerObjectUid(broker *korifiv1alpha1.CFServiceBroker, objectId string) string { - return tools.UUID(fmt.Sprintf("%s::%s", broker.Name, objectId)) +func (r *Reconciler) reconcileCatalogPlan(ctx context.Context, serviceOffering *korifiv1alpha1.CFServiceOffering, catalogPlan osbapi.Plan) error { + servicePlan := &korifiv1alpha1.CFServicePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: tools.NamespacedUUID(serviceOffering.Labels[korifiv1alpha1.RelServiceBrokerLabel], catalogPlan.ID), + Namespace: serviceOffering.Namespace, + }, + } + + _, err := controllerutil.CreateOrPatch(ctx, r.k8sClient, servicePlan, func() error { + if servicePlan.Labels == nil { + servicePlan.Labels = map[string]string{} + } + servicePlan.Labels[korifiv1alpha1.RelServiceBrokerLabel] = serviceOffering.Labels[korifiv1alpha1.RelServiceBrokerLabel] + servicePlan.Labels[korifiv1alpha1.RelServiceOfferingLabel] = serviceOffering.Name + + rawMetadata, err := json.Marshal(catalogPlan.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal service plan %q metadata: %w", catalogPlan.ID, err) + } + + servicePlan.Spec = korifiv1alpha1.CFServicePlanSpec{ + ServicePlan: services.ServicePlan{ + BrokerServicePlan: services.BrokerServicePlan{ + Name: catalogPlan.Name, + Free: catalogPlan.Free, + Description: catalogPlan.Description, + BrokerCatalog: services.ServicePlanBrokerCatalog{ + ID: catalogPlan.ID, + Metadata: &runtime.RawExtension{ + Raw: rawMetadata, + }, + Features: services.ServicePlanFeatures{ + PlanUpdateable: catalogPlan.PlanUpdateable, + Bindable: catalogPlan.Bindable, + }, + }, + Schemas: catalogPlan.Schemas, + }, + }, + } + + return nil + }) + + return err } func toSpecServiceOffering(catalogService osbapi.Service) (services.ServiceOffering, error) { diff --git a/controllers/controllers/services/brokers/controller_test.go b/controllers/controllers/services/brokers/controller_test.go index 8981952a1..3fb9830ca 100644 --- a/controllers/controllers/services/brokers/controller_test.go +++ b/controllers/controllers/services/brokers/controller_test.go @@ -16,6 +16,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) var _ = Describe("CFServiceBroker", func() { @@ -51,6 +52,39 @@ var _ = Describe("CFServiceBroker", func() { "foo": "bar", "documentationUrl": "https://doc.url", }, + Plans: []osbapi.Plan{{ + ID: "plan-id", + Name: "plan-name", + Description: "plan description", + Metadata: map[string]any{ + "plan-md": "plan-md-value", + }, + Free: true, + Bindable: true, + BindingRotatable: true, + PlanUpdateable: true, + Schemas: services.ServicePlanSchemas{ + ServiceInstance: services.ServiceInstanceSchema{ + Create: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"create-param":"create-value"}`), + }, + }, + Update: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"update-param":"update-value"}`), + }, + }, + }, + ServiceBinding: services.ServiceBindingSchema{ + Create: services.InputParameterSchema{ + Parameters: &runtime.RawExtension{ + Raw: []byte(`{"binding-create-param":"binding-create-value"}`), + }, + }, + }, + }, + }}, }}, }).Start() @@ -138,6 +172,65 @@ var _ = Describe("CFServiceBroker", func() { }).Should(Succeed()) }) + It("creates CFServicePlans to reflect catalog plans", func() { + Eventually(func(g Gomega) { + offerings := &korifiv1alpha1.CFServiceOfferingList{} + g.Expect(adminClient.List(ctx, offerings, client.InNamespace(serviceBroker.Namespace))).To(Succeed()) + g.Expect(offerings.Items).To(HaveLen(1)) + + plans := &korifiv1alpha1.CFServicePlanList{} + g.Expect(adminClient.List(ctx, plans, client.InNamespace(serviceBroker.Namespace))).To(Succeed()) + g.Expect(plans.Items).To(HaveLen(1)) + + plan := plans.Items[0] + + g.Expect(plan.Labels).To(SatisfyAll( + HaveKeyWithValue(korifiv1alpha1.RelServiceBrokerLabel, serviceBroker.Name), + HaveKeyWithValue(korifiv1alpha1.RelServiceOfferingLabel, offerings.Items[0].Name), + )) + g.Expect(plan.Spec).To(MatchAllFields(Fields{ + "ServicePlan": MatchAllFields(Fields{ + "BrokerServicePlan": MatchAllFields(Fields{ + "Name": Equal("plan-name"), + "Free": BeTrue(), + "Description": Equal("plan description"), + "BrokerCatalog": MatchAllFields(Fields{ + "ID": Equal("plan-id"), + "Metadata": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"plan-md": "plan-md-value"}`), + })), + "Features": Equal(services.ServicePlanFeatures{ + PlanUpdateable: true, + Bindable: true, + }), + }), + "Schemas": MatchFields(IgnoreExtras, Fields{ + "ServiceInstance": MatchFields(IgnoreExtras, Fields{ + "Create": MatchFields(IgnoreExtras, Fields{ + "Parameters": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"create-param":"create-value"}`), + })), + }), + "Update": MatchFields(IgnoreExtras, Fields{ + "Parameters": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"update-param":"update-value"}`), + })), + }), + }), + "ServiceBinding": MatchFields(IgnoreExtras, Fields{ + "Create": MatchFields(IgnoreExtras, Fields{ + "Parameters": PointTo(MatchFields(IgnoreExtras, Fields{ + "Raw": MatchJSON(`{"binding-create-param": "binding-create-value"}`), + })), + }), + }), + }), + }), + }), + })) + }).Should(Succeed()) + }) + When("getting the catalog fails", func() { BeforeEach(func() { Expect(k8s.PatchResource(ctx, adminClient, serviceBroker, func() { @@ -194,6 +287,23 @@ var _ = Describe("CFServiceBroker", func() { g.Expect(offerings.Items[1].Spec.BrokerCatalog.Id).To(Equal("service-id")) }).Should(Succeed()) }) + + It("creates a plan per broker", func() { + Eventually(func(g Gomega) { + plans := &korifiv1alpha1.CFServicePlanList{} + g.Expect(adminClient.List(ctx, plans, client.InNamespace(testNamespace))).To(Succeed()) + g.Expect(plans.Items).To(HaveLen(2)) + + brokerGUIDs := []string{ + plans.Items[0].Labels[korifiv1alpha1.RelServiceBrokerLabel], + plans.Items[1].Labels[korifiv1alpha1.RelServiceBrokerLabel], + } + g.Expect(brokerGUIDs).To(ConsistOf(serviceBroker.Name, anotherServiceBroker.Name)) + + g.Expect(plans.Items[0].Spec.BrokerCatalog.ID).To(Equal("plan-id")) + g.Expect(plans.Items[1].Spec.BrokerCatalog.ID).To(Equal("plan-id")) + }).Should(Succeed()) + }) }) Describe("credentials secret", func() { diff --git a/controllers/controllers/services/brokers/osbapi/client.go b/controllers/controllers/services/brokers/osbapi/client.go index 57ee6c22e..a9aee03c3 100644 --- a/controllers/controllers/services/brokers/osbapi/client.go +++ b/controllers/controllers/services/brokers/osbapi/client.go @@ -41,7 +41,17 @@ type Service struct { Plans []Plan `json:"plans"` } -type Plan struct{} +type Plan struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Metadata map[string]any `json:"metadata"` + Free bool `json:"free"` + Bindable bool `json:"bindable"` + BindingRotatable bool `json:"binding_rotatable"` + PlanUpdateable bool `json:"plan_updateable"` + Schemas services.ServicePlanSchemas `json:"schemas"` +} type Client struct { k8sClient client.Client diff --git a/helm/korifi/controllers/cf_roles/cf_admin.yaml b/helm/korifi/controllers/cf_roles/cf_admin.yaml index 63c44ffb0..2b7d197bc 100644 --- a/helm/korifi/controllers/cf_roles/cf_admin.yaml +++ b/helm/korifi/controllers/cf_roles/cf_admin.yaml @@ -198,6 +198,7 @@ rules: - korifi.cloudfoundry.org resources: - cfserviceofferings + - cfserviceplans verbs: - list diff --git a/helm/korifi/controllers/cf_roles/cf_root_namespace_user.yaml b/helm/korifi/controllers/cf_roles/cf_root_namespace_user.yaml index fb6bbe046..a3dc15593 100644 --- a/helm/korifi/controllers/cf_roles/cf_root_namespace_user.yaml +++ b/helm/korifi/controllers/cf_roles/cf_root_namespace_user.yaml @@ -43,5 +43,6 @@ rules: - korifi.cloudfoundry.org resources: - cfserviceofferings + - cfserviceplans verbs: - list diff --git a/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceofferings.yaml b/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceofferings.yaml index 79f92db25..dc3e893c9 100644 --- a/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceofferings.yaml +++ b/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceofferings.yaml @@ -101,11 +101,7 @@ spec: - description - name type: object - status: - description: CFServiceOfferingStatus defines the observed state of CFServiceOffering - type: object type: object served: true storage: true - subresources: - status: {} + subresources: {} diff --git a/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceplans.yaml b/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceplans.yaml new file mode 100644 index 000000000..1cdb733a1 --- /dev/null +++ b/helm/korifi/controllers/crds/korifi.cloudfoundry.org_cfserviceplans.yaml @@ -0,0 +1,123 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.15.0 + name: cfserviceplans.korifi.cloudfoundry.org +spec: + group: korifi.cloudfoundry.org + names: + kind: CFServicePlan + listKind: CFServicePlanList + plural: cfserviceplans + singular: cfserviceplan + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Plan + type: string + - jsonPath: .spec.available + name: Available + type: string + - jsonPath: .spec.free + name: Free + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + broker_catalog: + properties: + features: + properties: + bindable: + type: boolean + plan_updateable: + type: boolean + required: + - bindable + - plan_updateable + type: object + id: + type: string + metadata: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - id + type: object + description: + type: string + free: + type: boolean + name: + type: string + schemas: + properties: + service_binding: + properties: + create: + properties: + parameters: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + required: + - create + type: object + service_instance: + properties: + create: + properties: + parameters: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + update: + properties: + parameters: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + required: + - create + - update + type: object + required: + - service_binding + - service_instance + type: object + required: + - broker_catalog + - free + - name + - schemas + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/helm/korifi/controllers/role.yaml b/helm/korifi/controllers/role.yaml index 1cdb6641d..530ccc289 100644 --- a/helm/korifi/controllers/role.yaml +++ b/helm/korifi/controllers/role.yaml @@ -453,11 +453,14 @@ rules: - apiGroups: - korifi.cloudfoundry.org resources: - - cfserviceofferings/status + - cfserviceplans verbs: + - create - get + - list - patch - update + - watch - apiGroups: - korifi.cloudfoundry.org resources: diff --git a/model/cfresource.go b/model/cfresource.go index 5dae52b03..7d54c68fb 100644 --- a/model/cfresource.go +++ b/model/cfresource.go @@ -18,5 +18,6 @@ type CFResource struct { GUID string `json:"guid"` CreatedAt time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at"` + Metadata Metadata `json:"metadata"` State CFResourceState `json:"state"` } diff --git a/model/services/plans.go b/model/services/plans.go new file mode 100644 index 000000000..7e539dee3 --- /dev/null +++ b/model/services/plans.go @@ -0,0 +1,67 @@ +package services + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +// +kubebuilder:object:generate=true +type ServicePlan struct { + BrokerServicePlan `json:",inline"` +} + +// +kubebuilder:object:generate=true +type BrokerServicePlan struct { + Name string `json:"name"` + Free bool `json:"free"` + Description string `json:"description,omitempty"` + BrokerCatalog ServicePlanBrokerCatalog `json:"broker_catalog"` + Schemas ServicePlanSchemas `json:"schemas"` +} + +type ServicePlanCost struct { + Amount string `json:"amount"` + Currency string `json:"currency"` + Unit string `json:"unit"` +} + +type ServicePlanMaintenanceInfo struct { + Version string `json:"version"` + Description string `json:"description"` +} + +// +kubebuilder:object:generate=true +type ServicePlanBrokerCatalog struct { + ID string `json:"id"` + // +kubebuilder:validation:Optional + Metadata *runtime.RawExtension `json:"metadata"` + // +kubebuilder:validation:Optional + Features ServicePlanFeatures `json:"features"` +} + +// +kubebuilder:object:generate=true +type InputParameterSchema struct { + // +kubebuilder:validation:Optional + Parameters *runtime.RawExtension `json:"parameters,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceInstanceSchema struct { + Create InputParameterSchema `json:"create"` + Update InputParameterSchema `json:"update"` +} + +// +kubebuilder:object:generate=true +type ServiceBindingSchema struct { + Create InputParameterSchema `json:"create"` +} + +// +kubebuilder:object:generate=true +type ServicePlanSchemas struct { + ServiceInstance ServiceInstanceSchema `json:"service_instance"` + ServiceBinding ServiceBindingSchema `json:"service_binding"` +} + +type ServicePlanFeatures struct { + PlanUpdateable bool `json:"plan_updateable"` + Bindable bool `json:"bindable"` +} diff --git a/model/services/zz_generated.deepcopy.go b/model/services/zz_generated.deepcopy.go index 767e8766c..4931d1e3f 100644 --- a/model/services/zz_generated.deepcopy.go +++ b/model/services/zz_generated.deepcopy.go @@ -24,6 +24,59 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrokerServicePlan) DeepCopyInto(out *BrokerServicePlan) { + *out = *in + in.BrokerCatalog.DeepCopyInto(&out.BrokerCatalog) + in.Schemas.DeepCopyInto(&out.Schemas) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrokerServicePlan. +func (in *BrokerServicePlan) DeepCopy() *BrokerServicePlan { + if in == nil { + return nil + } + out := new(BrokerServicePlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InputParameterSchema) DeepCopyInto(out *InputParameterSchema) { + *out = *in + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InputParameterSchema. +func (in *InputParameterSchema) DeepCopy() *InputParameterSchema { + if in == nil { + return nil + } + out := new(InputParameterSchema) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceBindingSchema) DeepCopyInto(out *ServiceBindingSchema) { + *out = *in + in.Create.DeepCopyInto(&out.Create) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBindingSchema. +func (in *ServiceBindingSchema) DeepCopy() *ServiceBindingSchema { + if in == nil { + return nil + } + out := new(ServiceBindingSchema) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceBroker) DeepCopyInto(out *ServiceBroker) { *out = *in @@ -60,6 +113,23 @@ func (in *ServiceBrokerCatalog) DeepCopy() *ServiceBrokerCatalog { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceInstanceSchema) DeepCopyInto(out *ServiceInstanceSchema) { + *out = *in + in.Create.DeepCopyInto(&out.Create) + in.Update.DeepCopyInto(&out.Update) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceInstanceSchema. +func (in *ServiceInstanceSchema) DeepCopy() *ServiceInstanceSchema { + if in == nil { + return nil + } + out := new(ServiceInstanceSchema) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOffering) DeepCopyInto(out *ServiceOffering) { *out = *in @@ -90,3 +160,57 @@ func (in *ServiceOffering) DeepCopy() *ServiceOffering { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServicePlan) DeepCopyInto(out *ServicePlan) { + *out = *in + in.BrokerServicePlan.DeepCopyInto(&out.BrokerServicePlan) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServicePlan. +func (in *ServicePlan) DeepCopy() *ServicePlan { + if in == nil { + return nil + } + out := new(ServicePlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServicePlanBrokerCatalog) DeepCopyInto(out *ServicePlanBrokerCatalog) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + out.Features = in.Features +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServicePlanBrokerCatalog. +func (in *ServicePlanBrokerCatalog) DeepCopy() *ServicePlanBrokerCatalog { + if in == nil { + return nil + } + out := new(ServicePlanBrokerCatalog) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServicePlanSchemas) DeepCopyInto(out *ServicePlanSchemas) { + *out = *in + in.ServiceInstance.DeepCopyInto(&out.ServiceInstance) + in.ServiceBinding.DeepCopyInto(&out.ServiceBinding) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServicePlanSchemas. +func (in *ServicePlanSchemas) DeepCopy() *ServicePlanSchemas { + if in == nil { + return nil + } + out := new(ServicePlanSchemas) + in.DeepCopyInto(out) + return out +} diff --git a/tests/e2e/e2e_suite_test.go b/tests/e2e/e2e_suite_test.go index 1c6c27d71..c26029361 100644 --- a/tests/e2e/e2e_suite_test.go +++ b/tests/e2e/e2e_suite_test.go @@ -1211,6 +1211,28 @@ func cleanupBrokers() { Expect(err).NotTo(HaveOccurred()) ctx := context.Background() - Expect(k8sClient.DeleteAllOf(ctx, &korifiv1alpha1.CFServiceBroker{}, client.InNamespace(rootNamespace))).To(Succeed()) - Expect(k8sClient.DeleteAllOf(ctx, &korifiv1alpha1.CFServiceOffering{}, client.InNamespace(rootNamespace))).To(Succeed()) + Expect(k8sClient.Delete(ctx, &korifiv1alpha1.CFServiceBroker{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: rootNamespace, + Name: serviceBrokerGUID, + }, + })).To(Succeed()) + + Expect(k8sClient.DeleteAllOf( + ctx, + &korifiv1alpha1.CFServiceOffering{}, + client.InNamespace(rootNamespace), + client.MatchingLabels{ + korifiv1alpha1.RelServiceBrokerLabel: serviceBrokerGUID, + }, + )).To(Succeed()) + + Expect(k8sClient.DeleteAllOf( + ctx, + &korifiv1alpha1.CFServicePlan{}, + client.InNamespace(rootNamespace), + client.MatchingLabels{ + korifiv1alpha1.RelServiceBrokerLabel: serviceBrokerGUID, + }, + )).To(Succeed()) } diff --git a/tests/e2e/service_plans_test.go b/tests/e2e/service_plans_test.go new file mode 100644 index 000000000..7ac20f827 --- /dev/null +++ b/tests/e2e/service_plans_test.go @@ -0,0 +1,34 @@ +package e2e_test + +import ( + "net/http" + + korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "github.com/go-resty/resty/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" +) + +var _ = Describe("Service Plans", func() { + var resp *resty.Response + + Describe("List", func() { + var result resourceList[resource] + + JustBeforeEach(func() { + var err error + resp, err = adminClient.R().SetResult(&result).Get("/v3/service_plans") + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns service plans", func() { + Expect(resp).To(HaveRestyStatusCode(http.StatusOK)) + Expect(result.Resources).To(ContainElement(MatchFields(IgnoreExtras, Fields{ + "Metadata": PointTo(MatchFields(IgnoreExtras, Fields{ + "Labels": HaveKeyWithValue(korifiv1alpha1.RelServiceBrokerLabel, serviceBrokerGUID), + })), + }))) + }) + }) +}) diff --git a/tools/uuid.go b/tools/uuid.go index e998d7511..0abf07673 100644 --- a/tools/uuid.go +++ b/tools/uuid.go @@ -8,6 +8,6 @@ import ( var korifiNs = uuid.NewV5(uuid.NamespaceDNS, "korifi.cloudfoundry.org") -func UUID(input string) string { - return uuid.NewV5(korifiNs, strings.TrimSpace(input)).String() +func NamespacedUUID(segments ...string) string { + return uuid.NewV5(korifiNs, strings.TrimSpace(strings.Join(segments, "::"))).String() }