diff --git a/actor/v7action/cloud_controller_client.go b/actor/v7action/cloud_controller_client.go index 5a75ce37aa0..bdb4c4e8dab 100644 --- a/actor/v7action/cloud_controller_client.go +++ b/actor/v7action/cloud_controller_client.go @@ -116,6 +116,7 @@ type CloudControllerClient interface { GetServiceBrokers(query ...ccv3.Query) ([]resources.ServiceBroker, ccv3.Warnings, error) GetServiceCredentialBindings(query ...ccv3.Query) ([]resources.ServiceCredentialBinding, ccv3.Warnings, error) GetServiceCredentialBindingDetails(guid string) (resources.ServiceCredentialBindingDetails, ccv3.Warnings, error) + GetServiceInstanceByGUID(serviceInstanceGUID string) (resources.ServiceInstance, ccv3.Warnings, error) GetServiceInstanceByNameAndSpace(name, spaceGUID string, query ...ccv3.Query) (resources.ServiceInstance, ccv3.IncludedResources, ccv3.Warnings, error) GetServiceInstanceParameters(serviceInstanceGUID string) (types.JSONObject, ccv3.Warnings, error) GetServiceInstanceSharedSpaces(serviceInstanceGUID string) ([]ccv3.SpaceWithOrganization, ccv3.Warnings, error) diff --git a/actor/v7action/service_app_binding.go b/actor/v7action/service_app_binding.go index 3a00d319a14..9eb02bdc0d9 100644 --- a/actor/v7action/service_app_binding.go +++ b/actor/v7action/service_app_binding.go @@ -18,6 +18,11 @@ type CreateServiceAppBindingParams struct { Strategy resources.BindingStrategyType } +type ListAppBindingParams struct { + SpaceGUID string + AppName string +} + type ListServiceAppBindingParams struct { SpaceGUID string ServiceInstanceName string @@ -65,6 +70,33 @@ func (actor Actor) CreateServiceAppBinding(params CreateServiceAppBindingParams) } } +func (actor Actor) ListAppBindings(params ListAppBindingParams) ([]resources.ServiceCredentialBinding, Warnings, error) { + var ( + app resources.Application + bindings []resources.ServiceCredentialBinding + ) + + warnings, err := railway.Sequentially( + func() (warnings ccv3.Warnings, err error) { + app, warnings, err = actor.CloudControllerClient.GetApplicationByNameAndSpace(params.AppName, params.SpaceGUID) + return + }, + func() (warnings ccv3.Warnings, err error) { + bindings, warnings, err = actor.getServiceAppBindings("", app.GUID) + return + }, + ) + + switch err.(type) { + case nil: + return bindings, Warnings(warnings), nil + case ccerror.ApplicationNotFoundError: + return nil, Warnings(warnings), actionerror.ApplicationNotFoundError{Name: params.AppName} + default: + return nil, Warnings(warnings), err + } +} + func (actor Actor) ListServiceAppBindings(params ListServiceAppBindingParams) ([]resources.ServiceCredentialBinding, Warnings, error) { var ( serviceInstance resources.ServiceInstance @@ -144,16 +176,24 @@ func (actor Actor) createServiceAppBinding(serviceInstanceGUID, appGUID, binding } func (actor Actor) getServiceAppBindings(serviceInstanceGUID, appGUID string) ([]resources.ServiceCredentialBinding, ccv3.Warnings, error) { - bindings, warnings, err := actor.CloudControllerClient.GetServiceCredentialBindings( - ccv3.Query{Key: ccv3.TypeFilter, Values: []string{"app"}}, - ccv3.Query{Key: ccv3.ServiceInstanceGUIDFilter, Values: []string{serviceInstanceGUID}}, - ccv3.Query{Key: ccv3.AppGUIDFilter, Values: []string{appGUID}}, - ) + queries := []ccv3.Query{ + {Key: ccv3.TypeFilter, Values: []string{"app"}}, + {Key: ccv3.AppGUIDFilter, Values: []string{appGUID}}, + } + if serviceInstanceGUID != "" { + queries = append(queries, ccv3.Query{Key: ccv3.ServiceInstanceGUIDFilter, Values: []string{serviceInstanceGUID}}) + } + + bindings, warnings, err := actor.CloudControllerClient.GetServiceCredentialBindings(queries...) switch { case err != nil: return []resources.ServiceCredentialBinding{}, warnings, err case len(bindings) == 0: + // If no specific service instance is requested, return empty set without error. + if serviceInstanceGUID == "" { + return []resources.ServiceCredentialBinding{}, warnings, nil + } return []resources.ServiceCredentialBinding{}, warnings, actionerror.ServiceBindingNotFoundError{ AppGUID: appGUID, ServiceInstanceGUID: serviceInstanceGUID, diff --git a/actor/v7action/service_app_binding_test.go b/actor/v7action/service_app_binding_test.go index aba01e03fe6..fc67134395c 100644 --- a/actor/v7action/service_app_binding_test.go +++ b/actor/v7action/service_app_binding_test.go @@ -251,6 +251,140 @@ var _ = Describe("Service App Binding Action", func() { }) }) + Describe("ListAppBindings", func() { + const ( + appName = "fake-app-name" + appGUID = "fake-app-guid" + spaceGUID = "fake-space-guid" + bindingGUID = "fake-binding-guid" + ) + + var ( + params ListAppBindingParams + warnings Warnings + executionError error + serviceCredentialBindings []resources.ServiceCredentialBinding + ) + + BeforeEach(func() { + fakeCloudControllerClient.GetApplicationByNameAndSpaceReturns( + resources.Application{ + GUID: appGUID, + Name: appName, + }, + ccv3.Warnings{"get app warning"}, + nil, + ) + + fakeCloudControllerClient.GetServiceCredentialBindingsReturns( + []resources.ServiceCredentialBinding{ + {GUID: bindingGUID}, + }, + ccv3.Warnings{"get bindings warning"}, + nil, + ) + + params = ListAppBindingParams{ + SpaceGUID: "fake-space-guid", + AppName: "fake-app-name", + } + }) + + JustBeforeEach(func() { + serviceCredentialBindings, warnings, executionError = actor.ListAppBindings(params) + }) + + It("returns an event stream, warning, and no errors", func() { + Expect(executionError).NotTo(HaveOccurred()) + + Expect(warnings).To(ConsistOf(Warnings{ + "get app warning", + "get bindings warning", + })) + + Expect(serviceCredentialBindings).To(Equal([]resources.ServiceCredentialBinding{{GUID: bindingGUID}})) + }) + + Describe("app lookup", func() { + It("makes the correct call", func() { + Expect(fakeCloudControllerClient.GetApplicationByNameAndSpaceCallCount()).To(Equal(1)) + actualAppName, actualSpaceGUID := fakeCloudControllerClient.GetApplicationByNameAndSpaceArgsForCall(0) + Expect(actualAppName).To(Equal(appName)) + Expect(actualSpaceGUID).To(Equal(spaceGUID)) + }) + + When("not found", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetApplicationByNameAndSpaceReturns( + resources.Application{}, + ccv3.Warnings{"get app warning"}, + ccerror.ApplicationNotFoundError{Name: appName}, + ) + }) + + It("returns the error and warning", func() { + Expect(warnings).To(ContainElement("get app warning")) + Expect(executionError).To(MatchError(actionerror.ApplicationNotFoundError{Name: appName})) + }) + }) + + When("fails", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetApplicationByNameAndSpaceReturns( + resources.Application{}, + ccv3.Warnings{"get app warning"}, + errors.New("boom"), + ) + }) + + It("returns the error and warning", func() { + Expect(warnings).To(ContainElement("get app warning")) + Expect(executionError).To(MatchError("boom")) + }) + }) + }) + + Describe("binding lookup", func() { + It("makes the correct call", func() { + Expect(fakeCloudControllerClient.GetServiceCredentialBindingsCallCount()).To(Equal(1)) + Expect(fakeCloudControllerClient.GetServiceCredentialBindingsArgsForCall(0)).To(ConsistOf( + ccv3.Query{Key: ccv3.TypeFilter, Values: []string{"app"}}, + ccv3.Query{Key: ccv3.AppGUIDFilter, Values: []string{appGUID}}, + )) + }) + + When("there are no bindings", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetServiceCredentialBindingsReturns( + []resources.ServiceCredentialBinding{}, + ccv3.Warnings{"get bindings warning"}, + nil, + ) + }) + + It("returns an empty list", func() { + Expect(warnings).To(ContainElement("get bindings warning")) + Expect(serviceCredentialBindings).To(Equal([]resources.ServiceCredentialBinding{})) + }) + }) + + When("fails", func() { + BeforeEach(func() { + fakeCloudControllerClient.GetServiceCredentialBindingsReturns( + []resources.ServiceCredentialBinding{}, + ccv3.Warnings{"get binding warning"}, + errors.New("boom"), + ) + }) + + It("returns the error and warning", func() { + Expect(warnings).To(ContainElement("get binding warning")) + Expect(executionError).To(MatchError("boom")) + }) + }) + }) + }) + Describe("ListServiceAppBindings", func() { const ( serviceInstanceName = "fake-service-instance-name" diff --git a/actor/v7action/service_instance.go b/actor/v7action/service_instance.go index 04b0586484b..4e3a5604ff2 100644 --- a/actor/v7action/service_instance.go +++ b/actor/v7action/service_instance.go @@ -33,6 +33,11 @@ func (actor Actor) GetServiceInstanceByNameAndSpace(serviceInstanceName string, return serviceInstance, Warnings(warnings), err } +func (actor Actor) GetServiceInstanceByGUID(serviceInstanceGUID string) (resources.ServiceInstance, Warnings, error) { + serviceInstance, warnings, err := actor.CloudControllerClient.GetServiceInstanceByGUID(serviceInstanceGUID) + return serviceInstance, Warnings(warnings), err +} + func (actor Actor) CreateUserProvidedServiceInstance(serviceInstance resources.ServiceInstance) (Warnings, error) { serviceInstance.Type = resources.UserProvidedServiceInstance _, warnings, err := actor.CloudControllerClient.CreateServiceInstance(serviceInstance) diff --git a/actor/v7action/v7actionfakes/fake_cloud_controller_client.go b/actor/v7action/v7actionfakes/fake_cloud_controller_client.go index da11432a92e..5a1a5025a83 100644 --- a/actor/v7action/v7actionfakes/fake_cloud_controller_client.go +++ b/actor/v7action/v7actionfakes/fake_cloud_controller_client.go @@ -1601,6 +1601,21 @@ type FakeCloudControllerClient struct { result2 ccv3.Warnings result3 error } + GetServiceInstanceByGUIDStub func(string) (resources.ServiceInstance, ccv3.Warnings, error) + getServiceInstanceByGUIDMutex sync.RWMutex + getServiceInstanceByGUIDArgsForCall []struct { + arg1 string + } + getServiceInstanceByGUIDReturns struct { + result1 resources.ServiceInstance + result2 ccv3.Warnings + result3 error + } + getServiceInstanceByGUIDReturnsOnCall map[int]struct { + result1 resources.ServiceInstance + result2 ccv3.Warnings + result3 error + } GetServiceInstanceByNameAndSpaceStub func(string, string, ...ccv3.Query) (resources.ServiceInstance, ccv3.IncludedResources, ccv3.Warnings, error) getServiceInstanceByNameAndSpaceMutex sync.RWMutex getServiceInstanceByNameAndSpaceArgsForCall []struct { @@ -9839,6 +9854,73 @@ func (fake *FakeCloudControllerClient) GetServiceCredentialBindingsReturnsOnCall }{result1, result2, result3} } +func (fake *FakeCloudControllerClient) GetServiceInstanceByGUID(arg1 string) (resources.ServiceInstance, ccv3.Warnings, error) { + fake.getServiceInstanceByGUIDMutex.Lock() + ret, specificReturn := fake.getServiceInstanceByGUIDReturnsOnCall[len(fake.getServiceInstanceByGUIDArgsForCall)] + fake.getServiceInstanceByGUIDArgsForCall = append(fake.getServiceInstanceByGUIDArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.GetServiceInstanceByGUIDStub + fakeReturns := fake.getServiceInstanceByGUIDReturns + fake.recordInvocation("GetServiceInstanceByGUID", []interface{}{arg1}) + fake.getServiceInstanceByGUIDMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeCloudControllerClient) GetServiceInstanceByGUIDCallCount() int { + fake.getServiceInstanceByGUIDMutex.RLock() + defer fake.getServiceInstanceByGUIDMutex.RUnlock() + return len(fake.getServiceInstanceByGUIDArgsForCall) +} + +func (fake *FakeCloudControllerClient) GetServiceInstanceByGUIDCalls(stub func(string) (resources.ServiceInstance, ccv3.Warnings, error)) { + fake.getServiceInstanceByGUIDMutex.Lock() + defer fake.getServiceInstanceByGUIDMutex.Unlock() + fake.GetServiceInstanceByGUIDStub = stub +} + +func (fake *FakeCloudControllerClient) GetServiceInstanceByGUIDArgsForCall(i int) string { + fake.getServiceInstanceByGUIDMutex.RLock() + defer fake.getServiceInstanceByGUIDMutex.RUnlock() + argsForCall := fake.getServiceInstanceByGUIDArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeCloudControllerClient) GetServiceInstanceByGUIDReturns(result1 resources.ServiceInstance, result2 ccv3.Warnings, result3 error) { + fake.getServiceInstanceByGUIDMutex.Lock() + defer fake.getServiceInstanceByGUIDMutex.Unlock() + fake.GetServiceInstanceByGUIDStub = nil + fake.getServiceInstanceByGUIDReturns = struct { + result1 resources.ServiceInstance + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeCloudControllerClient) GetServiceInstanceByGUIDReturnsOnCall(i int, result1 resources.ServiceInstance, result2 ccv3.Warnings, result3 error) { + fake.getServiceInstanceByGUIDMutex.Lock() + defer fake.getServiceInstanceByGUIDMutex.Unlock() + fake.GetServiceInstanceByGUIDStub = nil + if fake.getServiceInstanceByGUIDReturnsOnCall == nil { + fake.getServiceInstanceByGUIDReturnsOnCall = make(map[int]struct { + result1 resources.ServiceInstance + result2 ccv3.Warnings + result3 error + }) + } + fake.getServiceInstanceByGUIDReturnsOnCall[i] = struct { + result1 resources.ServiceInstance + result2 ccv3.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeCloudControllerClient) GetServiceInstanceByNameAndSpace(arg1 string, arg2 string, arg3 ...ccv3.Query) (resources.ServiceInstance, ccv3.IncludedResources, ccv3.Warnings, error) { fake.getServiceInstanceByNameAndSpaceMutex.Lock() ret, specificReturn := fake.getServiceInstanceByNameAndSpaceReturnsOnCall[len(fake.getServiceInstanceByNameAndSpaceArgsForCall)] diff --git a/api/cloudcontroller/ccv3/internal/api_routes.go b/api/cloudcontroller/ccv3/internal/api_routes.go index a5a7ba50cbd..d1f7ffd9fe6 100644 --- a/api/cloudcontroller/ccv3/internal/api_routes.go +++ b/api/cloudcontroller/ccv3/internal/api_routes.go @@ -86,6 +86,7 @@ const ( GetServiceCredentialBindingsRequest = "GetServiceCredentialBindings" GetServiceCredentialBindingDetailsRequest = "GetServiceCredentialBindingDetails" GetServiceInstanceParametersRequest = "GetServiceInstanceParameters" + GetServiceInstanceRequest = "GetServiceInstance" GetServiceInstancesRequest = "GetServiceInstances" GetServiceInstanceRelationshipsSharedSpacesRequest = "GetServiceInstanceRelationshipSharedSpacesRequest" GetServiceInstanceSharedSpacesUsageSummaryRequest = "GetServiceInstanceSharedSpacesUsageSummaryRequest" @@ -305,6 +306,7 @@ var APIRoutes = map[string]Route{ GetServiceCredentialBindingsRequest: {Path: "/v3/service_credential_bindings", Method: http.MethodGet}, DeleteServiceCredentialBindingRequest: {Path: "/v3/service_credential_bindings/:service_credential_binding_guid", Method: http.MethodDelete}, GetServiceCredentialBindingDetailsRequest: {Path: "/v3/service_credential_bindings/:service_credential_binding_guid/details", Method: http.MethodGet}, + GetServiceInstanceRequest: {Path: "/v3/service_instances/:service_instance_guid", Method: http.MethodGet}, GetServiceInstancesRequest: {Path: "/v3/service_instances", Method: http.MethodGet}, PostServiceInstanceRequest: {Path: "/v3/service_instances", Method: http.MethodPost}, GetServiceInstanceParametersRequest: {Path: "/v3/service_instances/:service_instance_guid/parameters", Method: http.MethodGet}, diff --git a/api/cloudcontroller/ccv3/service_instance.go b/api/cloudcontroller/ccv3/service_instance.go index d988543d099..a8db979b7a6 100644 --- a/api/cloudcontroller/ccv3/service_instance.go +++ b/api/cloudcontroller/ccv3/service_instance.go @@ -32,6 +32,18 @@ func (client *Client) GetServiceInstances(query ...Query) ([]resources.ServiceIn return result, included, warnings, err } +func (client *Client) GetServiceInstanceByGUID(serviceInstanceGUID string) (resources.ServiceInstance, Warnings, error) { + var result resources.ServiceInstance + + _, warnings, err := client.MakeRequest(RequestParams{ + RequestName: internal.GetServiceInstanceRequest, + URIParams: internal.Params{"service_instance_guid": serviceInstanceGUID}, + ResponseBody: &result, + }) + + return result, warnings, err +} + func (client *Client) GetServiceInstanceByNameAndSpace(name, spaceGUID string, query ...Query) (resources.ServiceInstance, IncludedResources, Warnings, error) { query = append(query, Query{Key: NameFilter, Values: []string{name}}, diff --git a/api/cloudcontroller/ccv3/service_instance_test.go b/api/cloudcontroller/ccv3/service_instance_test.go index fcd8dc6dd8d..4847a7f2446 100644 --- a/api/cloudcontroller/ccv3/service_instance_test.go +++ b/api/cloudcontroller/ccv3/service_instance_test.go @@ -130,6 +130,98 @@ var _ = Describe("Service Instance", func() { }) }) + Describe("GetServiceInstanceByGUID", func() { + const guid = "fake-guid" + const name = "fake-name" + + var ( + instance resources.ServiceInstance + warnings Warnings + executeErr error + ) + + JustBeforeEach(func() { + instance, warnings, executeErr = client.GetServiceInstanceByGUID(guid) + }) + + When("service instance exists", func() { + BeforeEach(func() { + requester.MakeRequestCalls(func(params RequestParams) (JobURL, Warnings, error) { + Expect(params.URIParams).To(BeEquivalentTo(map[string]string{"service_instance_guid": guid})) + Expect(params.RequestName).To(Equal(internal.GetServiceInstanceRequest)) + params.ResponseBody.(*resources.ServiceInstance).GUID = guid + params.ResponseBody.(*resources.ServiceInstance).Name = name + return "", Warnings{"warning-1", "warning-2"}, nil + }) + }) + + It("returns the service instance with warnings", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(instance).To(Equal( + resources.ServiceInstance{ + GUID: "fake-guid", + Name: "fake-name", + }, + )) + Expect(warnings).To(ConsistOf("warning-1", "warning-2")) + Expect(requester.MakeRequestCallCount()).To(Equal(1)) + + actualParams := requester.MakeRequestArgsForCall(0) + Expect(actualParams.URIParams).To(Equal(internal.Params{"service_instance_guid": guid})) + Expect(actualParams.RequestName).To(Equal(internal.GetServiceInstanceRequest)) + Expect(actualParams.ResponseBody).To(BeAssignableToTypeOf(&resources.ServiceInstance{})) + }) + }) + + When("the cloud controller returns errors and warnings", func() { + BeforeEach(func() { + errors := []ccerror.V3Error{ + { + Code: 42424, + Detail: "Some detailed error message", + Title: "CF-SomeErrorTitle", + }, + { + Code: 11111, + Detail: "Some other detailed error message", + Title: "CF-SomeOtherErrorTitle", + }, + } + + requester.MakeListRequestReturns( + IncludedResources{}, + Warnings{"this is a warning"}, + ccerror.MultiError{ResponseCode: http.StatusTeapot, Errors: errors}, + ) + + requester.MakeRequestReturns( + "", + Warnings{"this is a warning"}, + ccerror.MultiError{ResponseCode: http.StatusTeapot, Errors: errors}) + }) + + It("returns the error and all warnings", func() { + Expect(executeErr).To(MatchError(ccerror.MultiError{ + ResponseCode: http.StatusTeapot, + Errors: []ccerror.V3Error{ + { + Code: 42424, + Detail: "Some detailed error message", + Title: "CF-SomeErrorTitle", + }, + { + Code: 11111, + Detail: "Some other detailed error message", + Title: "CF-SomeOtherErrorTitle", + }, + }, + })) + Expect(warnings).To(ConsistOf("this is a warning")) + }) + }) + }) + Describe("GetServiceInstanceByNameAndSpace", func() { const ( name = "fake-service-instance-name" diff --git a/command/common/command_list_v7.go b/command/common/command_list_v7.go index 6ab37305d7c..65297fc97f6 100644 --- a/command/common/command_list_v7.go +++ b/command/common/command_list_v7.go @@ -31,6 +31,7 @@ type commandList struct { Buildpacks v7.BuildpacksCommand `command:"buildpacks" description:"List all buildpacks"` CancelDeployment v7.CancelDeploymentCommand `command:"cancel-deployment" description:"Cancel the most recent deployment for an app. Resets the current droplet to the previous deployment's droplet."` CheckRoute v7.CheckRouteCommand `command:"check-route" description:"Perform a check to determine whether a route currently exists or not"` + CleanupOutdatedServiceBindings v7.CleanupOutdatedServiceBindingsCommand `command:"cleanup-outdated-service-bindings" description:"Cleans up old service bindings for an app, keeping only the most recent binding for each service instance"` Config v7.ConfigCommand `command:"config" description:"Write default values to the config"` ContinueDeployment v7.ContinueDeploymentCommand `command:"continue-deployment" description:"Continue the most recent deployment for an app."` CopySource v7.CopySourceCommand `command:"copy-source" description:"Copies the source code of an application to another existing application and restages that application"` diff --git a/command/common/internal/help_all_display.go b/command/common/internal/help_all_display.go index 9bbd28714a6..7b0bf308c06 100644 --- a/command/common/internal/help_all_display.go +++ b/command/common/internal/help_all_display.go @@ -33,7 +33,7 @@ var HelpCategoryList = []HelpCategory{ {"marketplace", "services", "service"}, {"create-service", "update-service", "upgrade-service", "delete-service", "rename-service"}, {"create-service-key", "service-keys", "service-key", "delete-service-key"}, - {"bind-service", "unbind-service"}, + {"bind-service", "unbind-service", "cleanup-outdated-service-bindings"}, {"bind-route-service", "unbind-route-service"}, {"create-user-provided-service", "update-user-provided-service"}, {"share-service", "unshare-service"}, diff --git a/command/v7/actor.go b/command/v7/actor.go index 4d29f289479..f852faa9e5d 100644 --- a/command/v7/actor.go +++ b/command/v7/actor.go @@ -160,6 +160,7 @@ type Actor interface { GetServiceBrokers() ([]resources.ServiceBroker, v7action.Warnings, error) GetServiceKeyByServiceInstanceAndName(serviceInstanceName, serviceKeyName, spaceGUID string) (resources.ServiceCredentialBinding, v7action.Warnings, error) GetServiceKeyDetailsByServiceInstanceAndName(serviceInstanceName, serviceKeyName, spaceGUID string) (resources.ServiceCredentialBindingDetails, v7action.Warnings, error) + GetServiceInstanceByGUID(serviceInstanceGUID string) (resources.ServiceInstance, v7action.Warnings, error) GetServiceInstanceByNameAndSpace(serviceInstanceName, spaceGUID string) (resources.ServiceInstance, v7action.Warnings, error) GetServiceInstanceDetails(serviceInstanceName, spaceGUID string, omitApps bool) (v7action.ServiceInstanceDetails, v7action.Warnings, error) GetServiceInstanceParameters(serviceInstanceName, spaceGUID string) (v7action.ServiceInstanceParameters, v7action.Warnings, error) @@ -184,6 +185,7 @@ type Actor interface { GetUAAAPIVersion() (string, error) GetUnstagedNewestPackageGUID(appGuid string) (string, v7action.Warnings, error) GetUser(username, origin string) (resources.User, error) + ListAppBindings(params v7action.ListAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error) ListServiceAppBindings(params v7action.ListServiceAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error) MakeCurlRequest(httpMethod string, path string, customHeaders []string, httpData string, failOnHTTPError bool) ([]byte, *http.Response, error) MapRoute(routeGUID string, appGUID string, destinationProtocol string) (v7action.Warnings, error) diff --git a/command/v7/cleanup_outdated_service_bindings_command.go b/command/v7/cleanup_outdated_service_bindings_command.go new file mode 100644 index 00000000000..0b0ab818322 --- /dev/null +++ b/command/v7/cleanup_outdated_service_bindings_command.go @@ -0,0 +1,194 @@ +package v7 + +import ( + "fmt" + "sort" + "time" + + "code.cloudfoundry.org/cli/v9/actor/v7action" + "code.cloudfoundry.org/cli/v9/command/flag" + "code.cloudfoundry.org/cli/v9/command/v7/shared" + "code.cloudfoundry.org/cli/v9/resources" +) + +type CleanupOutdatedServiceBindingsCommand struct { + BaseCommand + + RequiredArgs flag.AppName `positional-args:"yes"` + Force bool `long:"force" short:"f" description:"Force deletion without confirmation"` + KeepLast *int `long:"keep-last" description:"Keep the last N service bindings (default: 1)"` + ServiceInstanceName string `long:"service-instance" description:"Only delete service bindings for the specified service instance"` + Wait bool `long:"wait" short:"w" description:"Wait for the operation(s) to complete"` + + relatedCommands interface{} `related_commands:"bind-service, unbind-service"` +} + +type bindingKey struct { + AppGUID string + ServiceInstanceGUID string +} + +func (cmd CleanupOutdatedServiceBindingsCommand) Execute(args []string) error { + if err := cmd.SharedActor.CheckTarget(true, true); err != nil { + return err + } + if err := cmd.displayIntro(); err != nil { + return err + } + + var ( + bindings []resources.ServiceCredentialBinding + warnings v7action.Warnings + err error + ) + + if cmd.ServiceInstanceName == "" { + bindings, warnings, err = cmd.Actor.ListAppBindings( + v7action.ListAppBindingParams{ + SpaceGUID: cmd.Config.TargetedSpace().GUID, + AppName: cmd.RequiredArgs.AppName, + }) + } else { + bindings, warnings, err = cmd.Actor.ListServiceAppBindings( + v7action.ListServiceAppBindingParams{ + SpaceGUID: cmd.Config.TargetedSpace().GUID, + ServiceInstanceName: cmd.ServiceInstanceName, + AppName: cmd.RequiredArgs.AppName, + }) + } + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + keepLast := 1 + if cmd.KeepLast != nil { + if *cmd.KeepLast > 0 { + keepLast = *cmd.KeepLast + } else { + cmd.UI.DisplayWarning(fmt.Sprintf("Invalid argument for --keep-last: %d. Using default value of 1.", *cmd.KeepLast)) + } + } + + bindingsToDelete := GetOutdatedServiceBindings(bindings, keepLast) + + if len(bindingsToDelete) == 0 { + cmd.UI.DisplayText("No outdated service bindings found.") + cmd.UI.DisplayOK() + return nil + } else if len(bindingsToDelete) == 1 { + cmd.UI.DisplayText("Found 1 outdated service binding.") + } else { + cmd.UI.DisplayText(fmt.Sprintf("Found %d outdated service bindings.", len(bindingsToDelete))) + } + + if !cmd.Force { + response, promptErr := cmd.UI.DisplayBoolPrompt(false, "Really delete all outdated service bindings?") + + if promptErr != nil { + return promptErr + } + + if !response { + cmd.UI.DisplayText("Outdated service bindings have not been deleted.") + return nil + } + } + + for _, binding := range bindingsToDelete { + cmd.UI.DisplayText("Deleting service binding {{.BindingGUID}}...", map[string]interface{}{"BindingGUID": binding.GUID}) + + stream, warnings, err := cmd.Actor.DeleteServiceAppBinding(v7action.DeleteServiceAppBindingParams{ + ServiceBindingGUID: binding.GUID, + }) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + completed, err := shared.WaitForResult(stream, cmd.UI, cmd.Wait) + switch { + case err != nil: + return err + case completed: + cmd.UI.DisplayOK() + default: + si, warnings, err := cmd.Actor.GetServiceInstanceByGUID(binding.ServiceInstanceGUID) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + cmd.UI.DisplayOK() + cmd.UI.DisplayText("Unbinding in progress. Use 'cf service {{.ServiceInstanceName}}' to check operation status.", map[string]interface{}{"ServiceInstanceName": si.Name}) + } + } + + return nil +} + +func (cmd CleanupOutdatedServiceBindingsCommand) Usage() string { + return `CF_NAME cleanup-outdated-service-bindings APP_NAME [--keep-last N] [--service-instance SERVICE_INSTANCE_NAME] [--force] [--wait]` +} + +func (cmd CleanupOutdatedServiceBindingsCommand) Examples() string { + return ` +CF_NAME cleanup-outdated-service-bindings myapp +CF_NAME cleanup-outdated-service-bindings myapp --keep-last 2 --service-instance myinstance --wait +` +} + +// GetOutdatedServiceBindings returns a list that is sorted by 1. ServiceInstanceGUID and 2. CreatedAt ascending +func GetOutdatedServiceBindings(bindings []resources.ServiceCredentialBinding, keepLast int) []resources.ServiceCredentialBinding { + bindingGroups := make(map[bindingKey][]resources.ServiceCredentialBinding) + for _, binding := range bindings { + key := bindingKey{binding.AppGUID, binding.ServiceInstanceGUID} + bindingGroups[key] = append(bindingGroups[key], binding) + } + + parseTime := func(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t + } + + var outdatedBindings []resources.ServiceCredentialBinding + for key := range bindingGroups { + slice := bindingGroups[key] + sort.Slice(slice, func(i, j int) bool { + return parseTime(slice[i].CreatedAt).After(parseTime(slice[j].CreatedAt)) + }) + if len(slice) > keepLast { + outdatedBindings = append(outdatedBindings, slice[keepLast:]...) + } + } + + sort.SliceStable(outdatedBindings, func(i, j int) bool { + if outdatedBindings[i].ServiceInstanceGUID != outdatedBindings[j].ServiceInstanceGUID { + return outdatedBindings[i].ServiceInstanceGUID < outdatedBindings[j].ServiceInstanceGUID + } + return parseTime(outdatedBindings[i].CreatedAt).Before(parseTime(outdatedBindings[j].CreatedAt)) + }) + + return outdatedBindings +} + +func (cmd CleanupOutdatedServiceBindingsCommand) displayIntro() error { + user, err := cmd.Actor.GetCurrentUser() + if err != nil { + return err + } + + cmd.UI.DisplayTextWithFlavor( + "Cleaning up outdated service bindings for app {{.AppName}} in org {{.Org}} / space {{.Space}} as {{.User}}...", + map[string]interface{}{ + "AppName": cmd.RequiredArgs.AppName, + "User": user.Name, + "Space": cmd.Config.TargetedSpace().Name, + "Org": cmd.Config.TargetedOrganization().Name, + }, + ) + + return nil +} diff --git a/command/v7/cleanup_outdated_service_bindings_command_test.go b/command/v7/cleanup_outdated_service_bindings_command_test.go new file mode 100644 index 00000000000..d8937f7bd6d --- /dev/null +++ b/command/v7/cleanup_outdated_service_bindings_command_test.go @@ -0,0 +1,503 @@ +package v7_test + +import ( + "errors" + "math/rand" + + "code.cloudfoundry.org/cli/v9/actor/v7action" + "code.cloudfoundry.org/cli/v9/command/commandfakes" + v7 "code.cloudfoundry.org/cli/v9/command/v7" + "code.cloudfoundry.org/cli/v9/command/v7/v7fakes" + "code.cloudfoundry.org/cli/v9/resources" + "code.cloudfoundry.org/cli/v9/util/configv3" + "code.cloudfoundry.org/cli/v9/util/ui" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" +) + +var _ = Describe("cleanup-outdated-service-bindings Command", func() { + var ( + cmd v7.CleanupOutdatedServiceBindingsCommand + testUI *ui.UI + input *Buffer + confirmYes, confirmNo func() error + executeErr error + fakeConfig *commandfakes.FakeConfig + fakeSharedActor *commandfakes.FakeSharedActor + fakeActor *v7fakes.FakeActor + ) + + const ( + fakeUserName = "fake-user-name" + fakeOrgName = "fake-org-name" + fakeSpaceName = "fake-space-name" + fakeSpaceGUID = "fake-space-guid" + + fakeAppName1 = "fake-app-name-1" + fakeAppGUID1 = "fake-app-guid-1" + + fakeAppName2 = "fake-app-name-2" + fakeAppGUID2 = "fake-app-guid-2" + + fakeServiceInstanceGUID1 = "fake-service-instance-guid-1" + + fakeServiceInstanceName2 = "fake-service-instance-name-2" + fakeServiceInstanceGUID2 = "fake-service-instance-guid-2" + + fakeBindingGUID1 = "fake-binding-guid-1" + fakeTimestamp1 = "2026-01-01T12:00:00Z" + fakeBindingGUID2 = "fake-binding-guid-2" + fakeTimestamp2 = "2026-01-02T12:00:00Z" + fakeBindingGUID3 = "fake-binding-guid-3" + fakeTimestamp3 = "2026-01-03T12:00:00Z" + fakeBindingGUID4 = "fake-binding-guid-4" + fakeTimestamp4 = "2026-01-04T12:00:00Z" + fakeBindingGUID5 = "fake-binding-guid-5" + fakeTimestamp5 = "2026-01-05T12:00:00Z" + fakeBindingGUID6 = "fake-binding-guid-6" + fakeTimestamp6 = "2026-01-06T12:00:00Z" + ) + + BeforeEach(func() { + fakeConfig = new(commandfakes.FakeConfig) + fakeSharedActor = new(commandfakes.FakeSharedActor) + fakeActor = new(v7fakes.FakeActor) + + input = NewBuffer() + testUI = ui.NewTestUI(input, NewBuffer(), NewBuffer()) + confirmYes = func() error { _, err := input.Write([]byte("y\n")); return err } + confirmNo = func() error { _, err := input.Write([]byte("N\n")); return err } + + cmd = v7.CleanupOutdatedServiceBindingsCommand{ + BaseCommand: v7.BaseCommand{ + UI: testUI, + Config: fakeConfig, + SharedActor: fakeSharedActor, + Actor: fakeActor, + }, + } + + fakeConfig.TargetedSpaceReturns(configv3.Space{ + Name: fakeSpaceName, + GUID: fakeSpaceGUID, + }) + + fakeConfig.TargetedOrganizationReturns(configv3.Organization{Name: fakeOrgName}) + + fakeActor.GetCurrentUserReturns(configv3.User{Name: fakeUserName}, nil) + + fakeActor.ListAppBindingsReturns( + []resources.ServiceCredentialBinding{ + // without any optional parameters, fakeBindingGUID1, 2 and 4 should be deleted (keeping last 1 per service instance) + {GUID: fakeBindingGUID1, CreatedAt: fakeTimestamp1, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID1}, + {GUID: fakeBindingGUID2, CreatedAt: fakeTimestamp2, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + {GUID: fakeBindingGUID3, CreatedAt: fakeTimestamp3, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID1}, + {GUID: fakeBindingGUID4, CreatedAt: fakeTimestamp4, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + {GUID: fakeBindingGUID5, CreatedAt: fakeTimestamp5, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + // binding for another app - should be ignored + {GUID: fakeBindingGUID6, CreatedAt: fakeTimestamp6, AppName: fakeAppName2, AppGUID: fakeAppGUID2, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + }, + v7action.Warnings{"list warning"}, + nil, + ) + + fakeActor.DeleteServiceAppBindingReturnsOnCall(0, + nil, + v7action.Warnings{"delete warning 1"}, + nil, + ) + fakeActor.DeleteServiceAppBindingReturnsOnCall(1, + nil, + v7action.Warnings{"delete warning 2"}, + nil, + ) + fakeActor.DeleteServiceAppBindingReturnsOnCall(2, + nil, + v7action.Warnings{"delete warning 3"}, + nil, + ) + + setPositionalFlags(&cmd, fakeAppName1) + }) + + JustBeforeEach(func() { + executeErr = cmd.Execute(nil) + }) + + Describe("user and target setup", func() { + BeforeEach(func() { + err := confirmYes() + Expect(err).ToNot(HaveOccurred()) + }) + + It("checks the user is logged in, and targeting an org and space", func() { + Expect(executeErr).NotTo(HaveOccurred()) + + Expect(fakeSharedActor.CheckTargetCallCount()).To(Equal(1)) + actualOrg, actualSpace := fakeSharedActor.CheckTargetArgsForCall(0) + Expect(actualOrg).To(BeTrue()) + Expect(actualSpace).To(BeTrue()) + }) + }) + + Describe("intro message", func() { + BeforeEach(func() { + err := confirmYes() + Expect(err).ToNot(HaveOccurred()) + }) + + It("prints an intro and warnings and a user confirmation message", func() { + Expect(executeErr).NotTo(HaveOccurred()) + // Warnings from list + delete + Expect(testUI.Err).To(SatisfyAll( + Say("list warning"), + )) + + Expect(testUI.Out).To(Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, fakeAppName1, fakeOrgName, fakeSpaceName, fakeUserName)) + Expect(testUI.Out).To(Say(`Really delete all outdated service bindings\?`)) + }) + }) + + Describe("GetOutdatedServiceBindings function", func() { + var ( + bindings []resources.ServiceCredentialBinding + ) + + BeforeEach(func() { + bindings = []resources.ServiceCredentialBinding{ + {GUID: "g1", AppGUID: "a1", ServiceInstanceGUID: "s1", CreatedAt: "2026-01-01T00:00:00Z"}, + {GUID: "g2", AppGUID: "a1", ServiceInstanceGUID: "s1", CreatedAt: "2026-01-02T00:00:00Z"}, + {GUID: "g3", AppGUID: "a1", ServiceInstanceGUID: "s2", CreatedAt: "2026-01-01T00:00:00Z"}, + {GUID: "g4", AppGUID: "a1", ServiceInstanceGUID: "s2", CreatedAt: "2026-01-03T00:00:00Z"}, + {GUID: "g5", AppGUID: "a1", ServiceInstanceGUID: "s2", CreatedAt: "2026-01-02T00:00:00Z"}, + } + r := rand.New(rand.NewSource(GinkgoRandomSeed())) + r.Shuffle(len(bindings), func(i, j int) { bindings[i], bindings[j] = bindings[j], bindings[i] }) + }) + + It("returns an empty list if an empty list of bindings is passed", func() { + outdatedBindings := v7.GetOutdatedServiceBindings([]resources.ServiceCredentialBinding{}, 1) + Expect(outdatedBindings).To(BeEmpty()) + }) + + It("returns all but the newest per app/service pair when keepLast=1, sorted by 1. ServiceInstanceGUID and 2. CreatedAt", func() { + outdatedBindings := v7.GetOutdatedServiceBindings(bindings, 1) + + Expect(outdatedBindings).To(HaveExactElements( + resources.ServiceCredentialBinding{GUID: "g1", AppGUID: "a1", ServiceInstanceGUID: "s1", CreatedAt: "2026-01-01T00:00:00Z"}, + resources.ServiceCredentialBinding{GUID: "g3", AppGUID: "a1", ServiceInstanceGUID: "s2", CreatedAt: "2026-01-01T00:00:00Z"}, + resources.ServiceCredentialBinding{GUID: "g5", AppGUID: "a1", ServiceInstanceGUID: "s2", CreatedAt: "2026-01-02T00:00:00Z"}, + )) + }) + + It("respects keepLast > 1", func() { + outdatedBindings := v7.GetOutdatedServiceBindings(bindings, 2) + + Expect(outdatedBindings).To(HaveExactElements( + resources.ServiceCredentialBinding{GUID: "g3", AppGUID: "a1", ServiceInstanceGUID: "s2", CreatedAt: "2026-01-01T00:00:00Z"}, + )) + }) + }) + + Context("multiple bindings exist", func() { + When("user confirms with yes", func() { + BeforeEach(func() { + err := confirmYes() + Expect(err).ToNot(HaveOccurred()) + }) + + When("no --service-instance parameter is provided", func() { + It("lists bindings then delegates deletion of outdated bindings to the actor by GUID", func() { + Expect(fakeActor.ListAppBindingsCallCount()).To(Equal(1)) + Expect(fakeActor.ListAppBindingsArgsForCall(0)).To(Equal(v7action.ListAppBindingParams{ + SpaceGUID: fakeSpaceGUID, + AppName: fakeAppName1, + })) + + Expect(fakeActor.DeleteServiceAppBindingCallCount()).To(Equal(3)) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(0)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID1})) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(1)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID2})) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(2)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID4})) + }) + + It("prints deleting messages for all outdated bindings", func() { + Expect(testUI.Out).To(Say(`Found 3 outdated service bindings\.`)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID1)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID2)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID4)) + }) + }) + + When("--service-instance parameter is set", func() { + BeforeEach(func() { + fakeActor.ListServiceAppBindingsReturns( + []resources.ServiceCredentialBinding{ + // fakeBindingGUID2 and 4 should be deleted + {GUID: fakeBindingGUID2, CreatedAt: fakeTimestamp2, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + {GUID: fakeBindingGUID4, CreatedAt: fakeTimestamp4, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + {GUID: fakeBindingGUID5, CreatedAt: fakeTimestamp5, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + // binding for another app - should be ignored + {GUID: fakeBindingGUID6, CreatedAt: fakeTimestamp6, AppName: fakeAppName2, AppGUID: fakeAppGUID2, ServiceInstanceGUID: fakeServiceInstanceGUID2}, + }, + v7action.Warnings{"list warning"}, + nil, + ) + + setFlag(&cmd, "--service-instance", fakeServiceInstanceName2) + }) + + It("prints deleting messages for all outdated bindings", func() { + Expect(testUI.Out).To(Say(`Found 2 outdated service bindings\.`)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID2)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID4)) + }) + + It("lists bindings for the given service and then delegates deletion of outdated bindings to the actor by GUID", func() { + Expect(fakeActor.ListAppBindingsCallCount()).To(Equal(0)) + Expect(fakeActor.ListServiceAppBindingsCallCount()).To(Equal(1)) + Expect(fakeActor.ListServiceAppBindingsArgsForCall(0)).To(Equal(v7action.ListServiceAppBindingParams{ + SpaceGUID: fakeSpaceGUID, + ServiceInstanceName: fakeServiceInstanceName2, + AppName: fakeAppName1, + })) + + Expect(fakeActor.DeleteServiceAppBindingCallCount()).To(Equal(2)) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(0)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID2})) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(1)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID4})) + }) + }) + + When("--keep-last parameter is provided", func() { + BeforeEach(func() { + setFlag(&cmd, "--keep-last", func() *int { i := 2; return &i }()) + }) + + It("lists bindings then delegates deletion of outdated bindings to the actor by GUID", func() { + Expect(fakeActor.ListAppBindingsCallCount()).To(Equal(1)) + Expect(fakeActor.ListAppBindingsArgsForCall(0)).To(Equal(v7action.ListAppBindingParams{ + SpaceGUID: fakeSpaceGUID, + AppName: fakeAppName1, + })) + + Expect(fakeActor.DeleteServiceAppBindingCallCount()).To(Equal(1)) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(0)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID2})) + }) + + It("prints deleting messages for all outdated bindings", func() { + Expect(testUI.Out).To(Say(`Found 1 outdated service binding\.`)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID2)) + }) + }) + }) + + When("user confirms with no", func() { + BeforeEach(func() { + err := confirmNo() + Expect(err).ToNot(HaveOccurred()) + }) + + It("aborts the operation", func() { + Expect(executeErr).To(BeNil()) + Expect(testUI.Out).To(Say("Outdated service bindings have not been deleted.")) + }) + + It("does not delete any bindings", func() { + Expect(fakeActor.DeleteServiceAppBindingCallCount()).To(Equal(0)) + }) + }) + + When("the --force flag is set", func() { + BeforeEach(func() { + setFlag(&cmd, "--force") + }) + + It("deletes the outdated bindings without asking the user for confirmation", func() { + Expect(executeErr).To(BeNil()) + Expect(testUI.Out).To(Say(`Found 3 outdated service bindings\.`)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID1)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID2)) + Expect(testUI.Out).To(Say(`Deleting service binding %s\.\.\.`, fakeBindingGUID4)) + }) + + It("delegates deletion of outdated bindings to the actor by GUID", func() { + Expect(fakeActor.DeleteServiceAppBindingCallCount()).To(Equal(3)) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(0)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID1})) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(1)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID2})) + Expect(fakeActor.DeleteServiceAppBindingArgsForCall(2)).To(Equal(v7action.DeleteServiceAppBindingParams{ServiceBindingGUID: fakeBindingGUID4})) + }) + }) + }) + + Describe("processing the response streams", func() { + BeforeEach(func() { + err := confirmYes() + Expect(err).ToNot(HaveOccurred()) + }) + + Context("nil stream", func() { + It("prints per-binding delete message, OK, and warnings", func() { + Expect(testUI.Out).To(SatisfyAll( + Say(`Found 3 outdated service bindings\.\n`), + Say(`Deleting service binding %s\.\.\.\n`, fakeBindingGUID1), + Say(`Deleting service binding %s\.\.\.\n`, fakeBindingGUID2), + Say(`Deleting service binding %s\.\.\.\n`, fakeBindingGUID4), + Say(`OK\n`), + )) + Expect(testUI.Err).To(SatisfyAll( + Say("list warning"), + Say("delete warning 1"), + Say("delete warning 2"), + Say("delete warning 3"), + )) + }) + }) + + Context("one stream goes to complete, one to polling and the last to error", func() { + BeforeEach(func() { + eventStream1 := make(chan v7action.PollJobEvent) + go func() { + eventStream1 <- v7action.PollJobEvent{ + State: v7action.JobProcessing, + Warnings: v7action.Warnings{"job 1 processing warning"}, + } + eventStream1 <- v7action.PollJobEvent{ + State: v7action.JobComplete, + Warnings: v7action.Warnings{"job 1 complete warning"}, + } + close(eventStream1) + }() + + fakeActor.DeleteServiceAppBindingReturnsOnCall(0, + eventStream1, + v7action.Warnings{"delete 1 warning"}, + nil, + ) + + eventStream2 := make(chan v7action.PollJobEvent) + go func() { + eventStream2 <- v7action.PollJobEvent{ + State: v7action.JobProcessing, + Warnings: v7action.Warnings{"job 2 processing warning"}, + } + eventStream2 <- v7action.PollJobEvent{ + State: v7action.JobPolling, + Warnings: v7action.Warnings{"job 2 polling warning"}, + } + }() + + fakeActor.DeleteServiceAppBindingReturnsOnCall(1, + eventStream2, + v7action.Warnings{"delete 2 warning"}, + nil, + ) + + eventStream3 := make(chan v7action.PollJobEvent) + go func() { + eventStream3 <- v7action.PollJobEvent{ + State: v7action.JobFailed, + Warnings: v7action.Warnings{"job 3 failed warning"}, + Err: errors.New("boom"), + } + }() + + fakeActor.DeleteServiceAppBindingReturnsOnCall(2, + eventStream3, + v7action.Warnings{"delete 3 warning"}, + nil, + ) + + fakeActor.GetServiceInstanceByGUIDReturns( + resources.ServiceInstance{Name: fakeServiceInstanceName2}, + v7action.Warnings{"get service instance warning"}, + nil, + ) + }) + + It("processes all jobs and prints all messages", func() { + Expect(testUI.Out).To(SatisfyAll( + Say(`Deleting service binding %s\.\.\.\n`, fakeBindingGUID1), + Say(`OK\n`), + Say(`Deleting service binding %s\.\.\.\n`, fakeBindingGUID2), + Say(`OK\n`), + Say(`\n`), + Say(`Unbinding in progress. Use 'cf service %s' to check operation status\.\n`, fakeServiceInstanceName2), + )) + + Expect(testUI.Err).To(SatisfyAll( + Say("list warning"), + Say("delete 1 warning"), + Say("job 1 processing warning"), + Say("job 1 complete warning"), + Say("delete 2 warning"), + Say("job 2 processing warning"), + Say("job 2 polling warning"), + Say("delete 3 warning"), + Say("job 3 failed warning"), + )) + + Expect(executeErr).To(MatchError("boom")) + }) + }) + + Context("one stream goes to complete", func() { + BeforeEach(func() { + eventStream1 := make(chan v7action.PollJobEvent) + go func() { + eventStream1 <- v7action.PollJobEvent{ + State: v7action.JobProcessing, + Warnings: v7action.Warnings{"job 1 processing warning"}, + } + eventStream1 <- v7action.PollJobEvent{ + State: v7action.JobPolling, + Warnings: v7action.Warnings{"job 1 polling warning"}, + } + eventStream1 <- v7action.PollJobEvent{ + State: v7action.JobComplete, + Warnings: v7action.Warnings{"job 1 complete warning"}, + } + close(eventStream1) + }() + + fakeActor.ListAppBindingsReturns( + []resources.ServiceCredentialBinding{ + {GUID: fakeBindingGUID1, CreatedAt: fakeTimestamp1, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID1}, + {GUID: fakeBindingGUID3, CreatedAt: fakeTimestamp3, AppName: fakeAppName1, AppGUID: fakeAppGUID1, ServiceInstanceGUID: fakeServiceInstanceGUID1}, + }, + v7action.Warnings{"list warning"}, + nil, + ) + + fakeActor.DeleteServiceAppBindingReturnsOnCall(0, + eventStream1, + v7action.Warnings{"delete 1 warning"}, + nil, + ) + }) + + When("--wait flag specified", func() { + BeforeEach(func() { + setFlag(&cmd, "--wait") + }) + + It("waits for the event stream to complete", func() { + Expect(testUI.Out).To(SatisfyAll( + Say(`Deleting service binding %s\.\.\.\n`, fakeBindingGUID1), + Say(`Waiting for the operation to complete\.+\n`), + Say(`\n`), + Say(`OK\n`), + )) + + Expect(testUI.Err).To(SatisfyAll( + Say("list warning"), + Say("delete 1 warning"), + Say("job 1 processing warning"), + Say("job 1 polling warning"), + Say("job 1 complete warning"), + )) + }) + }) + }) + }) +}) diff --git a/command/v7/v7fakes/fake_actor.go b/command/v7/v7fakes/fake_actor.go index 6b3028d3a41..04356245da0 100644 --- a/command/v7/v7fakes/fake_actor.go +++ b/command/v7/v7fakes/fake_actor.go @@ -2088,6 +2088,21 @@ type FakeActor struct { result2 v7action.Warnings result3 error } + GetServiceInstanceByGUIDStub func(string) (resources.ServiceInstance, v7action.Warnings, error) + getServiceInstanceByGUIDMutex sync.RWMutex + getServiceInstanceByGUIDArgsForCall []struct { + arg1 string + } + getServiceInstanceByGUIDReturns struct { + result1 resources.ServiceInstance + result2 v7action.Warnings + result3 error + } + getServiceInstanceByGUIDReturnsOnCall map[int]struct { + result1 resources.ServiceInstance + result2 v7action.Warnings + result3 error + } GetServiceInstanceByNameAndSpaceStub func(string, string) (resources.ServiceInstance, v7action.Warnings, error) getServiceInstanceByNameAndSpaceMutex sync.RWMutex getServiceInstanceByNameAndSpaceArgsForCall []struct { @@ -2503,6 +2518,21 @@ type FakeActor struct { result1 resources.User result2 error } + ListAppBindingsStub func(v7action.ListAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error) + listAppBindingsMutex sync.RWMutex + listAppBindingsArgsForCall []struct { + arg1 v7action.ListAppBindingParams + } + listAppBindingsReturns struct { + result1 []resources.ServiceCredentialBinding + result2 v7action.Warnings + result3 error + } + listAppBindingsReturnsOnCall map[int]struct { + result1 []resources.ServiceCredentialBinding + result2 v7action.Warnings + result3 error + } ListServiceAppBindingsStub func(v7action.ListServiceAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error) listServiceAppBindingsMutex sync.RWMutex listServiceAppBindingsArgsForCall []struct { @@ -12783,6 +12813,73 @@ func (fake *FakeActor) GetServiceBrokersReturnsOnCall(i int, result1 []resources }{result1, result2, result3} } +func (fake *FakeActor) GetServiceInstanceByGUID(arg1 string) (resources.ServiceInstance, v7action.Warnings, error) { + fake.getServiceInstanceByGUIDMutex.Lock() + ret, specificReturn := fake.getServiceInstanceByGUIDReturnsOnCall[len(fake.getServiceInstanceByGUIDArgsForCall)] + fake.getServiceInstanceByGUIDArgsForCall = append(fake.getServiceInstanceByGUIDArgsForCall, struct { + arg1 string + }{arg1}) + stub := fake.GetServiceInstanceByGUIDStub + fakeReturns := fake.getServiceInstanceByGUIDReturns + fake.recordInvocation("GetServiceInstanceByGUID", []interface{}{arg1}) + fake.getServiceInstanceByGUIDMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeActor) GetServiceInstanceByGUIDCallCount() int { + fake.getServiceInstanceByGUIDMutex.RLock() + defer fake.getServiceInstanceByGUIDMutex.RUnlock() + return len(fake.getServiceInstanceByGUIDArgsForCall) +} + +func (fake *FakeActor) GetServiceInstanceByGUIDCalls(stub func(string) (resources.ServiceInstance, v7action.Warnings, error)) { + fake.getServiceInstanceByGUIDMutex.Lock() + defer fake.getServiceInstanceByGUIDMutex.Unlock() + fake.GetServiceInstanceByGUIDStub = stub +} + +func (fake *FakeActor) GetServiceInstanceByGUIDArgsForCall(i int) string { + fake.getServiceInstanceByGUIDMutex.RLock() + defer fake.getServiceInstanceByGUIDMutex.RUnlock() + argsForCall := fake.getServiceInstanceByGUIDArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeActor) GetServiceInstanceByGUIDReturns(result1 resources.ServiceInstance, result2 v7action.Warnings, result3 error) { + fake.getServiceInstanceByGUIDMutex.Lock() + defer fake.getServiceInstanceByGUIDMutex.Unlock() + fake.GetServiceInstanceByGUIDStub = nil + fake.getServiceInstanceByGUIDReturns = struct { + result1 resources.ServiceInstance + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeActor) GetServiceInstanceByGUIDReturnsOnCall(i int, result1 resources.ServiceInstance, result2 v7action.Warnings, result3 error) { + fake.getServiceInstanceByGUIDMutex.Lock() + defer fake.getServiceInstanceByGUIDMutex.Unlock() + fake.GetServiceInstanceByGUIDStub = nil + if fake.getServiceInstanceByGUIDReturnsOnCall == nil { + fake.getServiceInstanceByGUIDReturnsOnCall = make(map[int]struct { + result1 resources.ServiceInstance + result2 v7action.Warnings + result3 error + }) + } + fake.getServiceInstanceByGUIDReturnsOnCall[i] = struct { + result1 resources.ServiceInstance + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeActor) GetServiceInstanceByNameAndSpace(arg1 string, arg2 string) (resources.ServiceInstance, v7action.Warnings, error) { fake.getServiceInstanceByNameAndSpaceMutex.Lock() ret, specificReturn := fake.getServiceInstanceByNameAndSpaceReturnsOnCall[len(fake.getServiceInstanceByNameAndSpaceArgsForCall)] @@ -14543,6 +14640,73 @@ func (fake *FakeActor) GetUserReturnsOnCall(i int, result1 resources.User, resul }{result1, result2} } +func (fake *FakeActor) ListAppBindings(arg1 v7action.ListAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error) { + fake.listAppBindingsMutex.Lock() + ret, specificReturn := fake.listAppBindingsReturnsOnCall[len(fake.listAppBindingsArgsForCall)] + fake.listAppBindingsArgsForCall = append(fake.listAppBindingsArgsForCall, struct { + arg1 v7action.ListAppBindingParams + }{arg1}) + stub := fake.ListAppBindingsStub + fakeReturns := fake.listAppBindingsReturns + fake.recordInvocation("ListAppBindings", []interface{}{arg1}) + fake.listAppBindingsMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 +} + +func (fake *FakeActor) ListAppBindingsCallCount() int { + fake.listAppBindingsMutex.RLock() + defer fake.listAppBindingsMutex.RUnlock() + return len(fake.listAppBindingsArgsForCall) +} + +func (fake *FakeActor) ListAppBindingsCalls(stub func(v7action.ListAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error)) { + fake.listAppBindingsMutex.Lock() + defer fake.listAppBindingsMutex.Unlock() + fake.ListAppBindingsStub = stub +} + +func (fake *FakeActor) ListAppBindingsArgsForCall(i int) v7action.ListAppBindingParams { + fake.listAppBindingsMutex.RLock() + defer fake.listAppBindingsMutex.RUnlock() + argsForCall := fake.listAppBindingsArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeActor) ListAppBindingsReturns(result1 []resources.ServiceCredentialBinding, result2 v7action.Warnings, result3 error) { + fake.listAppBindingsMutex.Lock() + defer fake.listAppBindingsMutex.Unlock() + fake.ListAppBindingsStub = nil + fake.listAppBindingsReturns = struct { + result1 []resources.ServiceCredentialBinding + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + +func (fake *FakeActor) ListAppBindingsReturnsOnCall(i int, result1 []resources.ServiceCredentialBinding, result2 v7action.Warnings, result3 error) { + fake.listAppBindingsMutex.Lock() + defer fake.listAppBindingsMutex.Unlock() + fake.ListAppBindingsStub = nil + if fake.listAppBindingsReturnsOnCall == nil { + fake.listAppBindingsReturnsOnCall = make(map[int]struct { + result1 []resources.ServiceCredentialBinding + result2 v7action.Warnings + result3 error + }) + } + fake.listAppBindingsReturnsOnCall[i] = struct { + result1 []resources.ServiceCredentialBinding + result2 v7action.Warnings + result3 error + }{result1, result2, result3} +} + func (fake *FakeActor) ListServiceAppBindings(arg1 v7action.ListServiceAppBindingParams) ([]resources.ServiceCredentialBinding, v7action.Warnings, error) { fake.listServiceAppBindingsMutex.Lock() ret, specificReturn := fake.listServiceAppBindingsReturnsOnCall[len(fake.listServiceAppBindingsArgsForCall)] diff --git a/integration/v7/isolated/cleanup_outdated_service_bindings_command_test.go b/integration/v7/isolated/cleanup_outdated_service_bindings_command_test.go new file mode 100644 index 00000000000..49a2558d344 --- /dev/null +++ b/integration/v7/isolated/cleanup_outdated_service_bindings_command_test.go @@ -0,0 +1,371 @@ +package isolated + +import ( + "fmt" + "time" + + "code.cloudfoundry.org/cli/v9/integration/helpers" + "code.cloudfoundry.org/cli/v9/integration/helpers/servicebrokerstub" + "code.cloudfoundry.org/cli/v9/resources" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" + . "github.com/onsi/gomega/gexec" +) + +// Note that this test requires cloud_controller_ng property "cc.max_service_credential_bindings_per_app_service_instance" to be >= 2 +var _ = Describe("cleanup-outdated-service-bindings command", func() { + const command = "cleanup-outdated-service-bindings" + + Describe("help", func() { + matchHelpMessage := SatisfyAll( + Say(`NAME:\n`), + Say(`\s+cleanup-outdated-service-bindings - Cleans up old service bindings for an app, keeping only the most recent binding for each service instance\n`), + Say(`\n`), + Say(`USAGE:\n`), + Say(`\s+cf cleanup-outdated-service-bindings APP_NAME \[--keep-last N\] \[--service-instance SERVICE_INSTANCE_NAME\] \[--force\] \[--wait\]\n`), + Say(`\n`), + Say(`EXAMPLES:\n`), + Say(`\s+cf cleanup-outdated-service-bindings myapp\n`), + Say(`\s+cf cleanup-outdated-service-bindings myapp --keep-last 2 --service-instance myinstance --wait\n`), + Say(`\n`), + Say(`OPTIONS:\n`), + Say(`\s+--force, -f\s+Force deletion without confirmation\n`), + Say(`\s+--keep-last\s+Keep the last N service bindings \(default: 1\)\n`), + Say(`\s+--service-instance\s+Only delete service bindings for the specified service instance\n`), + Say(`\s+--wait, -w\s+Wait for the operation\(s\) to complete\n`), + Say(`\n`), + Say(`SEE ALSO:\n`), + Say(`\s+bind-service, unbind-service\n`), + ) + + When("the -h flag is specified", func() { + It("succeeds and prints help", func() { + session := helpers.CF(command, "-h") + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(matchHelpMessage) + }) + }) + + When("the --help flag is specified", func() { + It("succeeds and prints help", func() { + session := helpers.CF(command, "--help") + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(matchHelpMessage) + }) + }) + + When("no arguments are provided", func() { + It("displays a warning, the help text, and exits 1", func() { + session := helpers.CF(command) + Eventually(session).Should(Exit(1)) + Expect(session.Err).To(Say("Incorrect Usage: the required argument `APP_NAME` was not provided")) + Expect(session.Out).To(matchHelpMessage) + }) + }) + + When("unknown flag is passed", func() { + It("displays a warning, the help text, and exits 1", func() { + session := helpers.CF(command, "-u") + Eventually(session).Should(Exit(1)) + Expect(session.Err).To(Say("Incorrect Usage: unknown flag `u")) + Expect(session.Out).To(matchHelpMessage) + }) + }) + }) + + When("the environment is not setup correctly", func() { + It("fails with the appropriate errors", func() { + helpers.CheckEnvironmentTargetedCorrectly(true, true, ReadOnlyOrg, "cleanup-outdated-service-bindings", "app-name") + }) + }) + + Describe("cleaning up service bindings", func() { + var ( + orgName string + spaceName string + username string + broker *servicebrokerstub.ServiceBrokerStub + input *Buffer + ) + + getBindingCount := func(serviceInstanceName string) int { + var receiver struct { + Resources []resources.ServiceCredentialBinding `json:"resources"` + } + helpers.Curlf(&receiver, "/v3/service_credential_bindings?service_instance_names=%s", serviceInstanceName) + return len(receiver.Resources) + } + + createServiceInstanceWithTwoBindings := func(appName string) (serviceInstanceName, oldServiceBindingGUID string) { + serviceInstanceName = helpers.NewServiceInstanceName() + helpers.CreateManagedServiceInstance(broker.FirstServiceOfferingName(), broker.FirstServicePlanName(), serviceInstanceName) + + Eventually(helpers.CF("bind-service", appName, serviceInstanceName, "--wait")).Should(Exit(0)) + + appGUID := helpers.AppGUID(appName) + + var receiver struct { + Resources []resources.ServiceCredentialBinding `json:"resources"` + } + helpers.Curlf(&receiver, "/v3/service_credential_bindings?app_guids=%s&service_instance_names=%s", appGUID, serviceInstanceName) + Expect(receiver.Resources).To(HaveLen(1)) + + oldServiceBindingGUID = receiver.Resources[0].GUID + + jsonBody := fmt.Sprintf(` +{ + "type": "app", + "relationships": { + "service_instance": { + "data": { + "guid": "%s" + } + }, + "app": { + "data": { + "guid": "%s" + } + } + }, + "strategy": "multiple" +} +`, helpers.ServiceInstanceGUID(serviceInstanceName), appGUID) + helpers.CF("curl", "-d", jsonBody, "-X", "POST", "/v3/service_credential_bindings") + // TODO uncomment and remove previous curl + //Eventually(helpers.CF("bind-service", appName, serviceInstanceName, "--wait", "--strategy=multiple")).Should(Exit(0)) + return + } + + BeforeEach(func() { + orgName = helpers.NewOrgName() + spaceName = helpers.NewSpaceName() + helpers.SetupCF(orgName, spaceName) + username, _ = helpers.GetCredentials() + broker = servicebrokerstub.EnableServiceAccess() + input = NewBuffer() + }) + + AfterEach(func() { + helpers.QuickDeleteOrg(orgName) + broker.Forget() + }) + + Context("one service binding", func() { + var ( + serviceInstanceName string + appName string + ) + + BeforeEach(func() { + serviceInstanceName = helpers.NewServiceInstanceName() + helpers.CreateManagedServiceInstance(broker.FirstServiceOfferingName(), broker.FirstServicePlanName(), serviceInstanceName) + + appName = helpers.NewAppName() + helpers.WithHelloWorldApp(func(appDir string) { + Eventually(helpers.CF("push", appName, "--no-start", "-p", appDir, "-b", "staticfile_buildpack", "--no-route")).Should(Exit(0)) + }) + + Eventually(helpers.CF("bind-service", appName, serviceInstanceName, "--wait")).Should(Exit(0)) + }) + + It("does nothing", func() { + session := helpers.CF(command, appName) + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`No outdated service bindings found\.`), + Say(`OK\n`), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(1)) + }) + }) + + Context("two service bindings", func() { + var ( + serviceInstanceName string + appName string + oldServiceBindingGUID string + ) + + BeforeEach(func() { + appName = helpers.NewAppName() + helpers.WithHelloWorldApp(func(appDir string) { + Eventually(helpers.CF("push", appName, "--no-start", "-p", appDir, "-b", "staticfile_buildpack", "--no-route")).Should(Exit(0)) + }) + + serviceInstanceName, oldServiceBindingGUID = createServiceInstanceWithTwoBindings(appName) + }) + + It("deletes the oldest binding", func() { + _, err := input.Write([]byte("y\n")) + Expect(err).ToNot(HaveOccurred()) + + session := helpers.CFWithStdin(input, command, appName) + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`Found 1 outdated service binding\.`), + Say("Really delete all outdated service bindings?"), + Say(`Deleting service binding %s...`, oldServiceBindingGUID), + Say(`OK\n`), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(1)) + }) + + When("the user inputs 'N' to confirmation", func() { + It("deletes nothing", func() { + _, err := input.Write([]byte("N\n")) + Expect(err).ToNot(HaveOccurred()) + + session := helpers.CFWithStdin(input, command, appName) + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`Found 1 outdated service binding\.`), + Say("Really delete all outdated service bindings?"), + Say("Outdated service bindings have not been deleted."), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(2)) + }) + }) + + When("the --force flag is set", func() { + It("deletes the oldest binding without asking for confirmation", func() { + session := helpers.CFWithStdin(input, command, appName, "--force") + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`Found 1 outdated service binding\.`), + Say(`Deleting service binding %s...`, oldServiceBindingGUID), + Say(`OK\n`), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(1)) + }) + }) + + When("--keep-last 2 flag is specified", func() { + It("does nothing", func() { + session := helpers.CF(command, appName, "--keep-last", "2") + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`No outdated service bindings found.`), + Say(`OK\n`), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(2)) + }) + }) + + Context("asynchronous broker response", func() { + BeforeEach(func() { + broker.WithAsyncDelay(time.Second).Configure() + }) + + It("starts to delete the oldest binding", func() { + _, err := input.Write([]byte("y\n")) + Expect(err).ToNot(HaveOccurred()) + + session := helpers.CFWithStdin(input, command, appName) + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`Found 1 outdated service binding\.`), + Say(`Deleting service binding %s...`, oldServiceBindingGUID), + Say(`OK\n`), + Say(`Unbinding in progress. Use 'cf service %s' to check operation status\.\n`, serviceInstanceName), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(2)) + }) + + When("--wait flag is specified", func() { + It("waits for completion", func() { + _, err := input.Write([]byte("y\n")) + Expect(err).ToNot(HaveOccurred()) + + session := helpers.CFWithStdin(input, command, appName, "--wait") + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`Found 1 outdated service binding\.`), + Say(`Deleting service binding %s...`, oldServiceBindingGUID), + Say(`Waiting for the operation to complete\.+\n`), + Say(`OK\n`), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(1)) + }) + }) + }) + + When("a service instance name is specified", func() { + Context("two service instances with two bindings each", func() { + var ( + serviceInstance2Name string + oldServiceBinding2GUID string + ) + BeforeEach(func() { + serviceInstance2Name, oldServiceBinding2GUID = createServiceInstanceWithTwoBindings(appName) + }) + + It("deletes the oldest binding only of the specified service instance", func() { + _, err := input.Write([]byte("y\n")) + Expect(err).ToNot(HaveOccurred()) + + session := helpers.CFWithStdin(input, command, appName, "--service-instance", serviceInstance2Name) + Eventually(session).Should(Exit(0)) + + Expect(session.Out).To(SatisfyAll( + Say(`Cleaning up outdated service bindings for app %s in org %s / space %s as %s\.\.\.\n`, appName, orgName, spaceName, username), + Say(`Found 1 outdated service binding\.`), + Say(`Deleting service binding %s...`, oldServiceBinding2GUID), + Say(`OK\n`), + )) + + Expect(string(session.Err.Contents())).To(BeEmpty()) + Expect(getBindingCount(serviceInstanceName)).To(Equal(2)) + Expect(getBindingCount(serviceInstance2Name)).To(Equal(1)) + }) + }) + + Context("service instance does not exist", func() { + It("displays FAILED and service not found", func() { + session := helpers.CF(command, appName, "--service-instance", "does-not-exist") + Eventually(session).Should(Exit(1)) + Expect(session.Out).To(Say("FAILED")) + Expect(session.Err).To(Say("Service instance 'does-not-exist' not found")) + }) + }) + }) + }) + + Context("app does not exist", func() { + It("displays FAILED and app not found", func() { + session := helpers.CF(command, "does-not-exist") + Eventually(session).Should(Exit(1)) + Expect(session.Out).To(Say("FAILED")) + Expect(session.Err).To(Say("App 'does-not-exist' not found")) + }) + }) + }) +}) diff --git a/resources/service_credential_binding_resource_test.go b/resources/service_credential_binding_resource_test.go index 0a46c91a700..14392426090 100644 --- a/resources/service_credential_binding_resource_test.go +++ b/resources/service_credential_binding_resource_test.go @@ -26,6 +26,7 @@ var _ = Describe("service credential binding resource", func() { Entry("empty", ServiceCredentialBinding{}, `{}`), Entry("type", ServiceCredentialBinding{Type: "fake-type"}, `{"type": "fake-type"}`), Entry("name", ServiceCredentialBinding{Name: "fake-name"}, `{"name": "fake-name"}`), + Entry("created_at", ServiceCredentialBinding{CreatedAt: "fake-created-at"}, `{"created_at": "fake-created-at"}`), Entry("guid", ServiceCredentialBinding{GUID: "fake-guid"}, `{"guid": "fake-guid"}`), Entry("service instance guid guid", ServiceCredentialBinding{ServiceInstanceGUID: "fake-instance-guid"}, @@ -76,18 +77,19 @@ var _ = Describe("service credential binding resource", func() { Type: AppBinding, GUID: "fake-guid", Name: "fake-name", + CreatedAt: "fake-created-at", AppGUID: "fake-app-guid", ServiceInstanceGUID: "fake-service-instance-guid", Parameters: types.NewOptionalObject(map[string]interface{}{ "foo": "bar", }), - Strategy: MultipleBindingStrategy, - CreatedAt: "fake-created-at", + Strategy: MultipleBindingStrategy, }, `{ "type": "app", "guid": "fake-guid", "name": "fake-name", + "created_at": "fake-created-at", "relationships": { "service_instance": { "data": {