Skip to content

Commit

Permalink
Adding v3/apps/guid/droplets endpoint; Fixes cloudfoundry#3552
Browse files Browse the repository at this point in the history
  • Loading branch information
benjaminguttmann-avtq committed Nov 12, 2024
1 parent ea1c01a commit 7842582
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 14 deletions.
20 changes: 20 additions & 0 deletions api/handlers/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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) listDroplets(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.ListDroplets(r.Context(), authInfo, repositories.ListDropletsMessage{AppGUIDs: []string{appGUID}})
if err != nil {
return nil, apierrors.LogAndReturn(logger, apierrors.ForbiddenAsNotFound(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")
Expand Down Expand Up @@ -725,6 +744,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.listDroplets},
{Method: "GET", Pattern: AppCurrentDropletPath, Handler: h.getCurrentDroplet},
{Method: "POST", Pattern: AppStartPath, Handler: h.start},
{Method: "POST", Pattern: AppStopPath, Handler: h.stop},
Expand Down
85 changes: 85 additions & 0 deletions api/handlers/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,91 @@ var _ = Describe("App", func() {
})
})

Describe("GET /v3/apps/:guid/droplets", func() {
BeforeEach(func() {
dropletRepo.ListDropletsReturns([]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.ListDropletsCallCount()).To(Equal(1))
_, actualAuthInfo, dropletListMessage := dropletRepo.ListDropletsArgsForCall(0)
Expect(actualAuthInfo).To(Equal(authInfo))

Expect(dropletListMessage).To(Equal(repositories.ListDropletsMessage{
AppGUIDs: []string{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() {
appRepo.GetAppReturns(
repositories.AppRecord{},
apierrors.NewForbiddenError(nil, repositories.AppResourceType),
)
})

It("returns an error", func() {
expectNotFoundError("App")
})
})

When("there is some other error fetching the app", func() {
BeforeEach(func() {
appRepo.GetAppReturns(
repositories.AppRecord{},
errors.New("unknown!"),
)
})

It("returns an error", func() {
expectUnknownError()
})
})

When("the droplet is not accessible", func() {
BeforeEach(func() {
dropletRepo.ListDropletsReturns(
[]repositories.DropletRecord{},
apierrors.NewForbiddenError(nil, repositories.DropletResourceType),
)
})

It("returns an error", func() {
expectNotFoundError("Droplet")
})
})

When("there is some other error fetching the droplet", func() {
BeforeEach(func() {
dropletRepo.ListDropletsReturns(
[]repositories.DropletRecord{},
errors.New("unknown!"),
)
})

It("returns an error", func() {
expectUnknownError()
})
})
})

Describe("GET /v3/apps/:guid/actions/restart", func() {
BeforeEach(func() {
updatedAppRecord := appRecord
Expand Down
20 changes: 12 additions & 8 deletions api/repositories/droplet_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"slices"
"time"

"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"
Expand All @@ -16,7 +15,6 @@ import (
korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1"

k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
Expand Down Expand Up @@ -70,12 +68,18 @@ func (r DropletRecord) Relationships() map[string]string {

type ListDropletsMessage struct {
PackageGUIDs []string
AppGUIDs []string
}

func (m *ListDropletsMessage) matches(b korifiv1alpha1.CFBuild) bool {
return tools.EmptyOrContains(m.PackageGUIDs, b.Spec.PackageRef.Name) &&
meta.IsStatusConditionFalse(b.Status.Conditions, StagingConditionType) &&
meta.IsStatusConditionTrue(b.Status.Conditions, SucceededConditionType)
func (m *ListDropletsMessage) createSelector() map[string]string {
newSelector := make(map[string]string)
if len(m.PackageGUIDs) > 0 {
newSelector[korifiv1alpha1.CFPackageGUIDLabelKey] = m.PackageGUIDs[0]
}
if len(m.AppGUIDs) > 0 {
newSelector[korifiv1alpha1.CFAppGUIDLabelKey] = m.AppGUIDs[0]
}
return newSelector
}

func (r *DropletRepo) GetDroplet(ctx context.Context, authInfo authorization.Info, dropletGUID string) (DropletRecord, error) {
Expand Down Expand Up @@ -168,7 +172,7 @@ func (r *DropletRepo) ListDroplets(ctx context.Context, authInfo authorization.I

var allBuilds []korifiv1alpha1.CFBuild
for ns := range namespaces {
err := userClient.List(ctx, buildList, client.InNamespace(ns))
err := userClient.List(ctx, buildList, client.InNamespace(ns), client.MatchingLabels(message.createSelector()))
if k8serrors.IsForbidden(err) {
continue
}
Expand All @@ -178,7 +182,7 @@ func (r *DropletRepo) ListDroplets(ctx context.Context, authInfo authorization.I
allBuilds = append(allBuilds, buildList.Items...)
}

filteredBuilds := itx.FromSlice(allBuilds).Filter(message.matches)
filteredBuilds := itx.FromSlice(allBuilds)
return slices.Collect(it.Map(filteredBuilds, cfBuildToDropletRecord)), nil
}

Expand Down
53 changes: 47 additions & 6 deletions api/repositories/droplet_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ var _ = Describe("DropletRepository", func() {
Name: buildGUID,
Namespace: space.Name,
Labels: map[string]string{
"key1": "val1",
"key2": "val2",
"key1": "val1",
"key2": "val2",
korifiv1alpha1.CFPackageGUIDLabelKey: packageGUID,
korifiv1alpha1.CFAppGUIDLabelKey: appGUID,
},
Annotations: map[string]string{
"key1": "val1",
Expand Down Expand Up @@ -156,8 +158,10 @@ var _ = Describe("DropletRepository", func() {
Expect(dropletRecord.AppGUID).To(Equal(build.Spec.AppRef.Name))
Expect(dropletRecord.PackageGUID).To(Equal(build.Spec.PackageRef.Name))
Expect(dropletRecord.Labels).To(Equal(map[string]string{
"key1": "val1",
"key2": "val2",
"key1": "val1",
"key2": "val2",
korifiv1alpha1.CFPackageGUIDLabelKey: packageGUID,
korifiv1alpha1.CFAppGUIDLabelKey: appGUID,
}))
Expect(dropletRecord.Annotations).To(Equal(map[string]string{
"key1": "val1",
Expand Down Expand Up @@ -394,6 +398,41 @@ var _ = Describe("DropletRepository", func() {
})
})
})

JustBeforeEach(func() {
dropletRecords, listErr = dropletRepo.ListDroplets(testCtx, authInfo, repositories.ListDropletsMessage{
AppGUIDs: []string{appGUID},
})
})

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 label set on them", func() {
Expect(listErr).NotTo(HaveOccurred())
Expect(dropletRecords).To(HaveLen(1))
Expect(dropletRecords[0].Relationships()).To(Equal(map[string]string{"app": appGUID}))
})

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("UpdateDroplet", func() {
Expand Down Expand Up @@ -526,8 +565,10 @@ var _ = Describe("DropletRepository", func() {

By("returns a record with a Label field matching the CR", func() {
Expect(dropletRecord.Labels).To(Equal(map[string]string{
"key1": "val1edit",
"key3": "val3",
"key1": "val1edit",
"key3": "val3",
korifiv1alpha1.CFPackageGUIDLabelKey: packageGUID,
korifiv1alpha1.CFAppGUIDLabelKey: appGUID,
}))
})

Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit 7842582

Please sign in to comment.