diff --git a/api/handlers/app.go b/api/handlers/app.go index 1f048c4c1..5f0537860 100644 --- a/api/handlers/app.go +++ b/api/handlers/app.go @@ -27,6 +27,7 @@ const ( AppCurrentDropletPath = "/v3/apps/{guid}/droplets/current" AppProcessesPath = "/v3/apps/{guid}/processes" AppProcessByTypePath = "/v3/apps/{guid}/processes/{type}" + AppProcessStatsByTypePath = "/v3/apps/{guid}/processes/{type}/stats" AppProcessScalePath = "/v3/apps/{guid}/processes/{processType}/actions/scale" AppRoutesPath = "/v3/apps/{guid}/routes" AppStartPath = "/v3/apps/{guid}/actions/start" @@ -62,6 +63,7 @@ type App struct { appRepo CFAppRepository dropletRepo CFDropletRepository processRepo CFProcessRepository + processStats ProcessStats routeRepo CFRouteRepository domainRepo CFDomainRepository spaceRepo CFSpaceRepository @@ -74,6 +76,7 @@ func NewApp( appRepo CFAppRepository, dropletRepo CFDropletRepository, processRepo CFProcessRepository, + processStatsFetcher ProcessStats, routeRepo CFRouteRepository, domainRepo CFDomainRepository, spaceRepo CFSpaceRepository, @@ -85,6 +88,7 @@ func NewApp( appRepo: appRepo, dropletRepo: dropletRepo, processRepo: processRepo, + processStats: processStatsFetcher, routeRepo: routeRepo, domainRepo: domainRepo, spaceRepo: spaceRepo, @@ -554,6 +558,32 @@ func (h *App) getProcess(r *http.Request) (*routing.Response, error) { return routing.NewResponse(http.StatusOK).WithBody(presenter.ForProcess(process, h.serverURL)), nil } +func (h *App) getProcessStats(r *http.Request) (*routing.Response, error) { + authInfo, _ := authorization.InfoFromContext(r.Context()) + logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-process-stats") + appGUID := routing.URLParam(r, "guid") + processType := routing.URLParam(r, "type") + + 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) + } + + process, err := h.processRepo.GetProcessByAppTypeAndSpace(r.Context(), authInfo, appGUID, processType, app.SpaceGUID) + if err != nil { + return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch process from Kubernetes", "AppGUID", appGUID) + } + + processGUID := process.GUID + + records, err := h.processStats.FetchStats(r.Context(), authInfo, processGUID) + if err != nil { + return nil, apierrors.LogAndReturn(logger, apierrors.ForbiddenAsNotFound(err), "Failed to get process stats from Kubernetes", "ProcessGUID", processGUID) + } + + return routing.NewResponse(http.StatusOK).WithBody(presenter.ForProcessStats(records)), nil +} + func (h *App) getPackages(r *http.Request) (*routing.Response, error) { authInfo, _ := authorization.InfoFromContext(r.Context()) logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-packages") @@ -644,6 +674,7 @@ func (h *App) AuthenticatedRoutes() []routing.Route { {Method: "POST", Pattern: AppProcessScalePath, Handler: h.scaleProcess}, {Method: "GET", Pattern: AppProcessesPath, Handler: h.getProcesses}, {Method: "GET", Pattern: AppProcessByTypePath, Handler: h.getProcess}, + {Method: "GET", Pattern: AppProcessStatsByTypePath, Handler: h.getProcessStats}, {Method: "GET", Pattern: AppRoutesPath, Handler: h.getRoutes}, {Method: "DELETE", Pattern: AppPath, Handler: h.delete}, {Method: "PATCH", Pattern: AppEnvVarsPath, Handler: h.updateEnvVars}, diff --git a/api/handlers/app_test.go b/api/handlers/app_test.go index 1ab8669e2..ca75de64c 100644 --- a/api/handlers/app_test.go +++ b/api/handlers/app_test.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "code.cloudfoundry.org/korifi/api/actions" apierrors "code.cloudfoundry.org/korifi/api/errors" . "code.cloudfoundry.org/korifi/api/handlers" "code.cloudfoundry.org/korifi/api/handlers/fake" @@ -33,6 +34,7 @@ var _ = Describe("App", func() { appRepo *fake.CFAppRepository dropletRepo *fake.CFDropletRepository processRepo *fake.CFProcessRepository + processStats *fake.ProcessStats routeRepo *fake.CFRouteRepository domainRepo *fake.CFDomainRepository spaceRepo *fake.CFSpaceRepository @@ -47,6 +49,7 @@ var _ = Describe("App", func() { appRepo = new(fake.CFAppRepository) dropletRepo = new(fake.CFDropletRepository) processRepo = new(fake.CFProcessRepository) + processStats = new(fake.ProcessStats) routeRepo = new(fake.CFRouteRepository) domainRepo = new(fake.CFDomainRepository) spaceRepo = new(fake.CFSpaceRepository) @@ -58,6 +61,7 @@ var _ = Describe("App", func() { appRepo, dropletRepo, processRepo, + processStats, routeRepo, domainRepo, spaceRepo, @@ -987,6 +991,72 @@ var _ = Describe("App", func() { }) }) + Describe("GET /v3/apps/:guid/processes/{type}/stats", func() { + BeforeEach(func() { + processStats.FetchStatsReturns([]actions.PodStatsRecord{ + { + Type: "web", + Index: 0, + MemQuota: tools.PtrTo(int64(1024)), + }, + { + Type: "web", + Index: 1, + MemQuota: tools.PtrTo(int64(512)), + }, + }, nil) + + req = createHttpRequest("GET", "/v3/apps/"+appGUID+"/processes/web/stats", nil) + }) + + It("returns the process stats", func() { + Expect(processStats.FetchStatsCallCount()).To(Equal(1)) + _, actualAuthInfo, _ := processStats.FetchStatsArgsForCall(0) + Expect(actualAuthInfo).To(Equal(authInfo)) + + Expect(rr).To(HaveHTTPStatus(http.StatusOK)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.resources", HaveLen(2)), + MatchJSONPath("$.resources[0].type", "web"), + MatchJSONPath("$.resources[0].index", BeEquivalentTo(0)), + MatchJSONPath("$.resources[1].type", "web"), + MatchJSONPath("$.resources[1].mem_quota", BeEquivalentTo(512)), + ))) + }) + + When("getting the app fails", func() { + BeforeEach(func() { + appRepo.GetAppReturns(repositories.AppRecord{}, errors.New("get-app")) + }) + + It("returns an error", func() { + expectUnknownError() + }) + }) + + When("there is an error fetching the process", func() { + BeforeEach(func() { + processRepo.GetProcessByAppTypeAndSpaceReturns(repositories.ProcessRecord{}, errors.New("some-error")) + }) + + It("return a process unknown error", func() { + expectUnknownError() + }) + }) + + When("fetching the process stats errors", func() { + BeforeEach(func() { + processStats.FetchStatsReturns(nil, errors.New("boom")) + }) + + It("returns an error", func() { + expectUnknownError() + }) + }) + }) + Describe("the POST /v3/apps/:guid/process/:processType/actions/scale endpoint", func() { var payload *payloads.ProcessScale diff --git a/api/main.go b/api/main.go index c97ba2388..3fdefc2a4 100644 --- a/api/main.go +++ b/api/main.go @@ -274,6 +274,7 @@ func main() { appRepo, dropletRepo, processRepo, + processStats, routeRepo, domainRepo, spaceRepo, diff --git a/tests/e2e/apps_test.go b/tests/e2e/apps_test.go index 2f3d918f0..92910a542 100644 --- a/tests/e2e/apps_test.go +++ b/tests/e2e/apps_test.go @@ -245,6 +245,40 @@ var _ = Describe("Apps", func() { }) }) + Describe("Get app process stats by type", func() { + var processStats resourceList[statsResource] + + BeforeEach(func() { + appGUID, _ = pushTestApp(space1GUID, defaultAppBitsFile) + }) + + JustBeforeEach(func() { + Eventually(func(g Gomega) { + var err error + resp, err = adminClient.R(). + SetResult(&processStats). + Get("/v3/apps/" + appGUID + "/processes/web/stats") + g.Expect(err).NotTo(HaveOccurred()) + + // no 'g.' here - we require all calls to return 200 + Expect(resp).To(HaveRestyStatusCode(http.StatusOK)) + g.Expect(processStats.Resources).ToNot(BeEmpty()) + g.Expect(processStats.Resources[0].Usage).ToNot(BeZero()) + }).Should(Succeed()) + }) + + It("succeeds", func() { + Expect(resp).To(HaveRestyStatusCode(http.StatusOK)) + Expect(processStats.Resources).To(HaveLen(1)) + + Expect(processStats.Resources[0].Usage).To(MatchFields(IgnoreExtras, Fields{ + "Mem": Not(BeNil()), + "CPU": Not(BeNil()), + "Time": Not(BeNil()), + })) + }) + }) + Describe("List app packages", func() { var ( result resourceList[typedResource]