Skip to content

Commit

Permalink
only block app creations for apps that have a revision (#4392)
Browse files Browse the repository at this point in the history
  • Loading branch information
d-g-town authored Mar 8, 2024
1 parent 828b1e4 commit 571c0f2
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 27 deletions.
104 changes: 104 additions & 0 deletions api/server/handlers/porter_app/app_instances.go
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions api/server/router/porter_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
51 changes: 51 additions & 0 deletions dashboard/src/lib/hooks/useLatestAppRevisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
};
};
15 changes: 14 additions & 1 deletion dashboard/src/main/home/app-dashboard/apps/types.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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>;
49 changes: 23 additions & 26 deletions dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
Expand Down
13 changes: 13 additions & 0 deletions dashboard/src/shared/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3604,6 +3616,7 @@ export default {
getRevision,
listAppRevisions,
getLatestAppRevisions,
getAppInstances,
listDeploymentTargets,
createDeploymentTarget,
getDeploymentTarget,
Expand Down
10 changes: 10 additions & 0 deletions internal/porter_app/revisions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 571c0f2

Please sign in to comment.