cloudfoundry/cf-k8s-controllers

View on GitHub
api/handlers/app.go

Summary

Maintainability
B
7 hrs
Test Coverage
package handlers

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "net/url"
    "sort"
    "time"

    "code.cloudfoundry.org/korifi/api/authorization"
    apierrors "code.cloudfoundry.org/korifi/api/errors"
    "code.cloudfoundry.org/korifi/api/payloads"
    "code.cloudfoundry.org/korifi/api/presenter"
    "code.cloudfoundry.org/korifi/api/repositories"
    "code.cloudfoundry.org/korifi/api/routing"
    korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1"

    "github.com/go-logr/logr"
)

const (
    AppsPath                          = "/v3/apps"
    AppPath                           = "/v3/apps/{guid}"
    AppCurrentDropletRelationshipPath = "/v3/apps/{guid}/relationships/current_droplet"
    AppCurrentDropletPath             = "/v3/apps/{guid}/droplets/current"
    AppProcessesPath                  = "/v3/apps/{guid}/processes"
    AppProcessByTypePath              = "/v3/apps/{guid}/processes/{type}"
    AppProcessScalePath               = "/v3/apps/{guid}/processes/{processType}/actions/scale"
    AppRoutesPath                     = "/v3/apps/{guid}/routes"
    AppStartPath                      = "/v3/apps/{guid}/actions/start"
    AppStopPath                       = "/v3/apps/{guid}/actions/stop"
    AppRestartPath                    = "/v3/apps/{guid}/actions/restart"
    AppEnvVarsPath                    = "/v3/apps/{guid}/environment_variables"
    AppEnvPath                        = "/v3/apps/{guid}/env"
    AppFeaturePath                    = "/v3/apps/{guid}/features/{name}"
    AppPackagesPath                   = "/v3/apps/{guid}/packages"
    AppSSHEnabledPath                 = "/v3/apps/{guid}/ssh_enabled"
    invalidDropletMsg                 = "Unable to assign current droplet. Ensure the droplet exists and belongs to this app."

    AppStartedState = "STARTED"
    AppStoppedState = "STOPPED"
    AppRevisionKey  = "korifi.cloudfoundry.org/app-rev"
)

//counterfeiter:generate -o fake -fake-name CFAppRepository . CFAppRepository
type CFAppRepository interface {
    GetApp(context.Context, authorization.Info, string) (repositories.AppRecord, error)
    ListApps(context.Context, authorization.Info, repositories.ListAppsMessage) ([]repositories.AppRecord, error)
    PatchAppEnvVars(context.Context, authorization.Info, repositories.PatchAppEnvVarsMessage) (repositories.AppEnvVarsRecord, error)
    CreateApp(context.Context, authorization.Info, repositories.CreateAppMessage) (repositories.AppRecord, error)
    SetCurrentDroplet(context.Context, authorization.Info, repositories.SetCurrentDropletMessage) (repositories.CurrentDropletRecord, error)
    SetAppDesiredState(context.Context, authorization.Info, repositories.SetAppDesiredStateMessage) (repositories.AppRecord, error)
    DeleteApp(context.Context, authorization.Info, repositories.DeleteAppMessage) error
    GetAppEnv(context.Context, authorization.Info, string) (repositories.AppEnvRecord, error)
    PatchApp(context.Context, authorization.Info, repositories.PatchAppMessage) (repositories.AppRecord, error)
}

type App struct {
    serverURL        url.URL
    appRepo          CFAppRepository
    dropletRepo      CFDropletRepository
    processRepo      CFProcessRepository
    routeRepo        CFRouteRepository
    domainRepo       CFDomainRepository
    spaceRepo        CFSpaceRepository
    packageRepo      CFPackageRepository
    requestValidator RequestValidator
}

func NewApp(
    serverURL url.URL,
    appRepo CFAppRepository,
    dropletRepo CFDropletRepository,
    processRepo CFProcessRepository,
    routeRepo CFRouteRepository,
    domainRepo CFDomainRepository,
    spaceRepo CFSpaceRepository,
    packageRepo CFPackageRepository,
    requestValidator RequestValidator,
) *App {
    return &App{
        serverURL:        serverURL,
        appRepo:          appRepo,
        dropletRepo:      dropletRepo,
        processRepo:      processRepo,
        routeRepo:        routeRepo,
        domainRepo:       domainRepo,
        spaceRepo:        spaceRepo,
        packageRepo:      packageRepo,
        requestValidator: requestValidator,
    }
}

func (h *App) get(r *http.Request) (*routing.Response, error) {
    appGUID := routing.URLParam(r, "guid")
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get")

    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", "GUID", appGUID)
    }
    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForApp(app, h.serverURL)), nil
}

//nolint:dupl
func (h *App) create(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.create")
    var payload payloads.AppCreate
    if err := h.requestValidator.DecodeAndValidateJSONPayload(r, &payload); err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "failed to decode json payload")
    }

    spaceGUID := payload.Relationships.Space.Data.GUID
    _, err := h.spaceRepo.GetSpace(r.Context(), authInfo, spaceGUID)
    if err != nil {
        return nil, apierrors.LogAndReturn(
            logger,
            apierrors.AsUnprocessableEntity(err, "Invalid space. Ensure that the space exists and you have access to it.", apierrors.NotFoundError{}, apierrors.ForbiddenError{}),
            "spaceGUID", spaceGUID,
        )
    }

    appRecord, err := h.appRepo.CreateApp(r.Context(), authInfo, payload.ToAppCreateMessage())
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to create app", "App Name", payload.Name)
    }

    err = h.processRepo.CreateProcess(r.Context(), authInfo, repositories.CreateProcessMessage{
        AppGUID:   appRecord.GUID,
        SpaceGUID: spaceGUID,
        Type:      korifiv1alpha1.ProcessTypeWeb,
    })
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to create web process", "App Name", payload.Name)
    }

    return routing.NewResponse(http.StatusCreated).WithBody(presenter.ForApp(appRecord, h.serverURL)), nil
}

func (h *App) list(r *http.Request) (*routing.Response, error) { //nolint:dupl
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.list")

    appListFilter := new(payloads.AppList)
    err := h.requestValidator.DecodeAndValidateURLValues(r, appListFilter)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Unable to decode request query parameters")
    }

    appList, err := h.appRepo.ListApps(r.Context(), authInfo, appListFilter.ToMessage())
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch app(s) from Kubernetes")
    }

    h.sortList(appList, appListFilter.OrderBy)

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForApp, appList, h.serverURL, *r.URL)), nil
}

func timePtrAfter(t1, t2 *time.Time) bool {
    if t1 == nil || t2 == nil {
        return false
    }

    return (*t1).After(*t2)
}

func (h *App) sortList(appList []repositories.AppRecord, order string) {
    switch order {
    case "":
    case "created_at":
        sort.Slice(appList, func(i, j int) bool { return timePtrAfter(&appList[j].CreatedAt, &appList[i].CreatedAt) })
    case "-created_at":
        sort.Slice(appList, func(i, j int) bool { return timePtrAfter(&appList[i].CreatedAt, &appList[j].CreatedAt) })
    case "updated_at":
        sort.Slice(appList, func(i, j int) bool { return timePtrAfter(appList[j].UpdatedAt, appList[i].UpdatedAt) })
    case "-updated_at":
        sort.Slice(appList, func(i, j int) bool { return timePtrAfter(appList[i].UpdatedAt, appList[j].UpdatedAt) })
    case "name":
        sort.Slice(appList, func(i, j int) bool { return appList[i].Name < appList[j].Name })
    case "-name":
        sort.Slice(appList, func(i, j int) bool { return appList[i].Name > appList[j].Name })
    case "state":
        sort.Slice(appList, func(i, j int) bool { return appList[i].State < appList[j].State })
    case "-state":
        sort.Slice(appList, func(i, j int) bool { return appList[i].State > appList[j].State })
    }
}

func (h *App) setCurrentDroplet(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.set-current-droplet")
    appGUID := routing.URLParam(r, "guid")

    var payload payloads.AppSetCurrentDroplet
    if err := h.requestValidator.DecodeAndValidateJSONPayload(r, &payload); err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "failed to decode json payload")
    }

    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)
    }

    dropletGUID := payload.Data.GUID
    droplet, err := h.dropletRepo.GetDroplet(r.Context(), authInfo, dropletGUID)
    if err != nil {
        return nil, apierrors.LogAndReturn(
            logger,
            apierrors.AsUnprocessableEntity(err, invalidDropletMsg, apierrors.ForbiddenError{}, apierrors.NotFoundError{}),
            "Error fetching droplet",
        )
    }

    if droplet.AppGUID != appGUID {
        return nil, apierrors.LogAndReturn(
            logger,
            apierrors.NewUnprocessableEntityError(fmt.Errorf("droplet %s does not belong to app %s", droplet.GUID, appGUID), invalidDropletMsg),
            invalidDropletMsg,
        )
    }

    currentDroplet, err := h.appRepo.SetCurrentDroplet(r.Context(), authInfo, repositories.SetCurrentDropletMessage{
        AppGUID:     appGUID,
        DropletGUID: dropletGUID,
        SpaceGUID:   app.SpaceGUID,
    })
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Error setting current droplet")
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForCurrentDroplet(currentDroplet, h.serverURL)), 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")
    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)
    }

    if app.DropletGUID == "" {
        return nil, apierrors.LogAndReturn(
            logger,
            apierrors.DropletForbiddenAsNotFound(apierrors.NewNotFoundError(nil, repositories.DropletResourceType)),
            "App does not have a current droplet assigned",
            "appGUID", app.GUID,
        )
    }

    droplet, err := h.dropletRepo.GetDroplet(r.Context(), authInfo, app.DropletGUID)
    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.ForDroplet(droplet, h.serverURL)), nil
}

func (h *App) start(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.start")
    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)
    }

    app, err = h.startApp(r.Context(), authInfo, app)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to start app", "AppGUID", appGUID)
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForApp(app, h.serverURL)), nil
}

func (h *App) startApp(ctx context.Context, authInfo authorization.Info, app repositories.AppRecord) (repositories.AppRecord, error) {
    if app.DropletGUID == "" {
        return repositories.AppRecord{}, apierrors.NewUnprocessableEntityError(errors.New("app droplet not set"), "Assign a droplet before starting this app.")
    }

    app, err := h.appRepo.SetAppDesiredState(ctx, authInfo, repositories.SetAppDesiredStateMessage{
        AppGUID:      app.GUID,
        SpaceGUID:    app.SpaceGUID,
        DesiredState: AppStartedState,
    })
    if err != nil {
        return repositories.AppRecord{}, fmt.Errorf("failed to update app desired state: %w", err)
    }

    return app, nil
}

func (h *App) stop(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.stop")
    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)
    }

    app, err = h.stopApp(r.Context(), authInfo, app)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to stop app", "AppGUID", appGUID)
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForApp(app, h.serverURL)), nil
}

func (h *App) stopApp(ctx context.Context, authInfo authorization.Info, app repositories.AppRecord) (repositories.AppRecord, error) {
    app, err := h.appRepo.SetAppDesiredState(ctx, authInfo, repositories.SetAppDesiredStateMessage{
        AppGUID:      app.GUID,
        SpaceGUID:    app.SpaceGUID,
        DesiredState: AppStoppedState,
    })
    if err != nil {
        return repositories.AppRecord{}, fmt.Errorf("failed to update app desired state: %w", err)
    }

    return app, nil
}

func (h *App) getProcesses(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-processes")
    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)
    }

    fetchProcessesForAppMessage := repositories.ListProcessesMessage{
        AppGUIDs:  []string{appGUID},
        SpaceGUID: app.SpaceGUID,
    }

    processList, err := h.processRepo.ListProcesses(r.Context(), authInfo, fetchProcessesForAppMessage)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch app Process(es) from Kubernetes")
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForProcessList(processList, h.serverURL, *r.URL)), nil
}

func (h *App) getRoutes(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-routes")
    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)
    }

    routes, err := h.lookupAppRouteAndDomainList(r.Context(), authInfo, app.GUID, app.SpaceGUID)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch route or domains from Kubernetes")
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForRoute, routes, h.serverURL, *r.URL)), nil
}

func (h *App) scaleProcess(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    appGUID := routing.URLParam(r, "guid")
    processType := routing.URLParam(r, "processType")
    logger := logr.FromContextOrDiscard(r.Context()).
        WithName("handlers.app.scale-process").
        WithValues("appGUID", appGUID, "processType", processType)

    var payload payloads.ProcessScale
    if err := h.requestValidator.DecodeAndValidateJSONPayload(r, &payload); err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "failed to decode json payload")
    }

    app, err := h.appRepo.GetApp(r.Context(), authInfo, appGUID)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, apierrors.ForbiddenAsNotFound(err), "falied to get app")
    }

    appProcesses, err := h.processRepo.ListProcesses(r.Context(), authInfo, repositories.ListProcessesMessage{
        AppGUIDs:  []string{app.GUID},
        SpaceGUID: app.SpaceGUID,
    })
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "failed to list processes for app")
    }

    process, hasProcessType := findProcessType(appProcesses, processType)
    if !hasProcessType {
        return nil, apierrors.LogAndReturn(logger,
            apierrors.NewNotFoundError(nil, repositories.ProcessResourceType),
            "app does not have required process type",
        )
    }

    scaledProcessRecord, err := h.processRepo.ScaleProcess(r.Context(), authInfo, repositories.ScaleProcessMessage{
        GUID:               process.GUID,
        SpaceGUID:          app.SpaceGUID,
        ProcessScaleValues: payload.ToRecord(),
    })
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed due to error from Kubernetes", "appGUID", appGUID)
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForProcess(scaledProcessRecord, h.serverURL)), nil
}

func findProcessType(processes []repositories.ProcessRecord, processType string) (repositories.ProcessRecord, bool) {
    for _, proc := range processes {
        if proc.Type == processType {
            return proc, true
        }
    }

    return repositories.ProcessRecord{}, false
}

func (h *App) restart(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.restart")
    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)
    }

    app, err = h.stopApp(r.Context(), authInfo, app)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to stop app", "AppGUID", appGUID)
    }

    app, err = h.startApp(r.Context(), authInfo, app)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to start app", "AppGUID", appGUID)
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForApp(app, h.serverURL)), nil
}

func (h *App) delete(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.delete")
    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)
    }

    err = h.appRepo.DeleteApp(r.Context(), authInfo, repositories.DeleteAppMessage{
        AppGUID:   appGUID,
        SpaceGUID: app.SpaceGUID,
    })
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to delete app", "AppGUID", appGUID)
    }

    return routing.NewResponse(http.StatusAccepted).WithHeader("Location", presenter.JobURLForRedirects(appGUID, presenter.AppDeleteOperation, h.serverURL)), nil
}

func (h *App) lookupAppRouteAndDomainList(ctx context.Context, authInfo authorization.Info, appGUID, spaceGUID string) ([]repositories.RouteRecord, error) {
    routeRecords, err := h.routeRepo.ListRoutesForApp(ctx, authInfo, appGUID, spaceGUID)
    if err != nil {
        return []repositories.RouteRecord{}, err
    }

    return getDomainsForRoutes(ctx, h.domainRepo, authInfo, routeRecords)
}

func getDomainsForRoutes(ctx context.Context, domainRepo CFDomainRepository, authInfo authorization.Info, routeRecords []repositories.RouteRecord) ([]repositories.RouteRecord, error) {
    domainGUIDToDomainRecord := make(map[string]repositories.DomainRecord)
    for i, routeRecord := range routeRecords {
        currentDomainGUID := routeRecord.Domain.GUID
        domainRecord, has := domainGUIDToDomainRecord[currentDomainGUID]
        if !has {
            var err error
            domainRecord, err = domainRepo.GetDomain(ctx, authInfo, currentDomainGUID)
            if err != nil {
                // err = errors.New("resource not found for route's specified domain ref")
                return []repositories.RouteRecord{}, err
            }
            domainGUIDToDomainRecord[currentDomainGUID] = domainRecord
        }
        routeRecords[i].Domain = domainRecord
    }

    return routeRecords, nil
}

func (h *App) updateEnvVars(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.update-env-vars")
    appGUID := routing.URLParam(r, "guid")

    var payload payloads.AppPatchEnvVars
    if err := h.requestValidator.DecodeAndValidateJSONPayload(r, &payload); err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "failed to decode payload")
    }

    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)
    }

    envVarsRecord, err := h.appRepo.PatchAppEnvVars(r.Context(), authInfo, payload.ToMessage(appGUID, app.SpaceGUID))
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Error updating app environment variables")
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForAppEnvVars(envVarsRecord, h.serverURL)), nil
}

func (h *App) getEnvironment(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-environment")
    appGUID := routing.URLParam(r, "guid")

    appEnvRecord, err := h.appRepo.GetAppEnv(r.Context(), authInfo, appGUID)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch app environment variables", "AppGUID", appGUID)
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForAppEnv(appEnvRecord)), nil
}

func (h *App) getProcess(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.get-process")
    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)
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForProcess(process, h.serverURL)), 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")
    appGUID := routing.URLParam(r, "guid")

    _, 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)
    }

    fetchPackagesForAppMessage := repositories.ListPackagesMessage{
        AppGUIDs: []string{appGUID},
        States:   []string{},
    }

    packageList, err := h.packageRepo.ListPackages(r.Context(), authInfo, fetchPackagesForAppMessage)
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch app Package(s) from Kubernetes")
    }

    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForPackage, packageList, h.serverURL, *r.URL)), nil
}

//nolint:dupl
func (h *App) update(r *http.Request) (*routing.Response, error) {
    authInfo, _ := authorization.InfoFromContext(r.Context())
    logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.app.update")
    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)
    }

    var payload payloads.AppPatch
    if err = h.requestValidator.DecodeAndValidateJSONPayload(r, &payload); err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "failed to decode payload")
    }

    app, err = h.appRepo.PatchApp(r.Context(), authInfo, payload.ToMessage(appGUID, app.SpaceGUID))
    if err != nil {
        return nil, apierrors.LogAndReturn(logger, err, "Failed to patch app", "AppGUID", appGUID)
    }
    return routing.NewResponse(http.StatusOK).WithBody(presenter.ForApp(app, h.serverURL)), nil
}

func (h *App) getSSHEnabled(r *http.Request) (*routing.Response, error) {
    return routing.NewResponse(http.StatusOK).WithBody(presenter.AppSSHEnabled{
        Enabled: false,
        Reason:  "Disabled globally",
    }), nil
}

func (h *App) getAppFeature(r *http.Request) (*routing.Response, error) {
    featureName := routing.URLParam(r, "name")
    switch featureName {
    case "ssh":
        return routing.NewResponse(http.StatusOK).WithBody(map[string]any{
            "name":        "ssh",
            "description": "Enable SSHing into the app.",
            "enabled":     false,
        }), nil
    case "revisions":
        return routing.NewResponse(http.StatusOK).WithBody(map[string]any{
            "name":        "revisions",
            "description": "Enable versioning of an application",
            "enabled":     false,
        }), nil
    default:
        return nil, apierrors.NewNotFoundError(nil, "Feature")
    }
}

func (h *App) UnauthenticatedRoutes() []routing.Route {
    return nil
}

func (h *App) AuthenticatedRoutes() []routing.Route {
    return []routing.Route{
        {Method: "GET", Pattern: AppPath, Handler: h.get},
        {Method: "GET", Pattern: AppsPath, Handler: h.list},
        {Method: "POST", Pattern: AppsPath, Handler: h.create},
        {Method: "PATCH", Pattern: AppCurrentDropletRelationshipPath, Handler: h.setCurrentDroplet},
        {Method: "GET", Pattern: AppCurrentDropletPath, Handler: h.getCurrentDroplet},
        {Method: "POST", Pattern: AppStartPath, Handler: h.start},
        {Method: "POST", Pattern: AppStopPath, Handler: h.stop},
        {Method: "POST", Pattern: AppRestartPath, Handler: h.restart},
        {Method: "POST", Pattern: AppProcessScalePath, Handler: h.scaleProcess},
        {Method: "GET", Pattern: AppProcessesPath, Handler: h.getProcesses},
        {Method: "GET", Pattern: AppProcessByTypePath, Handler: h.getProcess},
        {Method: "GET", Pattern: AppRoutesPath, Handler: h.getRoutes},
        {Method: "DELETE", Pattern: AppPath, Handler: h.delete},
        {Method: "PATCH", Pattern: AppEnvVarsPath, Handler: h.updateEnvVars},
        {Method: "GET", Pattern: AppEnvPath, Handler: h.getEnvironment},
        {Method: "GET", Pattern: AppPackagesPath, Handler: h.getPackages},
        {Method: "GET", Pattern: AppFeaturePath, Handler: h.getAppFeature},
        {Method: "PATCH", Pattern: AppPath, Handler: h.update},
        {Method: "GET", Pattern: AppSSHEnabledPath, Handler: h.getSSHEnabled},
    }
}