Sfoglia il codice sorgente

Merge branch 'master' into referral-program

jusrhee 2 anni fa
parent
commit
08bc75a4e8
17 ha cambiato i file con 362 aggiunte e 161 eliminazioni
  1. 11 3
      dashboard/src/lib/hooks/usePorterYaml.ts
  2. 150 99
      dashboard/src/lib/porter-apps/index.ts
  3. 19 0
      dashboard/src/lib/porter-apps/services.ts
  4. 3 0
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  5. 4 6
      dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx
  6. 1 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx
  7. 2 1
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  8. 8 2
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx
  9. 75 21
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx
  10. 10 6
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/JobTabs.tsx
  11. 13 5
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Main.tsx
  12. 9 4
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx
  13. 3 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/Addons.tsx
  14. 3 6
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx
  15. 35 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewSaveButton.tsx
  16. 3 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx
  17. 13 3
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/ServiceSettings.tsx

+ 11 - 3
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -139,17 +139,18 @@ export const usePorterYaml = ({
           ignoreUnknownFields: true,
         });
 
-        const { services, predeploy, build } = serviceOverrides({
+        const { services, predeploy, initialDeploy, build } = serviceOverrides({
           overrides: proto,
           useDefaults,
           defaultCPU: newServiceDefaultCpuCores,
           defaultRAM: newServiceDefaultRamMegabytes,
         });
 
-        if (services.length || predeploy || build) {
+        if (services.length || predeploy || initialDeploy || build) {
           setDetectedServices({
             build,
             services,
+            initialDeploy,
             predeploy,
           });
         }
@@ -164,19 +165,26 @@ export const usePorterYaml = ({
           const {
             services: previewServices,
             predeploy: previewPredeploy,
+            initialDeploy: previewInitialDeploy,
             build: previewBuild,
           } = serviceOverrides({
             overrides: previewProto,
             useDefaults,
           });
 
-          if (previewServices.length || previewPredeploy || previewBuild) {
+          if (
+            previewServices.length ||
+            previewPredeploy ||
+            previewInitialDeploy ||
+            previewBuild
+          ) {
             setDetectedServices((prev) => ({
               ...prev,
               services: prev?.services ? prev.services : [],
               previews: {
                 services: previewServices,
                 predeploy: previewPredeploy,
+                initialDeploy: previewInitialDeploy,
                 build: previewBuild,
                 variables: data.preview_app?.env_variables ?? {},
               },

+ 150 - 99
dashboard/src/lib/porter-apps/index.ts

@@ -7,7 +7,7 @@ import {
   PorterApp,
   Service,
 } from "@porter-dev/api-contracts";
-import { match } from "ts-pattern";
+import { match, P } from "ts-pattern";
 import { z } from "zod";
 
 import { BUILDPACK_TO_NAME } from "main/home/app-dashboard/types/buildpack";
@@ -64,6 +64,11 @@ export const deletionValidator = z.object({
       name: z.string(),
     })
     .array(),
+  initialDeploy: z
+    .object({
+      name: z.string(),
+    })
+    .array(),
   envGroupNames: z
     .object({
       name: z.string(),
@@ -106,6 +111,7 @@ export const clientAppValidator = z.object({
     .default([]),
   services: serviceValidator.array(),
   predeploy: serviceValidator.array().optional(),
+  initialDeploy: serviceValidator.array().optional(),
   env: z
     .object({
       key: z.string(),
@@ -236,41 +242,101 @@ export function serviceOverrides({
     };
   }
 
-  if (useDefaults) {
-    return {
-      build: validatedBuild,
-      services,
-      predeploy: deserializeService({
-        service: defaultSerialized({
-          name: "pre-deploy",
-          type: "predeploy",
-          defaultCPU,
-          defaultRAM,
-        }),
-        override: serializedServiceFromProto({
+  const predeploy = match({
+    predeployOverride: overrides.predeploy,
+    useDefaults,
+  })
+    .with(
+      {
+        predeployOverride: P.nullish,
+      },
+      () => undefined
+    )
+    .with(
+      {
+        useDefaults: true,
+      },
+      ({ predeployOverride }) =>
+        deserializeService({
+          service: defaultSerialized({
+            name: "pre-deploy",
+            type: "predeploy",
+            defaultCPU,
+            defaultRAM,
+          }),
+          override: serializedServiceFromProto({
+            service: new Service({
+              ...predeployOverride,
+              name: "pre-deploy",
+            }),
+            isPredeploy: true,
+          }),
+          expanded: true,
+        })
+    )
+    .otherwise(({ predeployOverride }) =>
+      deserializeService({
+        service: serializedServiceFromProto({
           service: new Service({
-            ...overrides.predeploy,
+            ...predeployOverride,
             name: "pre-deploy",
           }),
           isPredeploy: true,
         }),
-        expanded: true,
-      }),
-    };
-  }
+      })
+    );
+
+  const initialDeploy = match({
+    initialDeployOverride: overrides.initialDeploy,
+    useDefaults,
+  })
+    .with(
+      {
+        initialDeployOverride: P.nullish,
+      },
+      () => undefined
+    )
+    .with(
+      {
+        useDefaults: true,
+        initialDeployOverride: P.not(P.nullish),
+      },
+      ({ initialDeployOverride }) =>
+        deserializeService({
+          service: defaultSerialized({
+            name: "initdeploy",
+            type: "initdeploy",
+            defaultCPU,
+            defaultRAM,
+          }),
+          override: serializedServiceFromProto({
+            service: new Service({
+              ...initialDeployOverride,
+              name: "initdeploy",
+            }),
+            isPredeploy: false,
+            isInitdeploy: true,
+          }),
+          expanded: true,
+        })
+    )
+    .otherwise(({ initialDeployOverride }) =>
+      deserializeService({
+        service: serializedServiceFromProto({
+          service: new Service({
+            ...(initialDeployOverride ?? {}),
+            name: "initdeploy",
+          }),
+          isInitdeploy: true,
+        }),
+      })
+    );
 
   return {
     build: validatedBuild,
     services,
-    predeploy: deserializeService({
-      service: serializedServiceFromProto({
-        service: new Service({
-          ...overrides.predeploy,
-          name: "pre-deploy",
-        }),
-        isPredeploy: true,
-      }),
-    }),
+    predeploy,
+    initialDeploy,
   };
 }
 
@@ -312,6 +378,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
   const predeploy = app.predeploy?.[0]?.run.value
     ? app.predeploy[0]
     : undefined;
+  const initialDeploy = app.initialDeploy?.[0]?.run.value
+    ? app.initialDeploy[0]
+    : undefined;
 
   const proto = match(source)
     .with(
@@ -329,6 +398,9 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           ...(predeploy && {
             predeploy: serviceProto(serializeService(predeploy)),
           }),
+          ...(initialDeploy && {
+            initialDeploy: serviceProto(serializeService(initialDeploy)),
+          }),
           helmOverrides:
             app.helmOverrides != null
               ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
@@ -477,7 +549,43 @@ export function clientAppFromProto({
       });
     });
 
-  const predeployList = [];
+  const predeployList = (proto.predeploy ? [proto.predeploy] : [])
+    .map((service) =>
+      serializedServiceFromProto({ service, isPredeploy: true })
+    )
+    .map((svc) => {
+      const override = overrides?.predeploy;
+      if (override) {
+        return deserializeService({
+          service: svc,
+          override: serializeService(override),
+        });
+      }
+
+      return deserializeService({
+        service: svc,
+        lockDeletions: lockServiceDeletions,
+      });
+    });
+  const initialDeployList = (proto.initialDeploy ? [proto.initialDeploy] : [])
+    .map((service) =>
+      serializedServiceFromProto({ service, isInitdeploy: true })
+    )
+    .map((svc) => {
+      const override = overrides?.initialDeploy;
+      if (override) {
+        return deserializeService({
+          service: svc,
+          override: serializeService(override),
+        });
+      }
+
+      return deserializeService({
+        service: svc,
+        lockDeletions: lockServiceDeletions,
+      });
+    });
+
   const parsedEnv: KeyValueType[] = [
     ...Object.entries(variables).map(([key, value]) => ({
       key,
@@ -498,83 +606,14 @@ export function clientAppFromProto({
   const helmOverrides =
     proto.helmOverrides == null ? "" : atob(proto.helmOverrides.b64Values);
 
-  if (proto.predeploy) {
-    predeployList.push(
-      deserializeService({
-        service: serializedServiceFromProto({
-          service: new Service({
-            ...proto.predeploy,
-            name: "pre-deploy",
-          }),
-          isPredeploy: true,
-        }),
-        lockDeletions: lockServiceDeletions,
-      })
-    );
-  }
-  if (!overrides?.predeploy) {
-    return {
-      name: {
-        readOnly: true,
-        value: proto.name,
-      },
-      services,
-      predeploy: predeployList,
-      env: parsedEnv,
-      envGroups: proto.envGroups.map((eg) => ({
-        name: eg.name,
-        version: eg.version,
-      })),
-      build: clientBuildFromProto(proto.build) ?? {
-        method: "pack",
-        context: "./",
-        buildpacks: [],
-        builder: "",
-      },
-      helmOverrides,
-      efsStorage: new EFS({
-        enabled: proto.efsStorage?.enabled ?? false,
-      }),
-      cloudSql: {
-        enabled: proto.cloudSql?.enabled ?? false,
-        connectionName: proto.cloudSql?.connectionName ?? "",
-        serviceAccountJsonSecret:
-          proto.cloudSql?.serviceAccountJsonSecret ?? "",
-        dbPort: proto.cloudSql?.dbPort ?? 5432,
-      },
-      requiredApps: proto.requiredApps.map((app) => ({
-        name: app.name,
-      })),
-      autoRollback: {
-        enabled: proto.autoRollback?.enabled ?? true, // enabled by default if not found in proto
-        readOnly: false, // TODO: detect autorollback from porter.yaml
-      },
-    };
-  }
-
-  const predeployOverrides = serializeService(overrides.predeploy);
-  const predeploy = proto.predeploy
-    ? [
-        deserializeService({
-          service: serializedServiceFromProto({
-            service: new Service({
-              ...proto.predeploy,
-              name: "pre-deploy",
-            }),
-            isPredeploy: true,
-          }),
-          override: predeployOverrides,
-        }),
-      ]
-    : undefined;
-
   return {
     name: {
       readOnly: true,
       value: proto.name,
     },
     services,
-    predeploy,
+    predeploy: predeployList.length ? predeployList : undefined,
+    initialDeploy: initialDeployList.length ? initialDeployList : undefined,
     env: parsedEnv,
     envGroups: proto.envGroups.map((eg) => ({
       name: eg.name,
@@ -667,6 +706,18 @@ export function applyPreviewOverrides({
     }
   }
 
+  if (app.initialDeploy) {
+    const initialDeployOverride = overrides?.initialDeploy;
+    if (initialDeployOverride) {
+      app.initialDeploy = [
+        deserializeService({
+          service: serializeService(app.initialDeploy[0]),
+          override: serializeService(initialDeployOverride),
+        }),
+      ];
+    }
+  }
+
   const envOverrides = overrides?.variables;
 
   const env = app.env.map((e) => {

+ 19 - 0
dashboard/src/lib/porter-apps/services.ts

@@ -32,10 +32,12 @@ const LAUNCHER_PREFIX = "/cnb/lifecycle/launcher ";
 export type DetectedServices = {
   services: ClientService[];
   predeploy?: ClientService;
+  initialDeploy?: ClientService;
   build?: BuildOptions;
   previews?: {
     services: ClientService[];
     predeploy?: ClientService;
+    initialDeploy?: ClientService;
     variables?: Record<string, string>;
   };
 };
@@ -196,6 +198,12 @@ export function isPredeployService(
   return service.config.type === "predeploy";
 }
 
+export function isInitdeployService(
+  service: SerializedService | ClientService
+): boolean {
+  return service.config.type === "initdeploy";
+}
+
 export function prefixSubdomain(subdomain: string): string {
   if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
     return subdomain;
@@ -713,9 +721,11 @@ export function serviceProto(service: SerializedService): Service {
 export function serializedServiceFromProto({
   service,
   isPredeploy,
+  isInitdeploy,
 }: {
   service: Service;
   isPredeploy?: boolean;
+  isInitdeploy?: boolean;
 }): SerializedService {
   const config = service.config;
   if (!config.case) {
@@ -758,6 +768,15 @@ export function serializedServiceFromProto({
               type: "predeploy" as const,
             },
           }
+        : isInitdeploy
+        ? {
+            ...service,
+            run: service.runOptional ?? service.run,
+            instances: service.instancesOptional ?? service.instances,
+            config: {
+              type: "initdeploy" as const,
+            },
+          }
         : {
             ...service,
             run: service.runOptional ?? service.run,

+ 3 - 0
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -137,6 +137,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         serviceNames: [],
         envGroupNames: [],
         predeploy: [],
+        initialDeploy: [],
       },
     },
   });
@@ -363,6 +364,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         predeploy: [],
         envGroupNames: [],
         serviceNames: [],
+        initialDeploy: [],
       },
       redeployOnSave: false,
     });
@@ -534,6 +536,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         envGroupNames: [],
         serviceNames: [],
         predeploy: [],
+        initialDeploy: [],
       },
       redeployOnSave: false,
     });

+ 4 - 6
dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx

@@ -5,22 +5,21 @@ import { z } from "zod";
 
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { type PorterAppFormData, type SourceOptions } from "lib/porter-apps";
+import PreviewSaveButton from "main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewSaveButton";
+import { type PorterAppFormData } from "lib/porter-apps";
 
 import api from "shared/api";
 
 import EnvSettings from "../../validate-apply/app-settings/EnvSettings";
 import { populatedEnvGroup } from "../../validate-apply/app-settings/types";
 import { type ButtonStatus } from "../AppDataContainer";
-import AppSaveButton from "../AppSaveButton";
 import { useLatestRevision } from "../LatestRevisionContext";
 
 type Props = {
-  latestSource: SourceOptions;
   buttonStatus: ButtonStatus;
 };
 
-const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
+const Environment: React.FC<Props> = ({ buttonStatus }) => {
   const {
     latestRevision,
     latestProto,
@@ -67,11 +66,10 @@ const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
         appName={latestProto.name}
         revision={previewRevision || latestRevision} // get versions of env groups attached to preview revision if set
         baseEnvGroups={baseEnvGroups}
-        latestSource={latestSource}
         attachedEnvGroups={attachedEnvGroups}
       />
       <Spacer y={1} />
-      <AppSaveButton
+      <PreviewSaveButton
         status={buttonStatus}
         isDisabled={isSubmitting}
         disabledTooltipMessage="Please wait for the deploy to complete before updating environment variables"

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -53,7 +53,7 @@ const Overview: React.FC<Props> = ({ buttonStatus }) => {
       <ServiceList
         addNewText={"Add a new pre-deploy job"}
         existingServiceNames={latestProto.predeploy ? ["pre-deploy"] : []}
-        isPredeploy
+        lifecycleJobType="predeploy"
         fieldArrayName={"app.predeploy"}
       />
       <Spacer y={0.5} />

+ 2 - 1
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -165,6 +165,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         serviceNames: [],
         envGroupNames: [],
         predeploy: [],
+        initialDeploy: [],
       },
     },
   });
@@ -715,7 +716,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     <ServiceList
                       addNewText={"Add a new pre-deploy job"}
                       fieldArrayName={"app.predeploy"}
-                      isPredeploy
+                      lifecycleJobType="predeploy"
                     />
                   </>,
                   <>

+ 8 - 2
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -16,6 +16,7 @@ import {
 import chip from "assets/computer-chip.svg";
 import job from "assets/job.png";
 import moon from "assets/moon.svg";
+import seed from "assets/seed.svg";
 import web from "assets/web.png";
 import worker from "assets/worker.png";
 
@@ -30,7 +31,7 @@ type ServiceProps = {
   service: ClientService;
   update: UseFieldArrayUpdate<
     PorterAppFormData,
-    "app.services" | "app.predeploy"
+    "app.services" | "app.predeploy" | "app.initialDeploy"
   >;
   remove: (index: number) => void;
   status?: ClientServiceStatus[];
@@ -66,7 +67,10 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         <JobTabs index={index} service={svc} />
       ))
       .with({ config: { type: "predeploy" } }, (svc) => (
-        <JobTabs index={index} service={svc} isPredeploy />
+        <JobTabs index={index} service={svc} lifecycleJobType="predeploy" />
+      ))
+      .with({ config: { type: "initdeploy" } }, (svc) => (
+        <JobTabs index={index} service={svc} lifecycleJobType="initdeploy" />
       ))
       .exhaustive();
   };
@@ -81,6 +85,8 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         return <Icon src={job} />;
       case "predeploy":
         return <Icon src={job} />;
+      case "initdeploy":
+        return <Icon src={seed} />;
     }
   };
 

+ 75 - 21
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -22,6 +22,7 @@ import {
   defaultSerialized,
   deserializeService,
   getServiceResourceAllowances,
+  isInitdeployService,
   isPredeployService,
 } from "lib/porter-apps/services";
 
@@ -47,9 +48,9 @@ type AddServiceFormValues = z.infer<typeof addServiceFormValidator>;
 
 type ServiceListProps = {
   addNewText: string;
-  isPredeploy?: boolean;
+  lifecycleJobType?: "predeploy" | "initdeploy";
   existingServiceNames?: string[];
-  fieldArrayName: "app.services" | "app.predeploy";
+  fieldArrayName: "app.services" | "app.predeploy" | "app.initialDeploy";
   serviceVersionStatus?: Record<string, ClientServiceStatus[]>;
   internalNetworkingDetails?: {
     namespace: string;
@@ -62,7 +63,7 @@ type ServiceListProps = {
 const ServiceList: React.FC<ServiceListProps> = ({
   addNewText,
   fieldArrayName,
-  isPredeploy = false,
+  lifecycleJobType,
   existingServiceNames = [],
   serviceVersionStatus,
   internalNetworkingDetails = {
@@ -115,7 +116,9 @@ const ServiceList: React.FC<ServiceListProps> = ({
     name:
       fieldArrayName === "app.services"
         ? "deletions.serviceNames"
-        : "deletions.predeploy",
+        : lifecycleJobType === "predeploy"
+        ? "deletions.predeploy"
+        : "deletions.initialDeploy",
   });
 
   const serviceName = watch("name");
@@ -126,12 +129,35 @@ const ServiceList: React.FC<ServiceListProps> = ({
   const services = useMemo(() => {
     // if predeploy, only show predeploy services
     // if not predeploy, only show non-predeploy services
+    if (lifecycleJobType === "predeploy") {
+      return fields.map((svc, idx) => {
+        const predeploy = isPredeployService(svc);
+        return {
+          svc,
+          idx,
+          included: predeploy,
+        };
+      });
+    }
+
+    if (lifecycleJobType === "initdeploy") {
+      return fields.map((svc, idx) => {
+        const initdeploy = isInitdeployService(svc);
+        return {
+          svc,
+          idx,
+          included: initdeploy,
+        };
+      });
+    }
+
     return fields.map((svc, idx) => {
       const predeploy = isPredeployService(svc);
+      const initdeploy = isInitdeployService(svc);
       return {
         svc,
         idx,
-        included: isPredeploy ? predeploy : !predeploy,
+        included: !predeploy && !initdeploy,
       };
     });
   }, [fields]);
@@ -141,14 +167,24 @@ const ServiceList: React.FC<ServiceListProps> = ({
       setError("name", {
         message: "A service with this name already exists",
       });
-    } else if (!isPredeploy && serviceName === "predeploy") {
+    } else if (
+      lifecycleJobType !== "predeploy" &&
+      serviceName === "predeploy"
+    ) {
       setError("name", {
         message: "predeploy is a reserved service name",
       });
+    } else if (
+      lifecycleJobType !== "initdeploy" &&
+      serviceName === "initdeploy"
+    ) {
+      setError("name", {
+        message: "initdeploy is a reserved service name",
+      });
     } else {
       clearErrors("name");
     }
-  }, [serviceName, isPredeploy]);
+  }, [serviceName, lifecycleJobType]);
 
   const isServiceNameDuplicate = (name: string): boolean => {
     return services.some(({ svc: s }) => s.name.value === name);
@@ -156,7 +192,10 @@ const ServiceList: React.FC<ServiceListProps> = ({
 
   const maybeRenderAddServicesButton = (): JSX.Element | null => {
     if (
-      (isPredeploy && services.find((s) => isPredeployService(s.svc))) ||
+      (lifecycleJobType === "predeploy" &&
+        services.find((s) => isPredeployService(s.svc))) ||
+      (lifecycleJobType === "initdeploy" &&
+        services.find((s) => isInitdeployService(s.svc))) ||
       !allowAddServices
     ) {
       return null;
@@ -165,22 +204,37 @@ const ServiceList: React.FC<ServiceListProps> = ({
       <>
         <AddServiceButton
           onClick={() => {
-            if (!isPredeploy) {
-              setShowAddServiceModal(true);
+            if (lifecycleJobType === "initdeploy") {
+              append(
+                deserializeService({
+                  service: defaultSerialized({
+                    name: "initdeploy",
+                    type: "initdeploy",
+                    defaultCPU: newServiceDefaultCpuCores,
+                    defaultRAM: newServiceDefaultRamMegabytes,
+                  }),
+                  expanded: true,
+                })
+              );
+              return;
+            }
+
+            if (lifecycleJobType === "predeploy") {
+              append(
+                deserializeService({
+                  service: defaultSerialized({
+                    name: "pre-deploy",
+                    type: "predeploy",
+                    defaultCPU: newServiceDefaultCpuCores,
+                    defaultRAM: newServiceDefaultRamMegabytes,
+                  }),
+                  expanded: true,
+                })
+              );
               return;
             }
 
-            append(
-              deserializeService({
-                service: defaultSerialized({
-                  name: "pre-deploy",
-                  type: "predeploy",
-                  defaultCPU: newServiceDefaultCpuCores,
-                  defaultRAM: newServiceDefaultRamMegabytes,
-                }),
-                expanded: true,
-              })
-            );
+            setShowAddServiceModal(true);
           }}
         >
           <i className="material-icons add-icon">add_icon</i>

+ 10 - 6
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/JobTabs.tsx

@@ -18,19 +18,19 @@ type Props = {
   index: number;
   service: ClientService & {
     config: {
-      type: "job" | "predeploy";
+      type: "job" | "predeploy" | "initdeploy";
     };
   };
-  isPredeploy?: boolean;
+  lifecycleJobType?: "predeploy" | "initdeploy";
 };
 
-const JobTabs: React.FC<Props> = ({ index, service, isPredeploy }) => {
+const JobTabs: React.FC<Props> = ({ index, service, lifecycleJobType }) => {
   const { control, register } = useFormContext<PorterAppFormData>();
   const [currentTab, setCurrentTab] = React.useState<
     "main" | "resources" | "advanced"
   >("main");
 
-  const tabs = isPredeploy
+  const tabs = lifecycleJobType
     ? [
         { label: "Main", value: "main" as const },
         { label: "Resources", value: "resources" as const },
@@ -50,13 +50,17 @@ const JobTabs: React.FC<Props> = ({ index, service, isPredeploy }) => {
       />
       {match(currentTab)
         .with("main", () => (
-          <MainTab index={index} service={service} isPredeploy={isPredeploy} />
+          <MainTab
+            index={index}
+            service={service}
+            lifecycleJobType={lifecycleJobType}
+          />
         ))
         .with("resources", () => (
           <Resources
             index={index}
             service={service}
-            isPredeploy={isPredeploy}
+            lifecycleJobType={lifecycleJobType}
           />
         ))
         .with(

+ 13 - 5
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Main.tsx

@@ -16,18 +16,19 @@ import { type ClientService } from "lib/porter-apps/services";
 type MainTabProps = {
   index: number;
   service: ClientService;
-  isPredeploy?: boolean;
+  lifecycleJobType?: "predeploy" | "initdeploy";
 };
 
 const MainTab: React.FC<MainTabProps> = ({
   index,
   service,
-  isPredeploy = false,
+  lifecycleJobType,
 }) => {
   const { register, control, watch } = useFormContext<PorterAppFormData>();
   const cron = watch(`app.services.${index}.config.cron.value`);
   const run = watch(`app.services.${index}.run.value`);
   const predeployRun = watch(`app.predeploy.${index}.run.value`);
+  const initdeployRun = watch(`app.initialDeploy.${index}.run.value`);
 
   const build = watch("app.build");
   const source = watch("source");
@@ -55,9 +56,14 @@ const MainTab: React.FC<MainTabProps> = ({
   }, []);
 
   const isStartCommandValid = useMemo(() => {
-    const runCommand = isPredeploy ? predeployRun : run;
+    const runCommand =
+      lifecycleJobType === "predeploy"
+        ? predeployRun
+        : lifecycleJobType === "initdeploy"
+        ? initdeployRun
+        : run;
     return runCommand.includes("&&") || runCommand.includes(";");
-  }, [isPredeploy, predeployRun, run]);
+  }, [lifecycleJobType, predeployRun, run]);
 
   // if your Docker image has a CMD or ENTRYPOINT
   return (
@@ -87,8 +93,10 @@ const MainTab: React.FC<MainTabProps> = ({
         disabled={service.run.readOnly}
         disabledTooltip={"You may only edit this field in your porter.yaml."}
         {...register(
-          isPredeploy
+          lifecycleJobType === "predeploy"
             ? `app.predeploy.${index}.run.value`
+            : lifecycleJobType === "initdeploy"
+            ? `app.initialDeploy.${index}.run.value`
             : `app.services.${index}.run.value`
         )}
       />

+ 9 - 4
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx

@@ -23,13 +23,13 @@ import IntelligentSlider from "./IntelligentSlider";
 type ResourcesProps = {
   index: number;
   service: ClientService;
-  isPredeploy?: boolean;
+  lifecycleJobType?: "predeploy" | "initdeploy";
 };
 
 const Resources: React.FC<ResourcesProps> = ({
   index,
   service,
-  isPredeploy = false,
+  lifecycleJobType,
 }) => {
   const { control, register, watch } = useFormContext<PorterAppFormData>();
   const { currentProject } = useContext(Context);
@@ -69,8 +69,10 @@ const Resources: React.FC<ResourcesProps> = ({
       )}
       <Controller
         name={
-          isPredeploy
+          lifecycleJobType === "predeploy"
             ? `app.predeploy.${index}.cpuCores`
+            : lifecycleJobType === "initdeploy"
+            ? `app.initialDeploy.${index}.cpuCores`
             : `app.services.${index}.cpuCores`
         }
         control={control}
@@ -102,8 +104,10 @@ const Resources: React.FC<ResourcesProps> = ({
       <Spacer y={1} />
       <Controller
         name={
-          isPredeploy
+          lifecycleJobType === "predeploy"
             ? `app.predeploy.${index}.ramMegabytes`
+            : lifecycleJobType === "initdeploy"
+            ? `app.initialDeploy.${index}.ramMegabytes`
             : `app.services.${index}.ramMegabytes`
         }
         control={control}
@@ -168,6 +172,7 @@ const Resources: React.FC<ResourcesProps> = ({
       {match(service.config)
         .with({ type: "job" }, () => null)
         .with({ type: "predeploy" }, () => null)
+        .with({ type: "initdeploy" }, () => null)
         .otherwise((config) => (
           <>
             <Spacer y={1} />

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/Addons.tsx

@@ -5,10 +5,11 @@ import { useFormContext } from "react-hook-form";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
-import AppSaveButton from "main/home/app-dashboard/app-view/AppSaveButton";
 import { AddonsList } from "main/home/managed-addons/AddonsList";
 import { type PorterAppFormData } from "lib/porter-apps";
 
+import PreviewSaveButton from "./PreviewSaveButton";
+
 type Props = {
   buttonStatus: ButtonStatus;
 };
@@ -30,7 +31,7 @@ export const Addons: React.FC<Props> = ({ buttonStatus }) => {
       <Spacer y={0.5} />
       <AddonsList />
       <Spacer y={0.75} />
-      <AppSaveButton
+      <PreviewSaveButton
         status={buttonStatus}
         isDisabled={isSubmitting}
         disabledTooltipMessage={"Please fill out all required fields"}

+ 3 - 6
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx

@@ -118,6 +118,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
         serviceNames: [],
         envGroupNames: [],
         predeploy: [],
+        initialDeploy: [],
       },
       addons: [],
     },
@@ -281,6 +282,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
         serviceNames: [],
         envGroupNames: [],
         predeploy: [],
+        initialDeploy: [],
       },
       addons: existingAddonsWithEnv,
     });
@@ -315,12 +317,7 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
           .with("services", () => (
             <ServiceSettings buttonStatus={buttonStatus} />
           ))
-          .with("variables", () => (
-            <Environment
-              latestSource={latestSource}
-              buttonStatus={buttonStatus}
-            />
-          ))
+          .with("variables", () => <Environment buttonStatus={buttonStatus} />)
           .with("required-apps", () => (
             <RequiredApps buttonStatus={buttonStatus} />
           ))

+ 35 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewSaveButton.tsx

@@ -0,0 +1,35 @@
+import React from "react";
+
+import Button from "components/porter/Button";
+import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
+
+type Props = {
+  status: ButtonStatus;
+  isDisabled: boolean;
+  disabledTooltipMessage: string;
+  height?: string;
+  disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
+};
+const PreviewSaveButton: React.FC<Props> = ({
+  status,
+  isDisabled,
+  disabledTooltipMessage,
+  height,
+  disabledTooltipPosition,
+}) => {
+  return (
+    <Button
+      type="submit"
+      status={status}
+      loadingText={"Saving..."}
+      disabled={isDisabled}
+      disabledTooltipMessage={disabledTooltipMessage}
+      height={height}
+      disabledTooltipPosition={disabledTooltipPosition}
+    >
+      Save
+    </Button>
+  );
+};
+
+export default PreviewSaveButton;

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RequiredApps.tsx

@@ -4,7 +4,6 @@ import { useFieldArray, useFormContext } from "react-hook-form";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
-import AppSaveButton from "main/home/app-dashboard/app-view/AppSaveButton";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import SelectableAppList from "main/home/app-dashboard/apps/SelectableAppList";
 import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
@@ -12,6 +11,8 @@ import { type PorterAppFormData } from "lib/porter-apps";
 
 import { Context } from "shared/Context";
 
+import PreviewSaveButton from "./PreviewSaveButton";
+
 type Props = {
   buttonStatus: ButtonStatus;
 };
@@ -72,7 +73,7 @@ export const RequiredApps: React.FC<Props> = ({ buttonStatus }) => {
         })}
       />
       <Spacer y={0.75} />
-      <AppSaveButton
+      <PreviewSaveButton
         status={buttonStatus}
         isDisabled={isSubmitting}
         disabledTooltipMessage={"Please fill out all required fields"}

+ 13 - 3
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/ServiceSettings.tsx

@@ -5,11 +5,12 @@ import { useFormContext } from "react-hook-form";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
-import AppSaveButton from "main/home/app-dashboard/app-view/AppSaveButton";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
 import { type PorterAppFormData } from "lib/porter-apps";
 
+import PreviewSaveButton from "./PreviewSaveButton";
+
 type Props = {
   buttonStatus: ButtonStatus;
 };
@@ -23,12 +24,21 @@ export const ServiceSettings: React.FC<Props> = ({ buttonStatus }) => {
 
   return (
     <>
+      <Text size={16}>Initial deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new initial deploy job"}
+        existingServiceNames={latestProto.initialDeploy ? ["initdeploy"] : []}
+        lifecycleJobType="initdeploy"
+        fieldArrayName={"app.initialDeploy"}
+      />
+      <Spacer y={0.5} />
       <Text size={16}>Pre-deploy job</Text>
       <Spacer y={0.5} />
       <ServiceList
         addNewText={"Add a new pre-deploy job"}
         existingServiceNames={latestProto.predeploy ? ["pre-deploy"] : []}
-        isPredeploy
+        lifecycleJobType="predeploy"
         fieldArrayName={"app.predeploy"}
       />
       <Spacer y={0.5} />
@@ -44,7 +54,7 @@ export const ServiceSettings: React.FC<Props> = ({ buttonStatus }) => {
         allowAddServices={false}
       />
       <Spacer y={0.75} />
-      <AppSaveButton
+      <PreviewSaveButton
         status={buttonStatus}
         isDisabled={isSubmitting}
         disabledTooltipMessage={"Please fill out all required fields"}