Prechádzať zdrojové kódy

submit form and run validate + apply (#3416)

ianedwards 2 rokov pred
rodič
commit
3c883c7c5d

+ 1 - 1
api/server/handlers/porter_app/create_app.go

@@ -50,7 +50,7 @@ type Image struct {
 // CreateAppRequest is the request object for the /apps/create endpoint
 type CreateAppRequest struct {
 	Name           string     `json:"name"`
-	SourceType     SourceType `json:"source_type"`
+	SourceType     SourceType `json:"type"`
 	GitBranch      string     `json:"git_branch"`
 	GitRepoName    string     `json:"git_repo_name"`
 	GitRepoID      uint       `json:"git_repo_id"`

+ 36 - 0
dashboard/src/lib/hooks/useAppAnalytics.ts

@@ -0,0 +1,36 @@
+import { useContext } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+type AppStep =
+  | "stack-launch-complete"
+  | "stack-launch-success"
+  | "stack-launch-failure";
+
+export const useAppAnalytics = (appName: string) => {
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const updateAppStep = async (step: AppStep, errorMessage: string = "") => {
+    try {
+      if (!currentCluster?.id || !currentProject?.id) {
+        return;
+      }
+      await api.updateStackStep(
+        "<token>",
+        {
+          step,
+          stack_name: appName,
+          error_message: errorMessage,
+        },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      );
+    } catch (err) {}
+  };
+
+  return {
+    updateAppStep,
+  };
+};

+ 48 - 0
dashboard/src/lib/hooks/useDeploymentTarget.ts

@@ -0,0 +1,48 @@
+import { useQuery } from "@tanstack/react-query";
+import { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { z } from "zod";
+
+const deploymentTargetValidator = z.object({
+  deployment_target_id: z.string(),
+});
+type DeploymentTarget = z.infer<typeof deploymentTargetValidator>;
+
+export function useDefaultDeploymentTarget() {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [
+    deploymentTarget,
+    setDeploymentTarget,
+  ] = useState<DeploymentTarget | null>(null);
+
+  const { data } = useQuery(
+    ["getDefaultDeploymentTarget", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject?.id || !currentCluster?.id) {
+        return;
+      }
+      const res = await api.getDefaultDeploymentTarget(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      return deploymentTargetValidator.parseAsync(res.data);
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+    }
+  );
+
+  useEffect(() => {
+    if (data) {
+      setDeploymentTarget(data);
+    }
+  }, [data]);
+
+  return deploymentTarget;
+}

+ 42 - 29
dashboard/src/lib/porter-apps/index.ts

@@ -11,6 +11,7 @@ import {
   serviceValidator,
 } from "./services";
 import { PorterApp, Service } from "@porter-dev/api-contracts";
+import { match } from "ts-pattern";
 
 // buildValidator is used to validate inputs for build setting fields
 export const buildValidator = z.object({
@@ -33,11 +34,14 @@ export const sourceValidator = z.discriminatedUnion("type", [
   }),
   z.object({
     type: z.literal("docker-registry"),
-    image_repo_uri: z.string(),
     // add branch and repo as undefined to allow for easy checks on changes to the source type
     // (i.e. we want to remove the services if any source fields change)
     git_branch: z.undefined(),
     git_repo_name: z.undefined(),
+    image: z.object({
+      repository: z.string(),
+      tag: z.string().default("latest"),
+    }),
   }),
 ]);
 export type SourceOptions = z.infer<typeof sourceValidator>;
@@ -48,12 +52,6 @@ export const porterAppValidator = z.object({
   services: serviceValidator.array(),
   env: z.record(z.string(), z.string()),
   build: buildValidator,
-  image: z
-    .object({
-      repository: z.string(),
-      tag: z.string(),
-    })
-    .optional(),
 });
 export type ClientPorterApp = z.infer<typeof porterAppValidator>;
 
@@ -106,7 +104,9 @@ export function defaultServicesWithOverrides({
   };
 }
 
-export function clientAppToProto(app: ClientPorterApp): PorterApp {
+export function clientAppToProto(data: PorterAppFormData): PorterApp {
+  const { app, source } = data;
+
   const services = app.services
     .filter((s) => !isPredeployService(s))
     .reduce((acc: Record<string, Service>, svc) => {
@@ -116,27 +116,40 @@ export function clientAppToProto(app: ClientPorterApp): PorterApp {
 
   const predeploy = app.services.find((s) => isPredeployService(s));
 
-  const proto = new PorterApp({
-    name: app.name,
-    services,
-    env: app.env,
-    build: {
-      context: app.build.context,
-      method: app.build.method,
-      buildpacks: app.build.buildpacks.map((b) => b.buildpack),
-      builder: app.build.builder,
-      dockerfile: app.build.dockerfile,
-    },
-    ...(app.image && {
-      image: {
-        repository: app.image.repository,
-        tag: app.image.tag,
-      },
-    }),
-    ...(predeploy && {
-      predeploy: serviceProto(serializeService(predeploy)),
-    }),
-  });
+  const proto = match(source)
+    .with(
+      { type: "github" },
+      () =>
+        new PorterApp({
+          name: app.name,
+          services,
+          env: app.env,
+          build: {
+            context: app.build.context,
+            method: app.build.method,
+            buildpacks: app.build.buildpacks.map((b) => b.buildpack),
+            builder: app.build.builder,
+            dockerfile: app.build.dockerfile,
+          },
+          ...(predeploy && {
+            predeploy: serviceProto(serializeService(predeploy)),
+          }),
+        })
+    )
+    .with(
+      { type: "docker-registry" },
+      (src) =>
+        new PorterApp({
+          name: app.name,
+          services,
+          env: app.env,
+          image: {
+            repository: src.image.repository,
+            tag: src.image.tag,
+          },
+        })
+    )
+    .exhaustive();
 
   return proto;
 }

+ 302 - 137
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -1,7 +1,8 @@
-import React, { useContext, useEffect } from "react";
+import React, { useCallback, useContext, useEffect } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
 import web from "assets/web.png";
 import AnimateHeight from "react-animate-height";
+import axios from "axios";
 
 import styled from "styled-components";
 import { useForm, Controller, FormProvider } from "react-hook-form";
@@ -13,7 +14,11 @@ import { ControlledInput } from "components/porter/ControlledInput";
 import Link from "components/porter/Link";
 
 import { Context } from "shared/Context";
-import { PorterAppFormData } from "lib/porter-apps";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppToProto,
+} from "lib/porter-apps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import SourceSelector from "../new-app-flow/SourceSelector";
 import Button from "components/porter/Button";
@@ -22,23 +27,37 @@ import ImageSettings from "./ImageSettings";
 import Container from "components/porter/Container";
 import ServiceList from "../validate-apply/services-settings/ServiceList";
 import {
-  ClientService,
   defaultSerialized,
   deserializeService,
 } from "lib/porter-apps/services";
 import EnvVariables from "../validate-apply/app-settings/EnvVariables";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { valueExists } from "shared/util";
+import api from "shared/api";
+import { z } from "zod";
+import { PorterApp } from "@porter-dev/api-contracts";
+import GithubActionModal from "../new-app-flow/GithubActionModal";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import Error from "components/porter/Error";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 
 type CreateAppProps = {} & RouteComponentProps;
 
-const CreateApp: React.FC<CreateAppProps> = ({}) => {
-  const { currentProject } = useContext(Context);
+const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
+  const { currentProject, currentCluster } = useContext(Context);
   const [step, setStep] = React.useState(0);
   const [detectedServices, setDetectedServices] = React.useState<{
     detected: boolean;
     count: number;
   }>({ detected: false, count: 0 });
+  const [showGHAModal, setShowGHAModal] = React.useState(false);
+
+  const [
+    validatedAppProto,
+    setValidatedAppProto,
+  ] = React.useState<PorterApp | null>(null);
+  const [isDeploying, setIsDeploying] = React.useState(false);
+  const [deployError, setDeployError] = React.useState("");
 
   const porterAppFormMethods = useForm<PorterAppFormData>({
     reValidateMode: "onSubmit",
@@ -65,14 +84,141 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
     control,
     watch,
     setValue,
+    handleSubmit,
     formState: { isSubmitting },
   } = porterAppFormMethods;
 
   const name = watch("app.name");
   const source = watch("source");
   const build = watch("app.build");
-  const image = watch("app.image");
+  const image = watch("source.image");
   const servicesFromYaml = usePorterYaml(source);
+  const deploymentTarget = useDefaultDeploymentTarget();
+  const { updateAppStep } = useAppAnalytics(name);
+
+  const onSubmit = handleSubmit(async (data) => {
+    try {
+      if (!currentProject || !currentCluster) {
+        return;
+      }
+
+      if (!deploymentTarget) {
+        return;
+      }
+
+      const proto = clientAppToProto(data);
+      const res = await api.validatePorterApp(
+        "<token>",
+        {
+          b64_app_proto: btoa(proto.toJsonString()),
+          deployment_target_id: deploymentTarget.deployment_target_id,
+          commit_sha: "",
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const validAppData = await z
+        .object({
+          validate_b64_app_proto: z.string(),
+        })
+        .parseAsync(res.data);
+
+      const validatedAppProto = PorterApp.fromJsonString(
+        atob(validAppData.validate_b64_app_proto)
+      );
+
+      setValidatedAppProto(validatedAppProto);
+      if (source?.type === "github") {
+        setShowGHAModal(true);
+        return;
+      }
+
+      await createAndApply({ app: validatedAppProto, source });
+    } catch (err) {
+      if (axios.isAxiosError(err) && err.response?.data?.error) {
+        setDeployError(err.response?.data?.error);
+        return;
+      }
+      setDeployError(
+        "An error occurred while validating your application. Please try again."
+      );
+    }
+  });
+
+  const createAndApply = useCallback(
+    async ({
+      app,
+      source,
+    }: {
+      app: PorterApp | null;
+      source: SourceOptions;
+    }) => {
+      setIsDeploying(true);
+      // log analytics event that we started form submission
+      updateAppStep("stack-launch-complete");
+
+      try {
+        if (!currentProject?.id || !currentCluster?.id) {
+          return false;
+        }
+
+        if (!app || !deploymentTarget) {
+          return false;
+        }
+
+        await api.createApp(
+          "<token>",
+          {
+            ...source,
+            name: app.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        );
+
+        await api.applyApp(
+          "<token>",
+          {
+            b64_app_proto: btoa(app.toJsonString()),
+            deployment_target_id: deploymentTarget.deployment_target_id,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        );
+
+        // log analytics event that we successfully deployed
+        updateAppStep("stack-launch-success");
+
+        if (source.type === "docker-registry") {
+          history.push(`/apps/${app.name}`);
+        }
+
+        return true;
+      } catch (err) {
+        if (axios.isAxiosError(err) && err.response?.data?.error) {
+          updateAppStep("stack-launch-failure", err.response?.data?.error);
+          setDeployError(err.response?.data?.error);
+          return false;
+        }
+
+        const msg =
+          "An error occurred while deploying your application. Please try again.";
+        updateAppStep("stack-launch-failure", msg);
+        setDeployError(msg);
+        return false;
+      } finally {
+        setIsDeploying(false);
+      }
+    },
+    [currentProject?.id, currentCluster?.id]
+  );
 
   useEffect(() => {
     // set step to 1 if name is filled out
@@ -121,7 +267,7 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
     }
   }, [servicesFromYaml, detectedServices.detected]);
 
-  if (!currentProject) {
+  if (!currentProject || !currentCluster) {
     return null;
   }
 
@@ -138,156 +284,175 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
           />
           <DarkMatter />
           <FormProvider {...porterAppFormMethods}>
-            <VerticalSteps
-              currentStep={step}
-              steps={[
-                <>
-                  <Text size={16}>Application name</Text>
-                  <Spacer y={0.5} />
-                  <Text color="helper">
-                    Lowercase letters, numbers, and "-" only.
-                  </Text>
-                  <Spacer y={0.5} />
-                  <ControlledInput
-                    placeholder="ex: academic-sophon"
-                    type="text"
-                    {...register("app.name")}
-                  />
-                </>,
-                <>
-                  <Text size={16}>Deployment method</Text>
-                  <Spacer y={0.5} />
-                  <Text color="helper">
-                    Deploy from a Git repository or a Docker registry.
-                    <Spacer inline width="5px" />
-                    <Link
-                      hasunderline
-                      to="https://docs.porter.run/standard/deploying-applications/overview"
-                      target="_blank"
-                    >
-                      Learn more
-                    </Link>
-                  </Text>
-                  <Spacer y={0.5} />
-                  <Controller
-                    name="source.type"
-                    control={control}
-                    render={({ field: { value, onChange } }) => (
-                      <SourceSelector
-                        selectedSourceType={value}
-                        setSourceType={(sourceType) => {
-                          onChange(sourceType);
-                        }}
-                      />
-                    )}
-                  />
-                  <AnimateHeight height={source ? "auto" : 0}>
-                    <Spacer y={1} />
-                    {source?.type ? (
-                      source.type === "github" ? (
-                        <RepoSettings
-                          build={build}
-                          source={source}
-                          projectId={currentProject.id}
-                        />
-                      ) : (
-                        <ImageSettings />
-                      )
-                    ) : null}
-                  </AnimateHeight>
-                </>,
-                <>
-                  <Container row>
-                    <Text size={16}>Application services</Text>
-                    {detectedServices.detected && (
-                      <AppearingDiv
-                        color={
-                          detectedServices.detected ? "#8590ff" : "#fcba03"
-                        }
+            <form onSubmit={onSubmit}>
+              <VerticalSteps
+                currentStep={step}
+                steps={[
+                  <>
+                    <Text size={16}>Application name</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Lowercase letters, numbers, and "-" only.
+                    </Text>
+                    <Spacer y={0.5} />
+                    <ControlledInput
+                      placeholder="ex: academic-sophon"
+                      type="text"
+                      {...register("app.name")}
+                    />
+                  </>,
+                  <>
+                    <Text size={16}>Deployment method</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Deploy from a Git repository or a Docker registry.
+                      <Spacer inline width="5px" />
+                      <Link
+                        hasunderline
+                        to="https://docs.porter.run/standard/deploying-applications/overview"
+                        target="_blank"
                       >
-                        {detectedServices.count > 0 ? (
-                          <I className="material-icons">check</I>
+                        Learn more
+                      </Link>
+                    </Text>
+                    <Spacer y={0.5} />
+                    <Controller
+                      name="source.type"
+                      control={control}
+                      render={({ field: { value, onChange } }) => (
+                        <SourceSelector
+                          selectedSourceType={value}
+                          setSourceType={(sourceType) => {
+                            onChange(sourceType);
+                          }}
+                        />
+                      )}
+                    />
+                    <AnimateHeight height={source ? "auto" : 0}>
+                      <Spacer y={1} />
+                      {source?.type ? (
+                        source.type === "github" ? (
+                          <RepoSettings
+                            build={build}
+                            source={source}
+                            projectId={currentProject.id}
+                          />
                         ) : (
-                          <I className="material-icons">error</I>
-                        )}
-                        <Text
+                          <ImageSettings />
+                        )
+                      ) : null}
+                    </AnimateHeight>
+                  </>,
+                  <>
+                    <Container row>
+                      <Text size={16}>Application services</Text>
+                      {detectedServices.detected && (
+                        <AppearingDiv
                           color={
                             detectedServices.detected ? "#8590ff" : "#fcba03"
                           }
                         >
-                          {detectedServices.count > 0
-                            ? `Detected ${detectedServices.count} service${
-                                detectedServices.count > 1 ? "s" : ""
-                              } from porter.yaml.`
-                            : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
-                        </Text>
-                      </AppearingDiv>
-                    )}
-                  </Container>
-                  <Spacer y={0.5} />
-                  <ServiceList
-                    defaultExpanded={true}
-                    addNewText={"Add a new service"}
-                  />
-                </>,
-                <>
-                  <Text size={16}>Environment variables (optional)</Text>
-                  <Spacer y={0.5} />
-                  <Text color="helper">
-                    Specify environment variables shared among all services.
-                  </Text>
-                  <EnvVariables />
-                </>,
-                source.type === "github" && (
+                          {detectedServices.count > 0 ? (
+                            <I className="material-icons">check</I>
+                          ) : (
+                            <I className="material-icons">error</I>
+                          )}
+                          <Text
+                            color={
+                              detectedServices.detected ? "#8590ff" : "#fcba03"
+                            }
+                          >
+                            {detectedServices.count > 0
+                              ? `Detected ${detectedServices.count} service${
+                                  detectedServices.count > 1 ? "s" : ""
+                                } from porter.yaml.`
+                              : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
+                          </Text>
+                        </AppearingDiv>
+                      )}
+                    </Container>
+                    <Spacer y={0.5} />
+                    <ServiceList
+                      defaultExpanded={true}
+                      addNewText={"Add a new service"}
+                    />
+                  </>,
                   <>
-                    <Text size={16}>Pre-deploy job (optional)</Text>
+                    <Text size={16}>Environment variables (optional)</Text>
                     <Spacer y={0.5} />
                     <Text color="helper">
-                      You may add a pre-deploy job to perform an operation
-                      before your application services deploy each time, like a
-                      database migration.
+                      Specify environment variables shared among all services.
                     </Text>
-                    <Spacer y={0.5} />
-                    <ServiceList
-                      limitOne={true}
-                      addNewText={"Add a new pre-deploy job"}
-                      prePopulateService={deserializeService(
-                        defaultSerialized({
-                          name: "pre-deploy",
-                          type: "predeploy",
-                        })
-                      )}
-                      isPredeploy
-                    />
-                  </>
-                ),
-                <Button
-                  status={isSubmitting && "loading"}
-                  loadingText={"Deploying..."}
-                  width={"120px"}
-                  disabled={true}
-                >
-                  Deploy app
-                </Button>,
-              ].filter((x) => x)}
-            />
+                    <EnvVariables />
+                  </>,
+                  source.type === "github" && (
+                    <>
+                      <Text size={16}>Pre-deploy job (optional)</Text>
+                      <Spacer y={0.5} />
+                      <Text color="helper">
+                        You may add a pre-deploy job to perform an operation
+                        before your application services deploy each time, like
+                        a database migration.
+                      </Text>
+                      <Spacer y={0.5} />
+                      <ServiceList
+                        limitOne={true}
+                        addNewText={"Add a new pre-deploy job"}
+                        prePopulateService={deserializeService(
+                          defaultSerialized({
+                            name: "pre-deploy",
+                            type: "predeploy",
+                          })
+                        )}
+                        isPredeploy
+                      />
+                    </>
+                  ),
+                  <Button
+                    type="submit"
+                    status={
+                      isSubmitting || isDeploying ? (
+                        "loading"
+                      ) : deployError ? (
+                        <Error message={deployError} />
+                      ) : undefined
+                    }
+                    loadingText={"Deploying..."}
+                    width={"120px"}
+                    disabled={!name || !source || isSubmitting || isDeploying}
+                  >
+                    Deploy app
+                  </Button>,
+                ].filter((x) => x)}
+              />
+            </form>
           </FormProvider>
           <Spacer y={3} />
         </StyledConfigureTemplate>
       </Div>
+      {showGHAModal && source?.type === "github" && (
+        <GithubActionModal
+          closeModal={() => setShowGHAModal(false)}
+          githubAppInstallationID={source.git_repo_id}
+          githubRepoOwner={source.git_repo_name.split("/")[0]}
+          githubRepoName={source.git_repo_name.split("/")[1]}
+          branch={source.git_branch}
+          stackName={name}
+          projectId={currentProject.id}
+          clusterId={currentCluster.id}
+          deployPorterApp={() =>
+            createAndApply({ app: validatedAppProto, source })
+          }
+          deploymentError={deployError}
+          porterYamlPath={source.porter_yaml_path}
+        />
+      )}
     </CenterWrapper>
   );
 };
 
 export default withRouter(CreateApp);
 
-const ErrorText = styled.span`
-  color: red;
-  margin-left: 10px;
-  display: ${(props: { hasError: boolean }) =>
-    props.hasError ? "inline-block" : "none"};
-`;
-
 const Div = styled.div`
   width: 100%;
   max-width: 900px;

+ 9 - 10
dashboard/src/main/home/app-dashboard/create-app/ImageSettings.tsx

@@ -6,14 +6,13 @@ import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import ImageSelector from "components/image-selector/ImageSelector";
 import { Controller, useFormContext } from "react-hook-form";
-import { ClientPorterApp, PorterAppFormData } from "lib/porter-apps";
+import { PorterAppFormData } from "lib/porter-apps";
 
 const ImageSettings: React.FC = ({}) => {
   const { control } = useFormContext<PorterAppFormData>();
 
   return (
     <StyledSourceBox>
-
       <Subtitle>
         Specify the container image you would like to connect to this template.
         <Spacer inline width="5px" />
@@ -31,23 +30,23 @@ const ImageSettings: React.FC = ({}) => {
       <DarkMatter antiHeight="-4px" />
       {/* // todo(ianedwards): rewrite image selector to be more easily controllable by form */}
       <Controller
-        name="app.image"
+        name="source.image"
         control={control}
         render={({ field: { onChange, value } }) => (
           <ImageSelector
-            selectedTag={value?.tag || ""}
+            selectedTag={value?.tag || "latest"}
             selectedImageUrl={value?.repository || ""}
             setSelectedImageUrl={(imageUrl) => {
-              onChange((prev: ClientPorterApp["image"]) => ({
-                ...prev,
+              onChange({
+                tag: value?.tag ?? "latest",
                 repository: imageUrl,
-              }));
+              });
             }}
             setSelectedTag={(tag) => {
-              onChange((prev: ClientPorterApp["image"]) => ({
-                ...prev,
+              onChange({
+                ...value,
                 tag,
-              }));
+              });
             }}
             forceExpanded={true}
           />

+ 67 - 0
dashboard/src/shared/api.tsx

@@ -793,6 +793,69 @@ const parsePorterYaml = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/parse`;
 });
 
+const getDefaultDeploymentTarget = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/default-deployment-target`;
+});
+
+const validatePorterApp = baseApi<
+  {
+    b64_app_proto: string;
+    deployment_target_id: string;
+    commit_sha: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/validate`;
+});
+
+const createApp = baseApi<
+  | {
+      name: string;
+      type: "github";
+      git_repo_id: number;
+      git_branch: string;
+      git_repo_name: string;
+      porter_yaml_path: string;
+    }
+  | {
+      name: string;
+      type: "docker-registry";
+      image: {
+        repository: string;
+        tag: string;
+      };
+    },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/create`;
+});
+
+const applyApp = baseApi<
+  {
+    deployment_target_id: string;
+    b64_app_proto?: string;
+    app_revision_id?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/apply`;
+});
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -2860,6 +2923,10 @@ export default {
   getProcfileContents,
   getPorterYamlContents,
   parsePorterYaml,
+  getDefaultDeploymentTarget,
+  validatePorterApp,
+  createApp,
+  applyApp,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,