瀏覽代碼

only block app creations for apps that have a revision (#4392)

d-g-town 2 年之前
父節點
當前提交
571c0f24d6

+ 104 - 0
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})
+}

+ 29 - 0
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{

+ 51 - 0
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(
+        "<token>",
+        {
+          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,
+  };
+};

+ 14 - 1
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<typeof appInstanceValidator>;

+ 23 - 26
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<CreateAppProps> = ({ history }) => {
     secrets: {},
   });
 
-  const { data: porterApps = [] } = useQuery<string[]>(
-    ["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(
-        "<token>",
-        {},
-        {
-          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],

+ 13 - 0
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,

+ 10 - 0
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