From 571c0f24d6e72fb730ec41b703c81a7354bd769b Mon Sep 17 00:00:00 2001 From: d-g-town <66391417+d-g-town@users.noreply.github.com> Date: Fri, 8 Mar 2024 09:54:01 -0500 Subject: [PATCH] only block app creations for apps that have a revision (#4392) --- .../handlers/porter_app/app_instances.go | 104 ++++++++++++++++++ api/server/router/porter_app.go | 29 +++++ .../src/lib/hooks/useLatestAppRevisions.ts | 51 +++++++++ .../src/main/home/app-dashboard/apps/types.ts | 15 ++- .../app-dashboard/create-app/CreateApp.tsx | 49 ++++----- dashboard/src/shared/api.tsx | 13 +++ internal/porter_app/revisions.go | 10 ++ 7 files changed, 244 insertions(+), 27 deletions(-) create mode 100644 api/server/handlers/porter_app/app_instances.go diff --git a/api/server/handlers/porter_app/app_instances.go b/api/server/handlers/porter_app/app_instances.go new file mode 100644 index 0000000000..fdbadcc4c6 --- /dev/null +++ b/api/server/handlers/porter_app/app_instances.go @@ -0,0 +1,104 @@ +package porter_app + +import ( + "net/http" + + "connectrpc.com/connect" + porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1" + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/apierrors" + "github.com/porter-dev/porter/api/server/shared/config" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/porter_app" + "github.com/porter-dev/porter/internal/telemetry" +) + +// AppInstancesHandler is the handler for the /apps/instances endpoint +type AppInstancesHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewAppInstancesHandler handles GET requests to the /apps/instances endpoint +func NewAppInstancesHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *AppInstancesHandler { + return &AppInstancesHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// AppInstancesRequest is the request object for the /apps/instances endpoint +type AppInstancesRequest struct { + DeploymentTargetID string `schema:"deployment_target_id"` +} + +// AppInstancesResponse is the response object for the /apps/instances endpoint +type AppInstancesResponse struct { + AppInstances []porter_app.AppInstance `json:"app_instances"` +} + +// ServeHTTP translates the request into a ListAppInstancesRequest to the cluster control plane +func (c *AppInstancesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-app-instances") + defer span.End() + + project, _ := r.Context().Value(types.ProjectScope).(*models.Project) + cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster) + + request := &AppInstancesRequest{} + if ok := c.DecodeAndValidate(w, r, request); !ok { + err := telemetry.Error(ctx, span, nil, "error decoding request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "project-id", Value: project.ID}, + telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID}, + telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID}, + ) + + var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier + if request.DeploymentTargetID != "" { + deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{ + Id: request.DeploymentTargetID, + } + } + + listAppInstancesReq := connect.NewRequest(&porterv1.ListAppInstancesRequest{ + ProjectId: int64(project.ID), + DeploymentTargetIdentifier: deploymentTargetIdentifier, + }) + + latestAppInstancesResp, err := c.Config().ClusterControlPlaneClient.ListAppInstances(ctx, listAppInstancesReq) + if err != nil { + err = telemetry.Error(ctx, span, err, "error getting latest app revisions") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + if latestAppInstancesResp == nil || latestAppInstancesResp.Msg == nil { + err = telemetry.Error(ctx, span, nil, "latest app revisions response is nil") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + var appInstances []porter_app.AppInstance + + for _, instance := range latestAppInstancesResp.Msg.AppInstances { + appInstances = append(appInstances, porter_app.AppInstance{ + Id: instance.Id, + DeploymentTarget: porter_app.DeploymentTarget{ + ID: instance.DeploymentTargetId, + Name: "", + }, + Name: instance.Name, + }) + } + + c.WriteResult(w, r, AppInstancesResponse{AppInstances: appInstances}) +} diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go index 8e98ebb61e..c693c48ba7 100644 --- a/api/server/router/porter_app.go +++ b/api/server/router/porter_app.go @@ -1036,6 +1036,35 @@ func getPorterAppRoutes( Router: r, }) + // GET /api/projects/{project_id}/clusters/{cluster_id}/apps/instances -> porter_app.NewAppInstancesHandler + latestAppInstancesEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: fmt.Sprintf("%s/instances", relPathV2), + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + types.ClusterScope, + }, + }, + ) + + latestAppInstancesHandler := porter_app.NewAppInstancesHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: latestAppInstancesEndpoint, + Handler: latestAppInstancesHandler, + Router: r, + }) + // POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/subdomain -> porter_app.NewCreateSubdomainHandler createSubdomainEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/dashboard/src/lib/hooks/useLatestAppRevisions.ts b/dashboard/src/lib/hooks/useLatestAppRevisions.ts index 3e04892637..62f70b58d7 100644 --- a/dashboard/src/lib/hooks/useLatestAppRevisions.ts +++ b/dashboard/src/lib/hooks/useLatestAppRevisions.ts @@ -2,7 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; import { + appInstanceValidator, appRevisionWithSourceValidator, + type AppInstance, type AppRevisionWithSource, } from "main/home/app-dashboard/apps/types"; @@ -57,3 +59,52 @@ export const useLatestAppRevisions = ({ revisions: apps, }; }; + +// use this hook to get the latest revision of every app in the project/cluster +export const useAppInstances = ({ + projectId, + clusterId, +}: { + projectId: number; + clusterId: number; +}): { + instances: AppInstance[]; +} => { + const { data: appInstances = [] } = useQuery( + [ + "getAppInstances", + { + cluster_id: clusterId, + project_id: projectId, + }, + ], + async () => { + if (clusterId === -1 || projectId === -1) { + return; + } + + const res = await api.getAppInstances( + "", + { + deployment_target_id: undefined, + }, + { cluster_id: clusterId, project_id: projectId } + ); + + const apps = await z + .object({ + app_instances: z.array(appInstanceValidator), + }) + .parseAsync(res.data); + + return apps.app_instances; + }, + { + refetchOnWindowFocus: false, + enabled: clusterId !== 0 && projectId !== 0, + } + ); + return { + instances: appInstances, + }; +}; diff --git a/dashboard/src/main/home/app-dashboard/apps/types.ts b/dashboard/src/main/home/app-dashboard/apps/types.ts index 7723f59f96..ea103a7ee2 100644 --- a/dashboard/src/main/home/app-dashboard/apps/types.ts +++ b/dashboard/src/main/home/app-dashboard/apps/types.ts @@ -1,5 +1,7 @@ -import { appRevisionValidator } from "lib/revisions/types"; import { z } from "zod"; + +import { appRevisionValidator } from "lib/revisions/types"; + import { porterAppValidator } from "../app-view/AppView"; export const appRevisionWithSourceValidator = z.object({ @@ -10,3 +12,14 @@ export const appRevisionWithSourceValidator = z.object({ export type AppRevisionWithSource = z.infer< typeof appRevisionWithSourceValidator >; + +export const appInstanceValidator = z.object({ + id: z.string(), + name: z.string(), + deployment_target: z.object({ + id: z.string().optional(), + name: z.string().optional(), + }), +}); + +export type AppInstance = z.infer; diff --git a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx index b3e8bbb11e..473ed1dc24 100644 --- a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx +++ b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx @@ -42,6 +42,10 @@ import { Context } from "shared/Context"; import { valueExists } from "shared/util"; import applicationGrad from "assets/application-grad.svg"; +import { + useAppInstances, + useLatestAppRevisions, +} from "../../../../lib/hooks/useLatestAppRevisions"; import ImageSettings from "../image-settings/ImageSettings"; import GithubActionModal from "../new-app-flow/GithubActionModal"; import SourceSelector from "../new-app-flow/SourceSelector"; @@ -82,34 +86,27 @@ const CreateApp: React.FC = ({ history }) => { secrets: {}, }); - const { data: porterApps = [] } = useQuery( - ["getPorterApps", currentProject?.id, currentCluster?.id], - async () => { - if (!currentProject?.id || !currentCluster?.id) { - return await Promise.resolve([]); - } + const { revisions: appsWithRevisions } = useLatestAppRevisions({ + projectId: currentProject?.id ?? 0, + clusterId: currentCluster?.id ?? 0, + }); - const res = await api.getPorterApps( - "", - {}, - { - project_id: currentProject?.id, - cluster_id: currentCluster?.id, - } - ); + const { instances: appInstances } = useAppInstances({ + projectId: currentProject?.id ?? 0, + clusterId: currentCluster?.id ?? 0, + }); - const apps = await z - .object({ - name: z.string(), - }) - .array() - .parseAsync(res.data); - return apps.map((app) => app.name); - }, - { - enabled: !!currentProject?.id && !!currentCluster?.id, - } - ); + const porterApps = useMemo((): string[] => { + return appsWithRevisions.reduce(function (result: string[], app) { + const instances = appInstances.filter( + (instance) => instance.id === app.app_revision.app_instance_id + ); + if (instances.length > 0) { + return result.concat(instances[0].name); + } + return result; + }, []); + }, [appsWithRevisions, appInstances]); const { data: baseEnvGroups = [] } = useQuery( ["getAllEnvGroups", currentProject?.id, currentCluster?.id], diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 8a5fbb79c9..1eadad26c9 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -1229,6 +1229,18 @@ const getLatestAppRevisions = baseApi< return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`; }); +const getAppInstances = baseApi< + { + deployment_target_id: string | undefined; + }, + { + project_id: number; + cluster_id: number; + } +>("GET", ({ project_id, cluster_id }) => { + return `/api/projects/${project_id}/clusters/${cluster_id}/apps/instances`; +}); + const listDeploymentTargets = baseApi< { preview: boolean; @@ -3604,6 +3616,7 @@ export default { getRevision, listAppRevisions, getLatestAppRevisions, + getAppInstances, listDeploymentTargets, createDeploymentTarget, getDeploymentTarget, diff --git a/internal/porter_app/revisions.go b/internal/porter_app/revisions.go index 226633c258..30129a1e17 100644 --- a/internal/porter_app/revisions.go +++ b/internal/porter_app/revisions.go @@ -41,6 +41,16 @@ type Revision struct { AppInstanceID uuid.UUID `json:"app_instance_id"` } +// AppInstance represents the data for an app instance +type AppInstance struct { + // Id is the app instance id + Id string `json:"id"` + // DeploymentTargetID is the id of the deployment target the revision is associated with + DeploymentTarget DeploymentTarget `json:"deployment_target"` + // Name is the name of the app instance + Name string `json:"name"` +} + // RevisionProgress describes the progress of a revision in its lifecycle type RevisionProgress struct { // PredeployStarted is true if the predeploy process has started