diff --git a/api/handlers/app.go b/api/handlers/app.go index a1d2e9dfa..fb7b9ad47 100644 --- a/api/handlers/app.go +++ b/api/handlers/app.go @@ -24,6 +24,7 @@ const ( AppsPath = "/v3/apps" AppPath = "/v3/apps/{guid}" AppCurrentDropletRelationshipPath = "/v3/apps/{guid}/relationships/current_droplet" + AppDropletsPath = "/v3/apps/{guid}/droplets" AppCurrentDropletPath = "/v3/apps/{guid}/droplets/current" AppProcessesPath = "/v3/apps/{guid}/processes" AppProcessByTypePath = "/v3/apps/{guid}/processes/{type}" @@ -217,6 +218,24 @@ func (h *App) setCurrentDroplet(r *http.Request) (*routing.Response, error) { return routing.NewResponse(http.StatusOK).WithBody(presenter.ForCurrentDroplet(currentDroplet, h.serverURL)), nil } +func (h *App) getDroplets(r *http.Request) (*routing.Response, error) { + authInfo, _ := authorization.InfoFromContext(r.Context()) + logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-droplets") + appGUID := routing.URLParam(r, "guid") + + app, err := h.appRepo.GetApp(r.Context(), authInfo, appGUID) + if err != nil { + return nil, apierrors.LogAndReturn(logger, apierrors.ForbiddenAsNotFound(err), "Failed to fetch app from Kubernetes", "AppGUID", appGUID) + } + + droplets, err := h.dropletRepo.GetDroplets(r.Context(), authInfo, appGUID) + if err != nil { + return nil, apierrors.LogAndReturn(logger, apierrors.DropletForbiddenAsNotFound(err), "Failed to fetch droplet from Kubernetes", "dropletGUID", app.DropletGUID) + } + + return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForDroplet, droplets, h.serverURL, *r.URL)), nil +} + func (h *App) getCurrentDroplet(r *http.Request) (*routing.Response, error) { authInfo, _ := authorization.InfoFromContext(r.Context()) logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-current-droplet") @@ -707,6 +726,7 @@ func (h *App) AuthenticatedRoutes() []routing.Route { {Method: "GET", Pattern: AppsPath, Handler: h.list}, {Method: "POST", Pattern: AppsPath, Handler: h.create}, {Method: "PATCH", Pattern: AppCurrentDropletRelationshipPath, Handler: h.setCurrentDroplet}, + {Method: "GET", Pattern: AppDropletsPath, Handler: h.getDroplets}, {Method: "GET", Pattern: AppCurrentDropletPath, Handler: h.getCurrentDroplet}, {Method: "POST", Pattern: AppStartPath, Handler: h.start}, {Method: "POST", Pattern: AppStopPath, Handler: h.stop}, diff --git a/api/handlers/app_test.go b/api/handlers/app_test.go index 40915d193..a003fee17 100644 --- a/api/handlers/app_test.go +++ b/api/handlers/app_test.go @@ -1306,6 +1306,47 @@ var _ = Describe("App", func() { }) }) + Describe("GET /v3/apps/:guid/droplets", func() { + BeforeEach(func() { + dropletRepo.GetDropletsReturns([]repositories.DropletRecord{{ + GUID: dropletGUID, + State: "STAGED", + AppGUID: appGUID, + }}, nil) + + req = createHttpRequest("GET", "/v3/apps/"+appGUID+"/droplets", nil) + }) + + It("returns the list of droplets", func() { + Expect(dropletRepo.GetDropletsCallCount()).To(Equal(1)) + _, actualAuthInfo, actualAppGUID := dropletRepo.GetDropletsArgsForCall(0) + Expect(actualAuthInfo).To(Equal(authInfo)) + Expect(actualAppGUID).To(Equal(appGUID)) + + Expect(rr).To(HaveHTTPStatus(http.StatusOK)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.pagination.total_results", BeEquivalentTo(1)), + MatchJSONPath("$.pagination.first.href", "https://api.example.org/v3/apps/"+appGUID+"/droplets"), + MatchJSONPath("$.resources", HaveLen(1)), + MatchJSONPath("$.resources[0].guid", Equal(dropletGUID)), + MatchJSONPath("$.resources[0].relationships.app.data.guid", Equal(appGUID)), + MatchJSONPath("$.resources[0].state", Equal("STAGED")), + ))) + }) + + When("the app is not accessible", func() { + BeforeEach(func() { + toReturnErr := apierrors.NewForbiddenError(nil, repositories.AppResourceType) + appRepo.GetAppReturns(repositories.AppRecord{}, toReturnErr) + }) + + It("returns an error", func() { + expectNotFoundError("App") + }) + }) + }) + Describe("GET /v3/apps/:guid/actions/restart", func() { BeforeEach(func() { updatedAppRecord := appRecord diff --git a/api/handlers/droplet.go b/api/handlers/droplet.go index 89d5559a6..374c5ae40 100644 --- a/api/handlers/droplet.go +++ b/api/handlers/droplet.go @@ -23,6 +23,7 @@ const ( //counterfeiter:generate -o fake -fake-name CFDropletRepository . CFDropletRepository type CFDropletRepository interface { GetDroplet(context.Context, authorization.Info, string) (repositories.DropletRecord, error) + GetDroplets(context.Context, authorization.Info, string) ([]repositories.DropletRecord, error) ListDroplets(context.Context, authorization.Info, repositories.ListDropletsMessage) ([]repositories.DropletRecord, error) UpdateDroplet(context.Context, authorization.Info, repositories.UpdateDropletMessage) (repositories.DropletRecord, error) } diff --git a/api/handlers/fake/cfdroplet_repository.go b/api/handlers/fake/cfdroplet_repository.go index 97ac05191..452e38bcc 100644 --- a/api/handlers/fake/cfdroplet_repository.go +++ b/api/handlers/fake/cfdroplet_repository.go @@ -26,6 +26,21 @@ type CFDropletRepository struct { result1 repositories.DropletRecord result2 error } + GetDropletsStub func(context.Context, authorization.Info, string) ([]repositories.DropletRecord, error) + getDropletsMutex sync.RWMutex + getDropletsArgsForCall []struct { + arg1 context.Context + arg2 authorization.Info + arg3 string + } + getDropletsReturns struct { + result1 []repositories.DropletRecord + result2 error + } + getDropletsReturnsOnCall map[int]struct { + result1 []repositories.DropletRecord + result2 error + } ListDropletsStub func(context.Context, authorization.Info, repositories.ListDropletsMessage) ([]repositories.DropletRecord, error) listDropletsMutex sync.RWMutex listDropletsArgsForCall []struct { @@ -126,6 +141,72 @@ func (fake *CFDropletRepository) GetDropletReturnsOnCall(i int, result1 reposito }{result1, result2} } +func (fake *CFDropletRepository) GetDroplets(arg1 context.Context, arg2 authorization.Info, arg3 string) ([]repositories.DropletRecord, error) { + fake.getDropletsMutex.Lock() + ret, specificReturn := fake.getDropletsReturnsOnCall[len(fake.getDropletsArgsForCall)] + fake.getDropletsArgsForCall = append(fake.getDropletsArgsForCall, struct { + arg1 context.Context + arg2 authorization.Info + arg3 string + }{arg1, arg2, arg3}) + stub := fake.GetDropletsStub + fakeReturns := fake.getDropletsReturns + fake.recordInvocation("GetDroplets", []interface{}{arg1, arg2, arg3}) + fake.getDropletsMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *CFDropletRepository) GetDropletsCallCount() int { + fake.getDropletsMutex.RLock() + defer fake.getDropletsMutex.RUnlock() + return len(fake.getDropletsArgsForCall) +} + +func (fake *CFDropletRepository) GetDropletsCalls(stub func(context.Context, authorization.Info, string) ([]repositories.DropletRecord, error)) { + fake.getDropletsMutex.Lock() + defer fake.getDropletsMutex.Unlock() + fake.GetDropletsStub = stub +} + +func (fake *CFDropletRepository) GetDropletsArgsForCall(i int) (context.Context, authorization.Info, string) { + fake.getDropletsMutex.RLock() + defer fake.getDropletsMutex.RUnlock() + argsForCall := fake.getDropletsArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *CFDropletRepository) GetDropletsReturns(result1 []repositories.DropletRecord, result2 error) { + fake.getDropletsMutex.Lock() + defer fake.getDropletsMutex.Unlock() + fake.GetDropletsStub = nil + fake.getDropletsReturns = struct { + result1 []repositories.DropletRecord + result2 error + }{result1, result2} +} + +func (fake *CFDropletRepository) GetDropletsReturnsOnCall(i int, result1 []repositories.DropletRecord, result2 error) { + fake.getDropletsMutex.Lock() + defer fake.getDropletsMutex.Unlock() + fake.GetDropletsStub = nil + if fake.getDropletsReturnsOnCall == nil { + fake.getDropletsReturnsOnCall = make(map[int]struct { + result1 []repositories.DropletRecord + result2 error + }) + } + fake.getDropletsReturnsOnCall[i] = struct { + result1 []repositories.DropletRecord + result2 error + }{result1, result2} +} + func (fake *CFDropletRepository) ListDroplets(arg1 context.Context, arg2 authorization.Info, arg3 repositories.ListDropletsMessage) ([]repositories.DropletRecord, error) { fake.listDropletsMutex.Lock() ret, specificReturn := fake.listDropletsReturnsOnCall[len(fake.listDropletsArgsForCall)] @@ -263,6 +344,8 @@ func (fake *CFDropletRepository) Invocations() map[string][][]interface{} { defer fake.invocationsMutex.RUnlock() fake.getDropletMutex.RLock() defer fake.getDropletMutex.RUnlock() + fake.getDropletsMutex.RLock() + defer fake.getDropletsMutex.RUnlock() fake.listDropletsMutex.RLock() defer fake.listDropletsMutex.RUnlock() fake.updateDropletMutex.RLock() diff --git a/api/repositories/droplet_repository.go b/api/repositories/droplet_repository.go index 599b105ff..54004a872 100644 --- a/api/repositories/droplet_repository.go +++ b/api/repositories/droplet_repository.go @@ -87,6 +87,40 @@ func (r *DropletRepo) GetDroplet(ctx context.Context, authInfo authorization.Inf return cfBuildToDroplet(build) } +func (r *DropletRepo) GetDroplets(ctx context.Context, authInfo authorization.Info, appGuid string) ([]DropletRecord, error) { + buildList := &korifiv1alpha1.CFBuildList{} + + namespaces, err := r.namespacePermissions.GetAuthorizedSpaceNamespaces(ctx, authInfo) + if err != nil { + return nil, fmt.Errorf("failed to list namespaces for spaces with user role bindings: %w", err) + } + + userClient, err := r.userClientFactory.BuildClient(authInfo) + if err != nil { + return []DropletRecord{}, fmt.Errorf("failed to build user client: %w", err) + } + + var allBuilds []korifiv1alpha1.CFBuild + for ns := range namespaces { + err := userClient.List(ctx, buildList, client.InNamespace(ns)) + if k8serrors.IsForbidden(err) { + continue + } + if err != nil { + return []DropletRecord{}, apierrors.FromK8sError(err, BuildResourceType) + } + allBuilds = append(allBuilds, buildList.Items...) + } + + var filteredDropletRecords []DropletRecord + for _, build := range allBuilds { + if build.Spec.AppRef.Name == appGuid { + filteredDropletRecords = append(filteredDropletRecords, cfBuildToDropletRecord(build)) + } + } + return filteredDropletRecords, nil +} + func (r *DropletRepo) getBuildAssociatedWithDroplet(ctx context.Context, authInfo authorization.Info, dropletGUID string) (*korifiv1alpha1.CFBuild, client.WithWatch, error) { // A droplet is a subset of a build ns, err := r.namespaceRetriever.NamespaceFor(ctx, dropletGUID, DropletResourceType) diff --git a/api/repositories/droplet_repository_test.go b/api/repositories/droplet_repository_test.go index c0b312e7f..f5891c537 100644 --- a/api/repositories/droplet_repository_test.go +++ b/api/repositories/droplet_repository_test.go @@ -313,6 +313,88 @@ var _ = Describe("DropletRepository", func() { }) }) + Describe("GetDroplets", func() { + var ( + dropletRecords []repositories.DropletRecord + // appGUID string + listErr error + ) + + BeforeEach(func() { + meta.SetStatusCondition(&build.Status.Conditions, metav1.Condition{ + Type: "Staging", + Status: metav1.ConditionFalse, + Reason: "kpack", + Message: "kpack", + }) + meta.SetStatusCondition(&build.Status.Conditions, metav1.Condition{ + Type: "Succeeded", + Status: metav1.ConditionTrue, + Reason: "Unknown", + Message: "Unknown", + }) + build.Status.Droplet = &korifiv1alpha1.BuildDropletStatus{ + Stack: dropletStack, + Registry: korifiv1alpha1.Registry{ + Image: registryImage, + ImagePullSecrets: []corev1.LocalObjectReference{ + { + Name: registryImageSecret, + }, + }, + }, + ProcessTypes: []korifiv1alpha1.ProcessType{ + { + Type: "rake", + Command: "bundle exec rake", + }, + { + Type: "web", + Command: "bundle exec rackup config.ru -p $PORT", + }, + }, + Ports: []int32{8080, 443}, + } + // Update Build Status based on changes made to local copy + Expect(k8sClient.Status().Update(testCtx, build)).To(Succeed()) + }) + + JustBeforeEach(func() { + dropletRecords, listErr = dropletRepo.GetDroplets(testCtx, authInfo, appGUID) + }) + + When("the user is not authorized to get the droplets", func() { + It("returns an empty list to users who lack access", func() { + Expect(listErr).NotTo(HaveOccurred()) + Expect(dropletRecords).To(BeEmpty()) + }) + }) + + When("the user is a space manager", func() { + BeforeEach(func() { + createRoleBinding(testCtx, userName, spaceDeveloperRole.Name, space.Name) + }) + + It("returns a list of droplet records with the appGUID relationship set on them", func() { + Expect(listErr).NotTo(HaveOccurred()) + Expect(dropletRecords).To(HaveLen(1)) + Expect(dropletRecords[0].Relationships()).To(Equal(map[string]string{"app": build.Spec.AppRef.Name})) + }) + + When("a space exists with a rolebinding for the user, but without permission to list droplets", func() { + BeforeEach(func() { + anotherSpace := createSpaceWithCleanup(testCtx, org.Name, "space-without-droplet-space-perm") + createRoleBinding(testCtx, userName, rootNamespaceUserRole.Name, anotherSpace.Name) + }) + + It("returns the droplet", func() { + Expect(listErr).NotTo(HaveOccurred()) + Expect(dropletRecords).To(HaveLen(1)) + }) + }) + }) + }) + Describe("ListDroplets", func() { var ( dropletRecords []repositories.DropletRecord diff --git a/tests/e2e/apps_test.go b/tests/e2e/apps_test.go index e77b63805..cf0bc7b8b 100644 --- a/tests/e2e/apps_test.go +++ b/tests/e2e/apps_test.go @@ -404,6 +404,26 @@ var _ = Describe("Apps", func() { }) }) + Describe("Get app droplets", func() { + BeforeEach(func() { + setCurrentDroplet(appGUID, buildGUID) + }) + + var resultList resourceList[resource] + + JustBeforeEach(func() { + var err error + resp, err = adminClient.R().SetResult(&resultList).Get("/v3/apps/" + appGUID + "/droplets") + Expect(err).NotTo(HaveOccurred()) + }) + + It("succeeds", func() { + Expect(resp).To(HaveRestyStatusCode(http.StatusOK)) + Expect(resultList.Resources[0].GUID).To(Equal(buildGUID)) + + }) + }) + Describe("Start an app", func() { var result appResource