Explorar el Código

handle predeploy with new syntax (#3404)

ianedwards hace 2 años
padre
commit
0a8d44088c

+ 2 - 2
dashboard/src/lib/porter-apps/index.ts

@@ -45,7 +45,6 @@ export const porterAppValidator = z.object({
   services: serviceValidator.array(),
   env: z.record(z.string(), z.string()),
   build: buildValidator,
-  predeploy: serviceValidator.optional(),
   image: z
     .object({
       repository: z.string(),
@@ -88,11 +87,12 @@ export function defaultServicesWithOverrides({
     ? deserializeService(
         defaultSerialized({
           name: "pre-deploy",
-          type: "job",
+          type: "predeploy",
         }),
         serializedServiceFromProto({
           name: "pre-deploy",
           service: overrides.predeploy,
+          isPredeploy: true,
         })
       )
     : undefined;

+ 55 - 5
dashboard/src/lib/porter-apps/services.ts

@@ -17,6 +17,8 @@ import {
 } from "./values";
 import { Service, ServiceType } from "@porter-dev/api-contracts";
 
+type ClientServiceType = "web" | "worker" | "job" | "predeploy";
+
 // serviceValidator is the validator for a ClientService
 // This is used to validate a service when creating or updating an app
 export const serviceValidator = z.object({
@@ -45,6 +47,9 @@ export const serviceValidator = z.object({
       allowConcurrent: serviceBooleanValidator,
       cron: serviceStringValidator,
     }),
+    z.object({
+      type: z.literal("predeploy"),
+    }),
   ]),
 });
 
@@ -76,15 +81,22 @@ export type SerializedService = {
         type: "job";
         allowConcurrent: boolean;
         cron: string;
+      }
+    | {
+        type: "predeploy";
       };
 };
 
+export function isPredeployService(service: SerializedService | ClientService) {
+  return service.config.type == "predeploy";
+}
+
 export function defaultSerialized({
   name,
   type,
 }: {
   name: string;
-  type: "web" | "worker" | "job";
+  type: ClientServiceType;
 }): SerializedService {
   const baseService = {
     name,
@@ -133,6 +145,12 @@ export function defaultSerialized({
         cron: "",
       },
     }))
+    .with("predeploy", () => ({
+      ...baseService,
+      config: {
+        type: "predeploy" as const,
+      },
+    }))
     .exhaustive();
 }
 
@@ -192,6 +210,19 @@ export function serializeService(service: ClientService): SerializedService {
         },
       })
     )
+    .with({ type: "predeploy" }, () =>
+      Object.freeze({
+        name: service.name.value,
+        run: service.run.value,
+        instances: service.instances.value,
+        port: service.port.value,
+        cpuCores: service.cpuCores.value,
+        ramMegabytes: service.ramMegabytes.value,
+        config: {
+          type: "predeploy" as const,
+        },
+      })
+    )
     .exhaustive();
 }
 
@@ -274,17 +305,22 @@ export function deserializeService(
         },
       };
     })
+    .with({ type: "predeploy" }, () => ({
+      ...baseService,
+      config: {
+        type: "predeploy" as const,
+      },
+    }))
     .exhaustive();
 }
 
 // getServiceTypeEnumProto converts the type of a ClientService to the protobuf ServiceType enum
-export const serviceTypeEnumProto = (
-  type: "web" | "worker" | "job"
-): ServiceType => {
+export const serviceTypeEnumProto = (type: ClientServiceType): ServiceType => {
   return match(type)
     .with("web", () => ServiceType.WEB)
     .with("worker", () => ServiceType.WORKER)
     .with("job", () => ServiceType.JOB)
+    .with("predeploy", () => ServiceType.JOB)
     .exhaustive();
 };
 
@@ -334,6 +370,18 @@ export function serviceProto(service: SerializedService): Service {
           },
         })
     )
+    .with(
+      { type: "predeploy" },
+      (config) =>
+        new Service({
+          ...service,
+          type: serviceTypeEnumProto(config.type),
+          config: {
+            value: {},
+            case: "jobConfig",
+          },
+        })
+    )
     .exhaustive();
 }
 
@@ -342,9 +390,11 @@ export function serviceProto(service: SerializedService): Service {
 export function serializedServiceFromProto({
   service,
   name,
+  isPredeploy,
 }: {
   service: Service;
   name: string;
+  isPredeploy?: boolean;
 }): SerializedService {
   const config = service.config;
   if (!config.case) {
@@ -375,7 +425,7 @@ export function serializedServiceFromProto({
       ...service,
       name,
       config: {
-        type: "job" as const,
+        type: isPredeploy ? ("predeploy" as const) : ("job" as const),
         ...value,
       },
     }))

+ 51 - 16
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -11,6 +11,7 @@ import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Link from "components/porter/Link";
+import EnvGroupArrayStacks from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
 
 import { Context } from "shared/Context";
 import {
@@ -28,6 +29,11 @@ import { useQuery } from "@tanstack/react-query";
 import api from "shared/api";
 import { z } from "zod";
 import { PorterApp } from "@porter-dev/api-contracts";
+import {
+  defaultSerialized,
+  deserializeService,
+} from "lib/porter-apps/services";
+import EnvVariables from "../validate-apply/app-settings/EnvVariables";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -137,21 +143,21 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
           })
           .parseAsync(res.data);
         const proto = PorterApp.fromJsonString(atob(data.b64_app_proto));
+
         const { services, predeploy } = defaultServicesWithOverrides({
           overrides: proto,
         });
 
         if (services.length) {
-          setValue("app.services", services);
+          const defaultServices = predeploy
+            ? [...services, predeploy]
+            : services;
+          setValue("app.services", defaultServices);
           setDetectedServices({
             detected: true,
             count: services.length,
           });
         }
-
-        if (predeploy) {
-          setValue("app.predeploy", predeploy);
-        }
       } catch (err) {
         // silent failure for now
       }
@@ -168,14 +174,14 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
     // set step to 2 if source is filled out
     if (source?.type && source.type === "github") {
       if (source.git_repo_name && source.git_branch) {
-        setStep((prev) => Math.max(prev, 3));
+        setStep((prev) => Math.max(prev, 5));
       }
     }
 
     // set step to 3 if source is filled out
     if (source?.type && source.type === "docker-registry") {
       if (image && image.tag) {
-        setStep((prev) => Math.max(prev, 3));
+        setStep((prev) => Math.max(prev, 5));
       }
     }
   }, [
@@ -319,16 +325,45 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
                   />
                 </>,
                 <>
-                  <Button
-                    status={isSubmitting && "loading"}
-                    loadingText={"Deploying..."}
-                    width={"120px"}
-                    disabled={true}
-                  >
-                    Deploy app
-                  </Button>
+                  <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" && (
+                  <>
+                    <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
+                  status={isSubmitting && "loading"}
+                  loadingText={"Deploying..."}
+                  width={"120px"}
+                  disabled={true}
+                >
+                  Deploy app
+                </Button>,
+              ].filter((x) => x)}
             />
           </FormProvider>
           <Spacer y={3} />

+ 43 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx

@@ -0,0 +1,43 @@
+import React, { useCallback } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import { PorterAppFormData } from "lib/porter-apps";
+import EnvGroupArrayStacks, {
+  KeyValueType,
+} from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
+
+const EnvVariables: React.FC = () => {
+  const { control } = useFormContext<PorterAppFormData>();
+
+  const recordToKVType = useCallback((env?: Record<string, string>) => {
+    return Object.entries(env ?? []).map(([key, value]) => {
+      return { key, value, hidden: false, locked: false, deleted: false };
+    });
+  }, []);
+
+  const kvTypeToRecord = useCallback((env: KeyValueType[]) => {
+    return env.reduce((acc, { key, value }) => {
+      acc[key] = value;
+      return acc;
+    }, {} as Record<string, string>);
+  }, []);
+
+  return (
+    <Controller
+      name={`app.env`}
+      control={control}
+      render={({ field: { value, onChange } }) => (
+        <EnvGroupArrayStacks
+          values={recordToKVType(value)}
+          setValues={(x: KeyValueType[]) => {
+            onChange(kvTypeToRecord(x));
+          }}
+          fileUpload={true}
+          syncedEnvGroups={[]}
+        />
+      )}
+    />
+  );
+};
+
+export default EnvVariables;

+ 6 - 1
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -160,12 +160,15 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         />
       ))
       .with({ config: { type: "job" } }, (svc) => (
+        <JobTabs index={index} service={svc} maxCPU={maxCPU} maxRAM={maxRAM} />
+      ))
+      .with({ config: { type: "predeploy" } }, (svc) => (
         <JobTabs
           index={index}
           service={svc}
           maxCPU={maxCPU}
           maxRAM={maxRAM}
-          isPredeploy={isPredeploy}
+          isPredeploy
         />
       ))
       .exhaustive();
@@ -179,6 +182,8 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         return <Icon src={worker} />;
       case "job":
         return <Icon src={job} />;
+      case "predeploy":
+        return <Icon src={job} />;
     }
   };
 

+ 24 - 9
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useMemo, useState } from "react";
 import ServiceContainer from "./ServiceContainer";
 import styled from "styled-components";
 import Spacer from "components/porter/Spacer";
@@ -17,6 +17,7 @@ import {
   ClientService,
   defaultSerialized,
   deserializeService,
+  isPredeployService,
 } from "lib/porter-apps/services";
 import {
   Controller,
@@ -25,7 +26,6 @@ import {
   useFormContext,
 } from "react-hook-form";
 import { ControlledInput } from "components/porter/ControlledInput";
-import { match } from "ts-pattern";
 
 const addServiceFormValidator = z.object({
   name: z
@@ -44,12 +44,14 @@ type ServiceListProps = {
   defaultExpanded?: boolean;
   limitOne?: boolean;
   prePopulateService?: ClientService;
+  isPredeploy?: boolean;
 };
 
 const ServiceList: React.FC<ServiceListProps> = ({
   addNewText,
   limitOne = false,
   prePopulateService,
+  isPredeploy = false,
 }) => {
   // top level app form
   const { control: appControl } = useFormContext<PorterAppFormData>();
@@ -69,7 +71,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
       type: "web",
     },
   });
-  const { append, remove, update, fields: services } = useFieldArray({
+  const { append, remove, update, fields } = useFieldArray({
     control: appControl,
     name: "app.services",
   });
@@ -81,8 +83,21 @@ const ServiceList: React.FC<ServiceListProps> = ({
     false
   );
 
+  const services = useMemo(() => {
+    // if predeploy, only show predeploy services
+    // if not predeploy, only show non-predeploy services
+    return fields.map((svc, idx) => {
+      const predeploy = isPredeployService(svc);
+      return {
+        svc,
+        idx,
+        included: isPredeploy ? predeploy : !predeploy,
+      };
+    });
+  }, [fields]);
+
   const isServiceNameDuplicate = (name: string) => {
-    return services.some((s) => s.name.value === name);
+    return services.some(({ svc: s }) => s.name.value === name);
   };
 
   const maybeRenderAddServicesButton = () => {
@@ -119,16 +134,16 @@ const ServiceList: React.FC<ServiceListProps> = ({
     <>
       {services.length > 0 && (
         <ServicesContainer>
-          {services.map((service, idx) => {
-            return (
+          {services.map(({ svc, idx, included }) => {
+            return included ? (
               <ServiceContainer
                 index={idx}
-                key={service.id}
-                service={service}
+                key={svc.id}
+                service={svc}
                 update={update}
                 remove={remove}
               />
-            );
+            ) : null;
           })}
         </ServicesContainer>
       )}

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

@@ -16,7 +16,7 @@ interface Props {
   index: number;
   service: ClientService & {
     config: {
-      type: "job";
+      type: "job" | "predeploy";
     };
   };
   chart?: any;

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

@@ -7,7 +7,6 @@ import InputSlider from "components/porter/InputSlider";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Checkbox from "components/porter/Checkbox";
 import Text from "components/porter/Text";
-import AnimateHeight from "react-animate-height";
 import { match } from "ts-pattern";
 
 type ResourcesProps = {
@@ -85,6 +84,7 @@ const Resources: React.FC<ResourcesProps> = ({
       />
       {match(service.config)
         .with({ type: "job" }, () => null)
+        .with({ type: "predeploy" }, () => null)
         .otherwise((config) => (
           <>
             <Spacer y={1} />
@@ -136,7 +136,7 @@ const Resources: React.FC<ResourcesProps> = ({
                   label="Min instances"
                   placeholder="ex: 1"
                   disabled={
-                    config.autoscaling.minInstances?.readOnly ??
+                    config.autoscaling?.minInstances?.readOnly ??
                     !config.autoscaling?.enabled.value
                   }
                   width="300px"