From a08ef98a677118f68dbe61a3670021ed42750146 Mon Sep 17 00:00:00 2001 From: klapkov <91314044+klapkov@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:30:10 +0200 Subject: [PATCH] Add DELETE /v3/service_offerings/:guid (#3614) * Add DELETE /v3/service_offerings/:guid * Implement delete handler and DeleteOffering in repo * Implement purging for Service Offerings - is purge is set to true, delete all related service plans, instances and bindings without contacting the service broker --- .../fake/cfservice_offering_repository.go | 78 +++++++++ api/handlers/service_offering.go | 19 +++ api/handlers/service_offering_test.go | 58 +++++++ api/main.go | 2 +- api/payloads/service_offering.go | 24 +++ api/payloads/service_offering_test.go | 21 +++ .../service_offering_repository.go | 161 +++++++++++++++++- .../service_offering_repository_test.go | 159 ++++++++++++++++- .../cf_roles/cf_root_namespace_user.yaml | 1 + helm/korifi/controllers/role.yaml | 1 + 10 files changed, 516 insertions(+), 8 deletions(-) diff --git a/api/handlers/fake/cfservice_offering_repository.go b/api/handlers/fake/cfservice_offering_repository.go index e8d66fdc7..1ca8d9123 100644 --- a/api/handlers/fake/cfservice_offering_repository.go +++ b/api/handlers/fake/cfservice_offering_repository.go @@ -11,6 +11,19 @@ import ( ) type CFServiceOfferingRepository struct { + DeleteOfferingStub func(context.Context, authorization.Info, repositories.DeleteServiceOfferingMessage) error + deleteOfferingMutex sync.RWMutex + deleteOfferingArgsForCall []struct { + arg1 context.Context + arg2 authorization.Info + arg3 repositories.DeleteServiceOfferingMessage + } + deleteOfferingReturns struct { + result1 error + } + deleteOfferingReturnsOnCall map[int]struct { + result1 error + } GetServiceOfferingStub func(context.Context, authorization.Info, string) (repositories.ServiceOfferingRecord, error) getServiceOfferingMutex sync.RWMutex getServiceOfferingArgsForCall []struct { @@ -45,6 +58,69 @@ type CFServiceOfferingRepository struct { invocationsMutex sync.RWMutex } +func (fake *CFServiceOfferingRepository) DeleteOffering(arg1 context.Context, arg2 authorization.Info, arg3 repositories.DeleteServiceOfferingMessage) error { + fake.deleteOfferingMutex.Lock() + ret, specificReturn := fake.deleteOfferingReturnsOnCall[len(fake.deleteOfferingArgsForCall)] + fake.deleteOfferingArgsForCall = append(fake.deleteOfferingArgsForCall, struct { + arg1 context.Context + arg2 authorization.Info + arg3 repositories.DeleteServiceOfferingMessage + }{arg1, arg2, arg3}) + stub := fake.DeleteOfferingStub + fakeReturns := fake.deleteOfferingReturns + fake.recordInvocation("DeleteOffering", []interface{}{arg1, arg2, arg3}) + fake.deleteOfferingMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *CFServiceOfferingRepository) DeleteOfferingCallCount() int { + fake.deleteOfferingMutex.RLock() + defer fake.deleteOfferingMutex.RUnlock() + return len(fake.deleteOfferingArgsForCall) +} + +func (fake *CFServiceOfferingRepository) DeleteOfferingCalls(stub func(context.Context, authorization.Info, repositories.DeleteServiceOfferingMessage) error) { + fake.deleteOfferingMutex.Lock() + defer fake.deleteOfferingMutex.Unlock() + fake.DeleteOfferingStub = stub +} + +func (fake *CFServiceOfferingRepository) DeleteOfferingArgsForCall(i int) (context.Context, authorization.Info, repositories.DeleteServiceOfferingMessage) { + fake.deleteOfferingMutex.RLock() + defer fake.deleteOfferingMutex.RUnlock() + argsForCall := fake.deleteOfferingArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *CFServiceOfferingRepository) DeleteOfferingReturns(result1 error) { + fake.deleteOfferingMutex.Lock() + defer fake.deleteOfferingMutex.Unlock() + fake.DeleteOfferingStub = nil + fake.deleteOfferingReturns = struct { + result1 error + }{result1} +} + +func (fake *CFServiceOfferingRepository) DeleteOfferingReturnsOnCall(i int, result1 error) { + fake.deleteOfferingMutex.Lock() + defer fake.deleteOfferingMutex.Unlock() + fake.DeleteOfferingStub = nil + if fake.deleteOfferingReturnsOnCall == nil { + fake.deleteOfferingReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.deleteOfferingReturnsOnCall[i] = struct { + result1 error + }{result1} +} + func (fake *CFServiceOfferingRepository) GetServiceOffering(arg1 context.Context, arg2 authorization.Info, arg3 string) (repositories.ServiceOfferingRecord, error) { fake.getServiceOfferingMutex.Lock() ret, specificReturn := fake.getServiceOfferingReturnsOnCall[len(fake.getServiceOfferingArgsForCall)] @@ -180,6 +256,8 @@ func (fake *CFServiceOfferingRepository) ListOfferingsReturnsOnCall(i int, resul func (fake *CFServiceOfferingRepository) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() + fake.deleteOfferingMutex.RLock() + defer fake.deleteOfferingMutex.RUnlock() fake.getServiceOfferingMutex.RLock() defer fake.getServiceOfferingMutex.RUnlock() fake.listOfferingsMutex.RLock() diff --git a/api/handlers/service_offering.go b/api/handlers/service_offering.go index d7e603c3f..c5f49ccd7 100644 --- a/api/handlers/service_offering.go +++ b/api/handlers/service_offering.go @@ -26,6 +26,7 @@ const ( type CFServiceOfferingRepository interface { GetServiceOffering(context.Context, authorization.Info, string) (repositories.ServiceOfferingRecord, error) ListOfferings(context.Context, authorization.Info, repositories.ListServiceOfferingMessage) ([]repositories.ServiceOfferingRecord, error) + DeleteOffering(context.Context, authorization.Info, repositories.DeleteServiceOfferingMessage) error } type ServiceOffering struct { @@ -103,6 +104,23 @@ func (h *ServiceOffering) list(r *http.Request) (*routing.Response, error) { return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForServiceOffering, serviceOfferingList, h.serverURL, *r.URL, includedResources...)), nil } +func (h *ServiceOffering) delete(r *http.Request) (*routing.Response, error) { + authInfo, _ := authorization.InfoFromContext(r.Context()) + logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.service-offering.delete") + + payload := new(payloads.ServiceOfferingDelete) + if err := h.requestValidator.DecodeAndValidateURLValues(r, payload); err != nil { + return nil, apierrors.LogAndReturn(logger, err, "Unable to decode request query parameters") + } + + serviceOfferingGUID := routing.URLParam(r, "guid") + if err := h.serviceOfferingRepo.DeleteOffering(r.Context(), authInfo, payload.ToMessage(serviceOfferingGUID)); err != nil { + return nil, apierrors.LogAndReturn(logger, err, "failed to delete service offering: %s", serviceOfferingGUID) + } + + return routing.NewResponse(http.StatusNoContent), nil +} + func (h *ServiceOffering) UnauthenticatedRoutes() []routing.Route { return nil } @@ -111,5 +129,6 @@ func (h *ServiceOffering) AuthenticatedRoutes() []routing.Route { return []routing.Route{ {Method: "GET", Pattern: ServiceOfferingPath, Handler: h.get}, {Method: "GET", Pattern: ServiceOfferingsPath, Handler: h.list}, + {Method: "DELETE", Pattern: ServiceOfferingPath, Handler: h.delete}, } } diff --git a/api/handlers/service_offering_test.go b/api/handlers/service_offering_test.go index 12a373958..2877256ef 100644 --- a/api/handlers/service_offering_test.go +++ b/api/handlers/service_offering_test.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + apierrors "code.cloudfoundry.org/korifi/api/errors" . "code.cloudfoundry.org/korifi/api/handlers" "code.cloudfoundry.org/korifi/api/handlers/fake" "code.cloudfoundry.org/korifi/api/payloads" @@ -271,4 +272,61 @@ var _ = Describe("ServiceOffering", func() { }) }) }) + + Describe("DELETE /v3/service_offerings/:guid", func() { + JustBeforeEach(func() { + req, err := http.NewRequestWithContext(ctx, "DELETE", "/v3/service_offerings/offering-guid", nil) + Expect(err).NotTo(HaveOccurred()) + + routerBuilder.Build().ServeHTTP(rr, req) + }) + + It("deletes the service offering", func() { + Expect(serviceOfferingRepo.DeleteOfferingCallCount()).To(Equal(1)) + _, actualAuthInfo, actualDeleteMessage := serviceOfferingRepo.DeleteOfferingArgsForCall(0) + Expect(actualAuthInfo).To(Equal(authInfo)) + Expect(actualDeleteMessage.GUID).To(Equal("offering-guid")) + Expect(actualDeleteMessage.Purge).To(BeFalse()) + + Expect(rr).To(HaveHTTPStatus(http.StatusNoContent)) + }) + + When("deleting the service offering fails with not found", func() { + BeforeEach(func() { + serviceOfferingRepo.DeleteOfferingReturns(apierrors.NewNotFoundError(nil, repositories.ServiceOfferingResourceType)) + }) + + It("returns 404 Not Found", func() { + expectNotFoundError("Service Offering") + }) + }) + + When("deleting the service offering fails", func() { + BeforeEach(func() { + serviceOfferingRepo.DeleteOfferingReturns(errors.New("boom")) + }) + + It("returns 500 Internal Server Error", func() { + expectUnknownError() + }) + }) + + When("purging is set to true", func() { + BeforeEach(func() { + requestValidator.DecodeAndValidateURLValuesStub = decodeAndValidateURLValuesStub(&payloads.ServiceOfferingDelete{ + Purge: true, + }) + }) + + It("purges the service offering", func() { + Expect(serviceOfferingRepo.DeleteOfferingCallCount()).To(Equal(1)) + _, actualAuthInfo, actualDeleteMessage := serviceOfferingRepo.DeleteOfferingArgsForCall(0) + Expect(actualAuthInfo).To(Equal(authInfo)) + Expect(actualDeleteMessage.GUID).To(Equal("offering-guid")) + Expect(actualDeleteMessage.Purge).To(BeTrue()) + + Expect(rr).To(HaveHTTPStatus(http.StatusNoContent)) + }) + }) + }) }) diff --git a/api/main.go b/api/main.go index 79c70ccb5..b3aaa1ae6 100644 --- a/api/main.go +++ b/api/main.go @@ -244,7 +244,7 @@ func main() { ) metricsRepo := repositories.NewMetricsRepo(userClientFactory) serviceBrokerRepo := repositories.NewServiceBrokerRepo(userClientFactory, cfg.RootNamespace) - serviceOfferingRepo := repositories.NewServiceOfferingRepo(userClientFactory, cfg.RootNamespace, serviceBrokerRepo) + serviceOfferingRepo := repositories.NewServiceOfferingRepo(userClientFactory, cfg.RootNamespace, serviceBrokerRepo, nsPermissions) servicePlanRepo := repositories.NewServicePlanRepo(userClientFactory, cfg.RootNamespace, orgRepo) processStats := actions.NewProcessStats(processRepo, appRepo, metricsRepo) diff --git a/api/payloads/service_offering.go b/api/payloads/service_offering.go index 9570249bc..d0da4aa26 100644 --- a/api/payloads/service_offering.go +++ b/api/payloads/service_offering.go @@ -95,3 +95,27 @@ func (l *ServiceOfferingList) DecodeFromURLValues(values url.Values) error { l.IncludeResourceRules = append(l.IncludeResourceRules, params.ParseFields(values)...) return nil } + +type ServiceOfferingDelete struct { + Purge bool +} + +func (d *ServiceOfferingDelete) SupportedKeys() []string { + return []string{"purge"} +} + +func (d *ServiceOfferingDelete) DecodeFromURLValues(values url.Values) error { + var err error + if d.Purge, err = getBool(values, "purge"); err != nil { + return err + } + + return nil +} + +func (d *ServiceOfferingDelete) ToMessage(guid string) repositories.DeleteServiceOfferingMessage { + return repositories.DeleteServiceOfferingMessage{ + GUID: guid, + Purge: d.Purge, + } +} diff --git a/api/payloads/service_offering_test.go b/api/payloads/service_offering_test.go index 6f8e6fbfd..3f2a307e8 100644 --- a/api/payloads/service_offering_test.go +++ b/api/payloads/service_offering_test.go @@ -72,3 +72,24 @@ var _ = Describe("ServiceOfferingList", func() { }) }) }) + +var _ = Describe("ServiceOfferingDelete", func() { + DescribeTable("valid query", + func(query string, expectedServiceOfferingDelete payloads.ServiceOfferingDelete) { + actualServiceOfferingDelete, decodeErr := decodeQuery[payloads.ServiceOfferingDelete](query) + + Expect(decodeErr).NotTo(HaveOccurred()) + Expect(*actualServiceOfferingDelete).To(Equal(expectedServiceOfferingDelete)) + }, + Entry("purge", "purge=true", payloads.ServiceOfferingDelete{Purge: true}), + ) + + DescribeTable("invalid query", + func(query string, expectedErrMsg string) { + _, decodeErr := decodeQuery[payloads.ServiceOfferingDelete](query) + Expect(decodeErr).To(HaveOccurred()) + }, + Entry("unsuported param", "foo=bar", "unsupported query parameter: foo"), + Entry("invalid value for purge", "purge=foo", "invalid syntax"), + ) +}) diff --git a/api/repositories/service_offering_repository.go b/api/repositories/service_offering_repository.go index c54766958..ba8ab4627 100644 --- a/api/repositories/service_offering_repository.go +++ b/api/repositories/service_offering_repository.go @@ -11,11 +11,13 @@ import ( "code.cloudfoundry.org/korifi/model" "code.cloudfoundry.org/korifi/model/services" "code.cloudfoundry.org/korifi/tools" + "code.cloudfoundry.org/korifi/tools/k8s" "github.com/BooleanCat/go-functional/v2/it" "github.com/BooleanCat/go-functional/v2/it/itx" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) const ServiceOfferingResourceType = "Service Offering" @@ -33,9 +35,10 @@ func (r ServiceOfferingRecord) Relationships() map[string]string { } type ServiceOfferingRepo struct { - userClientFactory authorization.UserK8sClientFactory - rootNamespace string - brokerRepo *ServiceBrokerRepo + userClientFactory authorization.UserK8sClientFactory + rootNamespace string + brokerRepo *ServiceBrokerRepo + namespacePermissions *authorization.NamespacePermissions } type ListServiceOfferingMessage struct { @@ -44,6 +47,11 @@ type ListServiceOfferingMessage struct { BrokerNames []string } +type DeleteServiceOfferingMessage struct { + GUID string + Purge bool +} + func (m *ListServiceOfferingMessage) matches(cfServiceOffering korifiv1alpha1.CFServiceOffering) bool { return tools.EmptyOrContains(m.Names, cfServiceOffering.Spec.Name) && tools.EmptyOrContains(m.GUIDs, cfServiceOffering.Name) && @@ -54,11 +62,13 @@ func NewServiceOfferingRepo( userClientFactory authorization.UserK8sClientFactory, rootNamespace string, brokerRepo *ServiceBrokerRepo, + namespacePermissions *authorization.NamespacePermissions, ) *ServiceOfferingRepo { return &ServiceOfferingRepo{ - userClientFactory: userClientFactory, - rootNamespace: rootNamespace, - brokerRepo: brokerRepo, + userClientFactory: userClientFactory, + rootNamespace: rootNamespace, + brokerRepo: brokerRepo, + namespacePermissions: namespacePermissions, } } @@ -103,6 +113,36 @@ func (r *ServiceOfferingRepo) ListOfferings(ctx context.Context, authInfo author return slices.Collect(it.Map(itx.FromSlice(offeringsList.Items).Filter(message.matches), offeringToRecord)), nil } +func (r *ServiceOfferingRepo) DeleteOffering(ctx context.Context, authInfo authorization.Info, message DeleteServiceOfferingMessage) error { + userClient, err := r.userClientFactory.BuildClient(authInfo) + if err != nil { + return fmt.Errorf("failed to build user client: %w", err) + } + + offering := &korifiv1alpha1.CFServiceOffering{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: r.rootNamespace, + Name: message.GUID, + }, + } + + if err = userClient.Get(ctx, client.ObjectKeyFromObject(offering), offering); err != nil { + return fmt.Errorf("failed to get service offering: %w", apierrors.FromK8sError(err, ServiceOfferingResourceType)) + } + + if message.Purge { + if err = r.purgeRelatedResources(ctx, authInfo, userClient, message.GUID); err != nil { + return fmt.Errorf("failed to purge service offering resources: %w", apierrors.FromK8sError(err, ServiceOfferingResourceType)) + } + } + + if err = userClient.Delete(ctx, offering); err != nil { + return fmt.Errorf("failed to delete service offering: %w", apierrors.FromK8sError(err, ServiceOfferingResourceType)) + } + + return nil +} + func offeringToRecord(offering korifiv1alpha1.CFServiceOffering) ServiceOfferingRecord { return ServiceOfferingRecord{ ServiceOffering: offering.Spec.ServiceOffering, @@ -117,3 +157,112 @@ func offeringToRecord(offering korifiv1alpha1.CFServiceOffering) ServiceOffering ServiceBrokerGUID: offering.Labels[korifiv1alpha1.RelServiceBrokerGUIDLabel], } } + +func (r *ServiceOfferingRepo) purgeRelatedResources(ctx context.Context, authInfo authorization.Info, userClient client.WithWatch, offeringGUID string) error { + planGUIDs, err := r.deleteServicePlans(ctx, userClient, offeringGUID) + if err != nil { + return fmt.Errorf("failed to delete service plans: %w", apierrors.FromK8sError(err, ServicePlanResourceType)) + } + + authorizedSpaceNamespacesIter, err := authorizedSpaceNamespaces(ctx, authInfo, r.namespacePermissions) + if err != nil { + return fmt.Errorf("failed to list namespaces: %w", err) + } + + serviceInstances, err := r.fetchServiceInstances(ctx, userClient, authorizedSpaceNamespacesIter, planGUIDs) + if err != nil { + return fmt.Errorf("failed to list service instances: %w", err) + } + + for _, instance := range serviceInstances { + err = k8s.PatchResource(ctx, userClient, &instance, func() { + controllerutil.RemoveFinalizer(&instance, korifiv1alpha1.CFServiceInstanceFinalizerName) + }) + if err != nil { + return fmt.Errorf("failed to remove finalizer for service instance: %s, %w", instance.Name, apierrors.FromK8sError(err, ServiceInstanceResourceType)) + } + + if err = userClient.Delete(ctx, &instance); err != nil { + return fmt.Errorf("failed to delete service instance: %w", apierrors.FromK8sError(err, ServiceInstanceResourceType)) + } + + } + + serviceBindings, err := r.fetchServiceBindings(ctx, userClient, authorizedSpaceNamespacesIter, planGUIDs) + if err != nil { + return fmt.Errorf("failed to list service bindings: %w", err) + } + + for _, binding := range serviceBindings { + err = k8s.PatchResource(ctx, userClient, &binding, func() { + controllerutil.RemoveFinalizer(&binding, korifiv1alpha1.CFServiceBindingFinalizerName) + }) + if err != nil { + return fmt.Errorf("failed to remove finalizer for service binding: %s, %w", binding.Name, apierrors.FromK8sError(err, ServiceBindingResourceType)) + } + } + + return nil +} + +func (r *ServiceOfferingRepo) deleteServicePlans(ctx context.Context, userClient client.WithWatch, offeringGUID string) ([]string, error) { + var planGUIDs []string + plans := &korifiv1alpha1.CFServicePlanList{} + + if err := userClient.List(ctx, plans, client.InNamespace(r.rootNamespace), client.MatchingLabels{ + korifiv1alpha1.RelServiceOfferingGUIDLabel: offeringGUID, + }); err != nil { + return []string{}, fmt.Errorf("failed to list service plans: %w", err) + } + + for _, plan := range plans.Items { + planGUIDs = append(planGUIDs, plan.Name) + if err := userClient.Delete(ctx, &plan); err != nil { + return []string{}, apierrors.FromK8sError(err, ServicePlanResourceType) + } + } + + return planGUIDs, nil +} + +func (r *ServiceOfferingRepo) fetchServiceInstances(ctx context.Context, userClient client.WithWatch, authorizedNamespaces itx.Iterator[string], planGUIDs []string) ([]korifiv1alpha1.CFServiceInstance, error) { + var serviceInstances []korifiv1alpha1.CFServiceInstance + + for _, ns := range authorizedNamespaces.Collect() { + instances := new(korifiv1alpha1.CFServiceInstanceList) + + err := userClient.List(ctx, instances, client.InNamespace(ns)) + if err != nil { + return []korifiv1alpha1.CFServiceInstance{}, fmt.Errorf("failed to list service instances: %w", err) + } + + filtered := itx.FromSlice(instances.Items).Filter(func(serviceInstance korifiv1alpha1.CFServiceInstance) bool { + return tools.EmptyOrContains(planGUIDs, serviceInstance.Spec.PlanGUID) + }).Collect() + + serviceInstances = append(serviceInstances, filtered...) + } + + return serviceInstances, nil +} + +func (r *ServiceOfferingRepo) fetchServiceBindings(ctx context.Context, userClient client.WithWatch, authorizedNamespaces itx.Iterator[string], planGUIDs []string) ([]korifiv1alpha1.CFServiceBinding, error) { + var serviceBindings []korifiv1alpha1.CFServiceBinding + + for _, ns := range authorizedNamespaces.Collect() { + bindings := new(korifiv1alpha1.CFServiceBindingList) + + err := userClient.List(ctx, bindings, client.InNamespace(ns)) + if err != nil { + return []korifiv1alpha1.CFServiceBinding{}, fmt.Errorf("failed to list service bindings: %w", err) + } + + filtered := itx.FromSlice(bindings.Items).Filter(func(serviceBinding korifiv1alpha1.CFServiceBinding) bool { + return tools.EmptyOrContains(planGUIDs, serviceBinding.Labels[korifiv1alpha1.PlanGUIDLabelKey]) + }).Collect() + + serviceBindings = append(serviceBindings, filtered...) + } + + return serviceBindings, nil +} diff --git a/api/repositories/service_offering_repository_test.go b/api/repositories/service_offering_repository_test.go index 3128e63f1..b461b4acd 100644 --- a/api/repositories/service_offering_repository_test.go +++ b/api/repositories/service_offering_repository_test.go @@ -1,7 +1,9 @@ package repositories_test import ( + "context" "errors" + "fmt" apierrors "code.cloudfoundry.org/korifi/api/errors" "code.cloudfoundry.org/korifi/api/repositories" @@ -9,8 +11,11 @@ import ( "code.cloudfoundry.org/korifi/model/services" "code.cloudfoundry.org/korifi/tools" . "github.com/onsi/gomega/gstruct" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" @@ -18,7 +23,11 @@ import ( ) var _ = Describe("ServiceOfferingRepo", func() { - var repo *repositories.ServiceOfferingRepo + var ( + repo *repositories.ServiceOfferingRepo + org *korifiv1alpha1.CFOrg + space *korifiv1alpha1.CFSpace + ) BeforeEach(func() { repo = repositories.NewServiceOfferingRepo( @@ -28,7 +37,11 @@ var _ = Describe("ServiceOfferingRepo", func() { userClientFactory, rootNamespace, ), + nsPerms, ) + + org = createOrgWithCleanup(ctx, uuid.NewString()) + space = createSpaceWithCleanup(ctx, org.Name, uuid.NewString()) }) Describe("Get", func() { @@ -317,4 +330,148 @@ var _ = Describe("ServiceOfferingRepo", func() { }) }) }) + + Describe("Delete-off", func() { + var ( + plan *korifiv1alpha1.CFServicePlan + offering *korifiv1alpha1.CFServiceOffering + instance *korifiv1alpha1.CFServiceInstance + binding *korifiv1alpha1.CFServiceBinding + message repositories.DeleteServiceOfferingMessage + deleteErr error + ) + + BeforeEach(func() { + offering = &korifiv1alpha1.CFServiceOffering{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: rootNamespace, + Name: uuid.NewString(), + }, + Spec: korifiv1alpha1.CFServiceOfferingSpec{ + ServiceOffering: services.ServiceOffering{ + Name: "my-offering", + Description: "my offering description", + Tags: []string{"t1"}, + Requires: []string{"r1"}, + }, + }, + } + Expect(k8sClient.Create(ctx, offering)).To(Succeed()) + + plan = &korifiv1alpha1.CFServicePlan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: rootNamespace, + Name: uuid.NewString(), + Labels: map[string]string{ + korifiv1alpha1.RelServiceOfferingGUIDLabel: offering.Name, + }, + }, + Spec: korifiv1alpha1.CFServicePlanSpec{ + ServicePlan: services.ServicePlan{ + Name: "my-service-plan", + Free: true, + Description: "service plan description", + }, + Visibility: korifiv1alpha1.ServicePlanVisibility{ + Type: korifiv1alpha1.PublicServicePlanVisibilityType, + }, + }, + } + Expect(k8sClient.Create(ctx, plan)).To(Succeed()) + + instance = &korifiv1alpha1.CFServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: space.Name, + Name: uuid.NewString(), + Finalizers: []string{ + korifiv1alpha1.CFServiceInstanceFinalizerName, + }, + }, + Spec: korifiv1alpha1.CFServiceInstanceSpec{ + PlanGUID: plan.Name, + Type: "user-provided", + }, + } + Expect(k8sClient.Create(ctx, instance)).To(Succeed()) + + binding = &korifiv1alpha1.CFServiceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Namespace: space.Name, + Labels: map[string]string{ + korifiv1alpha1.PlanGUIDLabelKey: plan.Name, + }, + Finalizers: []string{ + korifiv1alpha1.CFServiceBindingFinalizerName, + }, + }, + Spec: korifiv1alpha1.CFServiceBindingSpec{ + Service: corev1.ObjectReference{ + Kind: "CFServiceInstance", + APIVersion: korifiv1alpha1.SchemeGroupVersion.Identifier(), + Name: instance.Name, + }, + AppRef: corev1.LocalObjectReference{ + Name: "some-app-guid", + }, + }, + } + Expect(k8sClient.Create(ctx, binding)).To(Succeed()) + + createRoleBinding(ctx, userName, spaceDeveloperRole.Name, space.Name) + createRoleBinding(ctx, userName, adminRole.Name, rootNamespace) + + message = repositories.DeleteServiceOfferingMessage{GUID: offering.Name} + }) + + JustBeforeEach(func() { + deleteErr = repo.DeleteOffering(ctx, authInfo, message) + }) + + It("successfully deletes the offering", func() { + Expect(deleteErr).ToNot(HaveOccurred()) + + namespacedName := types.NamespacedName{ + Name: offering.Name, + Namespace: rootNamespace, + } + + err := k8sClient.Get(context.Background(), namespacedName, &korifiv1alpha1.CFServiceOffering{}) + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), fmt.Sprintf("error: %+v", err)) + }) + + When("the service offering does not exist", func() { + BeforeEach(func() { + message.GUID = "does-not-exist" + }) + + It("returns a error", func() { + Expect(errors.As(deleteErr, &apierrors.NotFoundError{})).To(BeTrue()) + }) + }) + + When("Purge is set to true", func() { + BeforeEach(func() { + message.Purge = true + }) + It("successfully deletes the offering and all related resources", func() { + Expect(deleteErr).ToNot(HaveOccurred()) + + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: offering.Name, Namespace: rootNamespace}, &korifiv1alpha1.CFServiceOffering{}) + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), fmt.Sprintf("error: %+v", err)) + + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: plan.Name, Namespace: rootNamespace}, &korifiv1alpha1.CFServicePlan{}) + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), fmt.Sprintf("error: %+v", err)) + + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: instance.Name, Namespace: space.Name}, &korifiv1alpha1.CFServiceInstance{}) + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), fmt.Sprintf("error: %+v", err)) + + serviceBinding := new(korifiv1alpha1.CFServiceBinding) + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: binding.Name, Namespace: space.Name}, serviceBinding) + + Expect(err).ToNot(HaveOccurred()) + Expect(serviceBinding.Finalizers).To(BeEmpty()) + }) + }) + }) }) 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 5f0e97639..dbdae539f 100644 --- a/helm/korifi/controllers/cf_roles/cf_root_namespace_user.yaml +++ b/helm/korifi/controllers/cf_roles/cf_root_namespace_user.yaml @@ -61,3 +61,4 @@ rules: verbs: - get - list + - delete diff --git a/helm/korifi/controllers/role.yaml b/helm/korifi/controllers/role.yaml index ad805b568..45d93023b 100644 --- a/helm/korifi/controllers/role.yaml +++ b/helm/korifi/controllers/role.yaml @@ -161,6 +161,7 @@ rules: - cfservicebindings/status - cfservicebrokers/status - cfserviceinstances/status + - cfserviceinstances/status - cfspaces/status - cftasks/status verbs: