Explorar el Código

Stacks release job frontend (#3012)

Feroze Mohideen hace 3 años
padre
commit
460911bc39

+ 101 - 3
api/server/handlers/stacks/create_porter_app.go

@@ -13,7 +13,9 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/stefanmcshane/helm/pkg/chart"
 )
 
@@ -96,7 +98,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		releaseDependencies = helmRelease.Chart.Metadata.Dependencies
 	}
 
-	chart, values, err := parse(
+	chart, values, releaseJobValues, err := parse(
 		porterYaml,
 		imageInfo,
 		c.Config(),
@@ -123,6 +125,31 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
+		// create the release job chart if it does not exist (only done by front-end currently, where we set overrideRelease=true)
+		if request.OverrideRelease && releaseJobValues != nil {
+			conf, err := createReleaseJobChart(
+				stackName,
+				releaseJobValues,
+				c.Config().ServerConf.DefaultApplicationHelmRepoURL,
+				registries,
+				cluster,
+				c.Repo(),
+			)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error making config for release job chart: %w", err)))
+				return
+			}
+			_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating release job chart: %w", err)))
+				_, err = helmAgent.UninstallChart(fmt.Sprintf("%s-r", stackName))
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling release job chart: %w", err)))
+				}
+				return
+			}
+		}
+
 		conf := &helm.InstallChartConfig{
 			Chart:      chart,
 			Name:       stackName,
@@ -133,7 +160,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			Registries: registries,
 		}
 
-		// create the chart
+		// create the app chart
 		_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deploying app: %s", err.Error())))
@@ -181,6 +208,50 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 		c.WriteResult(w, r, porterApp.ToPorterAppType())
 	} else {
+		// create/update the release job chart
+		if request.OverrideRelease && releaseJobValues != nil {
+			releaseJobName := fmt.Sprintf("%s-r", stackName)
+			helmRelease, err := helmAgent.GetRelease(releaseJobName, 0, false)
+			if err != nil {
+				// here the user has created a release job for an already created app, so we need to create and install  the release job chart
+				conf, err := createReleaseJobChart(
+					stackName,
+					releaseJobValues,
+					c.Config().ServerConf.DefaultApplicationHelmRepoURL,
+					registries,
+					cluster,
+					c.Repo(),
+				)
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error making config for release job chart: %w", err)))
+					return
+				}
+				_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating release job chart: %w", err)))
+					_, err = helmAgent.UninstallChart(fmt.Sprintf("%s-r", stackName))
+					if err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling release job chart: %w", err)))
+					}
+					return
+				}
+			} else {
+				conf := &helm.UpgradeReleaseConfig{
+					Name:       helmRelease.Name,
+					Cluster:    cluster,
+					Repo:       c.Repo(),
+					Registries: registries,
+					Values:     releaseJobValues,
+				}
+				_, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error upgrading release job chart: %w", err)))
+					return
+				}
+			}
+		}
+
+		// update the app chart
 		conf := &helm.InstallChartConfig{
 			Chart:      chart,
 			Name:       stackName,
@@ -195,7 +266,6 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		_, err = helmAgent.UpgradeInstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deploying app: %s", err.Error())))
-
 			return
 		}
 
@@ -241,9 +311,37 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 		updatedPorterApp, err := c.Repo().PorterApp().UpdatePorterApp(app)
 		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error writing updated app to DB: %s", err.Error())))
 			return
 		}
 
 		c.WriteResult(w, r, updatedPorterApp.ToPorterAppType())
 	}
 }
+
+func createReleaseJobChart(
+	stackName string,
+	values map[string]interface{},
+	repoUrl string,
+	registries []*models.Registry,
+	cluster *models.Cluster,
+	repo repository.Repository,
+) (*helm.InstallChartConfig, error) {
+	chart, err := loader.LoadChartPublic(repoUrl, "job", "")
+	if err != nil {
+		return nil, err
+	}
+
+	releaseName := fmt.Sprintf("%s-r", stackName)
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+
+	return &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       releaseName,
+		Namespace:  namespace,
+		Values:     values,
+		Cluster:    cluster,
+		Repo:       repo,
+		Registries: registries,
+	}, nil
+}

+ 26 - 5
api/server/handlers/stacks/parse.go

@@ -56,26 +56,32 @@ func parse(
 	existingValues map[string]interface{},
 	existingDependencies []*chart.Dependency,
 	opts SubdomainCreateOpts,
-) (*chart.Chart, map[string]interface{}, error) {
+) (*chart.Chart, map[string]interface{}, map[string]interface{}, error) {
 	parsed := &PorterStackYAML{}
 
 	err := yaml.Unmarshal(porterYaml, parsed)
 	if err != nil {
-		return nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
+		return nil, nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
 	}
 
 	values, err := buildStackValues(parsed, imageInfo, existingValues, opts)
 	if err != nil {
-		return nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
+		return nil, nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 	}
 	convertedValues := convertMap(values).(map[string]interface{})
 
 	chart, err := buildStackChart(parsed, config, projectID, existingDependencies)
 	if err != nil {
-		return nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
+		return nil, nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
 	}
 
-	return chart, convertedValues, nil
+	// return the parsed release values for the release job chart, if they exist
+	var releaseJobValues map[string]interface{}
+	if parsed.Release != nil && parsed.Release.Run != nil {
+		releaseJobValues = buildReleaseValues(parsed.Release, parsed.Env, imageInfo)
+	}
+
+	return chart, convertedValues, releaseJobValues, nil
 }
 
 func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existingValues map[string]interface{}, opts SubdomainCreateOpts) (map[string]interface{}, error) {
@@ -138,6 +144,21 @@ func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existi
 	return values, nil
 }
 
+func buildReleaseValues(release *App, env map[string]string, imageInfo types.ImageInfo) map[string]interface{} {
+	defaultValues := getDefaultValues(release, env, "job")
+	convertedConfig := convertMap(release.Config).(map[string]interface{})
+	helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
+
+	if imageInfo.Repository != "" && imageInfo.Tag != "" {
+		helm_values["image"] = map[string]interface{}{
+			"repository": imageInfo.Repository,
+			"tag":        imageInfo.Tag,
+		}
+	}
+
+	return helm_values
+}
+
 func getType(name string, app *App) string {
 	if app.Type != nil {
 		return *app.Type

+ 96 - 21
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -30,7 +30,7 @@ import RevisionSection from "main/home/cluster-dashboard/expanded-chart/Revision
 import BuildSettingsTabStack from "./BuildSettingsTabStack";
 import Button from "components/porter/Button";
 import Services from "../new-app-flow/Services";
-import { Service } from "../new-app-flow/serviceTypes";
+import { ReleaseService, Service } from "../new-app-flow/serviceTypes";
 import ConfirmOverlay from "components/porter/ConfirmOverlay";
 import Fieldset from "components/porter/Fieldset";
 import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
@@ -87,6 +87,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   >(undefined);
 
   const [services, setServices] = useState<Service[]>([]);
+  const [releaseJob, setReleaseJob] = useState<ReleaseService[]>([]);
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
@@ -119,15 +120,47 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         }
       );
 
+      let releaseChartData;
+      // get the release chart
+      try {
+        releaseChartData = await api.getChart(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: `porter-stack-${appName}`,
+            cluster_id: currentCluster.id,
+            name: `${appName}-r`,
+            revision: 0,
+          }
+        );
+      } catch (err) {
+        // do nothing, unable to find release chart
+        console.log(err);
+      }
+
+      // update apps and release
+      const newAppData = {
+        app: resPorterApp?.data,
+        chart: resChartData?.data,
+      };
+      const porterJson = await fetchPorterYamlContent(
+        "porter.yaml",
+        newAppData
+      );
+
+      setPorterJson(porterJson);
+      setAppData(newAppData);
+      updateServicesAndEnvVariables(resChartData?.data, releaseChartData?.data, porterJson);
+
       // Only check GHA status if no built image is set
-      const hasBuiltImage = !!resChartData.data.config?.global?.image
-        ?.repository;
+      const hasBuiltImage = !!resChartData.data.config?.global?.image?.repository;
       if (hasBuiltImage || !resPorterApp.data.repo_name) {
         setWorkflowCheckPassed(true);
         setHasBuiltImage(true);
       } else {
         try {
-          const resBranchContents = await api.getBranchContents(
+          await api.getBranchContents(
             "<token>",
             {
               dir: `./.github/workflows/porter_stack_${resPorterApp.data.name}.yml`,
@@ -166,17 +199,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           }
         }
       }
-      const newAppData = {
-        app: resPorterApp?.data,
-        chart: resChartData?.data,
-      };
-      const porterJson = await fetchPorterYamlContent(
-        "porter.yaml",
-        newAppData
-      );
-      setPorterJson(porterJson);
-      setAppData(newAppData);
-      updateServicesAndEnvVariables(resChartData?.data, porterJson);
     } catch (err) {
       setError(err);
       console.log(err);
@@ -227,6 +249,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       ) {
         const finalPorterYaml = createFinalPorterYaml(
           services,
+          releaseJob,
           envVars,
           porterJson
         );
@@ -317,8 +340,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
   const updateServicesAndEnvVariables = async (
     currentChart?: ChartType,
+    releaseChart?: ChartType,
     porterJson?: PorterJson
   ) => {
+    // handle normal chart
     const helmValues = currentChart?.config;
     const defaultValues = (currentChart?.chart as any)?.values;
     if (
@@ -340,6 +365,11 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         setSubdomain(subdomain);
       }
     }
+
+    // handle release chart
+    if (releaseChart?.config || porterJson?.release) {
+      setReleaseJob([Service.deserializeRelease(releaseChart?.config, porterJson)]);
+    }
   };
 
   const updateComponents = async (currentChart: ChartType) => {
@@ -498,6 +528,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     <Text color="helper">No services were found.</Text>
                   </Container>
                 </Fieldset>
+                <Spacer y={0.5} />
               </>
             )}
             <Services
@@ -509,10 +540,11 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               }}
               chart={appData.chart}
               services={services}
+              addNewText={"Add a new service"}
             />
             <Spacer y={1} />
             <Button
-              onClick={updatePorterApp}
+              onClick={async () => await updatePorterApp({})}
               status={buttonStatus}
               loadingText={"Updating..."}
               disabled={services.length === 0}
@@ -568,11 +600,54 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         );
       case "pre-deploy":
         return (
-          <JobRuns
-            lastRunStatus="all"
-            namespace={appData.chart?.namespace}
-            sortType="Newest"
-          />
+          <>
+            {!isLoading && releaseJob.length === 0 && (
+              <>
+                <Fieldset>
+                  <Container row>
+                    <PlaceholderIcon src={notFound} />
+                    <Text color="helper">No pre-deploy jobs were found.</Text>
+                  </Container>
+                </Fieldset>
+                <Spacer y={0.5} />
+              </>
+            )}
+            <Services
+              setServices={(x) => {
+                if (buttonStatus !== "") {
+                  setButtonStatus("");
+                }
+                setReleaseJob(x as ReleaseService[]);
+              }}
+              chart={appData.chart}
+              services={releaseJob}
+              limitOne={true}
+              customOnClick={() => {
+                setReleaseJob([Service.default(
+                  "release",
+                  "release",
+                  porterJson
+                ) as ReleaseService]);
+              }}
+              addNewText={"Add a new pre-deploy job"}
+              defaultExpanded={true}
+            />
+            <Button
+              onClick={async () => await updatePorterApp({})}
+              status={buttonStatus}
+              loadingText={"Updating..."}
+              disabled={releaseJob.length === 0}
+            >
+              Update pre-deploy job
+            </Button>
+            <Spacer y={0.5} />
+            {releaseJob.length > 0 && <JobRuns
+              lastRunStatus="all"
+              namespace={appData.chart?.namespace}
+              sortType="Newest"
+            />
+            }
+          </>
         );
       default:
         return <div>Tab not found</div>;

+ 1 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/JobTabs.tsx

@@ -52,7 +52,7 @@ const JobTabs: React.FC<Props> = ({
       <>
         <Spacer y={1} />
         <Input
-          label="CPUs (Mi)"
+          label="CPUs (Millicores)"
           placeholder="ex: 0.5"
           value={service.cpu.value}
           disabled={service.cpu.readOnly}

+ 35 - 19
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -33,7 +33,7 @@ import {
 import Error from "components/porter/Error";
 import { z } from "zod";
 import { PorterJson, PorterYamlSchema, createFinalPorterYaml } from "./schema";
-import { Service } from "./serviceTypes";
+import { ReleaseService, Service } from "./serviceTypes";
 import { Helper } from "components/form-components/Helper";
 import GithubConnectModal from "./GithubConnectModal";
 
@@ -51,6 +51,7 @@ interface FormState {
   applicationName: string;
   selectedSourceType: SourceType | undefined;
   serviceList: Service[];
+  releaseJob: ReleaseService[];
   envVariables: KeyValueType[];
   releaseCommand: string;
 }
@@ -59,6 +60,7 @@ const INITIAL_STATE: FormState = {
   applicationName: "",
   selectedSourceType: undefined,
   serviceList: [],
+  releaseJob: [],
   envVariables: [],
   releaseCommand: "",
 };
@@ -71,6 +73,7 @@ const Validators: {
   serviceList: (value: Service[]) => value.length > 0,
   envVariables: (value: KeyValueType[]) => true,
   releaseCommand: (value: string) => true,
+  releaseJob: (value: ReleaseService[]) => true,
 };
 
 type Detected = {
@@ -158,6 +161,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       const porterYamlToJson = parsedData as PorterJson;
       setPorterJson(porterYamlToJson);
       const newServices = [];
+      const newReleaseJob = [];
       const existingServices = formState.serviceList.map((s) => s.name);
       for (const [name, app] of Object.entries(porterYamlToJson.apps)) {
         if (!existingServices.includes(name)) {
@@ -170,10 +174,14 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
           }
         }
       }
+      if (!formState.releaseJob.length && porterYamlToJson.release != null) {
+        newReleaseJob.push(Service.default("pre-deploy", "release", porterYamlToJson) as ReleaseService);
+      }
       const newServiceList = [...formState.serviceList, ...newServices];
-      setFormState({ ...formState, serviceList: newServiceList });
+      const newReleaseJobList = [...formState.releaseJob, ...newReleaseJob];
+      setFormState({ ...formState, serviceList: newServiceList, releaseJob: newReleaseJobList });
       if (Validators.serviceList(newServiceList)) {
-        setCurrentStep(Math.max(currentStep, 4));
+        setCurrentStep(Math.max(currentStep, 5));
       }
       if (
         porterYamlToJson &&
@@ -183,13 +191,13 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         setDetected({
           detected: true,
           message: `Detected ${Object.keys(porterYamlToJson.apps).length
-            } apps from porter.yaml`,
+            } services from porter.yaml`,
         });
       } else {
         setDetected({
           detected: false,
           message:
-            "Could not detect any apps from porter.yaml. Make sure it exists in the root of your repo.",
+            "Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.",
         });
       }
     } catch (error) {
@@ -291,6 +299,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       // validate form data
       const finalPorterYaml = createFinalPorterYaml(
         formState.serviceList,
+        formState.releaseJob,
         formState.envVariables,
         porterJson
       );
@@ -321,6 +330,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
           dockerfile: buildView === "docker" ? dockerfilePath : "",
           image_repo_uri: imageUrl,
           porter_yaml: base64Encoded,
+          override_release: true,
           ...imageInfo,
         },
         {
@@ -498,11 +508,12 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   setServices={(services: Service[]) => {
                     setFormState({ ...formState, serviceList: services });
                     if (Validators.serviceList(services)) {
-                      setCurrentStep(Math.max(currentStep, 4));
+                      setCurrentStep(Math.max(currentStep, 5));
                     }
                   }}
                   services={formState.serviceList}
                   defaultExpanded={true}
+                  addNewText={"Add a new service"}
                 />
               </>,
               <>
@@ -519,28 +530,33 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   fileUpload={true}
                 />
               </>,
-              /*
               <>
-                <Text size={16}>Release command (optional)</Text>
+                <Text size={16}>Pre-deploy job (optional)</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  If specified, this command will be run before every
+                  If specified, this is a job that will be run before every
                   deployment.
                 </Text>
                 <Spacer y={0.5} />
-                <Input
-                  placeholder="yarn ./scripts/run-migrations.js"
-                  value={formState.releaseCommand}
-                  width="300px"
-                  setValue={(e) => {
-                    setFormState({ ...formState, releaseCommand: e });
-                    if (Validators.releaseCommand(e)) {
-                      setCurrentStep(Math.max(currentStep, 6));
-                    }
+                <Services
+                  setServices={(releaseJob: ReleaseService[]) => {
+                    setFormState({ ...formState, releaseJob });
+                  }}
+                  services={formState.releaseJob}
+                  defaultExpanded={true}
+                  limitOne={true}
+                  customOnClick={() => {
+                    setFormState({
+                      ...formState, releaseJob: [Service.default(
+                        "release",
+                        "release",
+                        porterJson
+                      ) as ReleaseService],
+                    })
                   }}
+                  addNewText={"Add a new pre-deploy job"}
                 />
               </>,
-              */
               <Button
                 onClick={() => {
                   if (imageUrl) {

+ 89 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/ReleaseTabs.tsx

@@ -0,0 +1,89 @@
+import Input from "components/porter/Input";
+import React from "react"
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import { ReleaseService } from "./serviceTypes";
+import { Height } from "react-animate-height";
+
+interface Props {
+    service: ReleaseService;
+    editService: (service: ReleaseService) => void;
+    setHeight: (height: Height) => void;
+}
+
+const ReleaseTabs: React.FC<Props> = ({
+    service,
+    editService,
+    setHeight,
+}) => {
+    const [currentTab, setCurrentTab] = React.useState<string>('main');
+
+    const renderMain = () => {
+        return (
+            <>
+                <Spacer y={1} />
+                <Input
+                    label="Start command"
+                    placeholder="ex: sh start.sh"
+                    disabled={service.startCommand.readOnly}
+                    value={service.startCommand.value}
+                    width="300px"
+                    setValue={(e) => { editService({ ...service, startCommand: { readOnly: false, value: e } }) }}
+                    disabledTooltip={"You may only edit this field in your porter.yaml."}
+                />
+            </>
+        )
+    };
+
+    const renderResources = () => {
+        return (
+            <>
+                <Spacer y={1} />
+                <Input
+                    label="CPUs (Mi)"
+                    placeholder="ex: 0.5"
+                    value={service.cpu.value}
+                    disabled={service.cpu.readOnly}
+                    width="300px"
+                    setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
+                    disabledTooltip={"You may only edit this field in your porter.yaml."}
+                />
+                <Spacer y={1} />
+                <Input
+                    label="RAM (MB)"
+                    placeholder="ex: 1"
+                    value={service.ram.value}
+                    disabled={service.ram.readOnly}
+                    width="300px"
+                    setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
+                    disabledTooltip={"You may only edit this field in your porter.yaml."}
+                />
+            </>
+        )
+    };
+
+
+    return (
+        <>
+            <TabSelector
+                options={[
+                    { label: 'Main', value: 'main' },
+                    { label: 'Resources', value: 'resources' },
+                ]}
+                currentTab={currentTab}
+                setCurrentTab={(value: string) => {
+                    if (value === 'main') {
+                        setHeight(159);
+                    } else if (value === 'resources') {
+                        setHeight(244);
+                    }
+                    setCurrentTab(value);
+                }}
+            />
+            {currentTab === 'main' && renderMain()}
+            {currentTab === 'resources' && renderResources()}
+        </>
+    )
+}
+
+export default ReleaseTabs;

+ 11 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx

@@ -13,6 +13,7 @@ import WorkerTabs from "./WorkerTabs";
 import JobTabs from "./JobTabs";
 import { Service } from "./serviceTypes";
 import StatusFooter from "../expanded-app/StatusFooter";
+import ReleaseTabs from "./ReleaseTabs";
 
 interface ServiceProps {
   service: Service;
@@ -59,6 +60,14 @@ const ServiceContainer: React.FC<ServiceProps> = ({
             setHeight={setHeight}
           />
         );
+      case "release":
+        return (
+          <ReleaseTabs
+            service={service}
+            editService={editService}
+            setHeight={setHeight}
+          />
+        );
     }
   };
 
@@ -70,6 +79,8 @@ const ServiceContainer: React.FC<ServiceProps> = ({
         return <Icon src={worker} />;
       case "job":
         return <Icon src={job} />;
+      case "release":
+        return <Icon src={job} />;
     }
   };
 

+ 52 - 41
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -13,17 +13,18 @@ import web from "assets/web.png";
 import worker from "assets/worker.png";
 import job from "assets/job.png";
 import { Service, ServiceType } from "./serviceTypes";
-import api from "../../../../shared/api";
-import { Context } from "../../../../shared/Context";
 
 interface ServicesProps {
   services: Service[];
   setServices: (services: Service[]) => void;
+  addNewText: string;
   defaultExpanded?: boolean;
-  chart?: any
+  chart?: any;
+  limitOne?: boolean;
+  customOnClick?: () => void;
 }
 
-const Services: React.FC<ServicesProps> = ({ services, setServices, chart, defaultExpanded = false }) => {
+const Services: React.FC<ServicesProps> = ({ services, setServices, addNewText, chart, defaultExpanded = false, limitOne = false, customOnClick }) => {
   const [showAddServiceModal, setShowAddServiceModal] = useState<boolean>(
     false
   );
@@ -39,45 +40,58 @@ const Services: React.FC<ServicesProps> = ({ services, setServices, chart, defau
     return serviceNames.includes(name);
   };
 
+  const maybeRenderAddServicesButton = () => {
+    if (limitOne && services.length > 0) {
+      return null;
+    }
+    return (
+      <>
+        <AddServiceButton
+          onClick={() => {
+            if (customOnClick != null) {
+              customOnClick();
+              return;
+            }
+            setShowAddServiceModal(true);
+            setServiceType("web");
+          }}
+        >
+          <i className="material-icons add-icon">add_icon</i>
+          {addNewText}
+        </AddServiceButton>
+        <Spacer y={0.5} />
+      </>
+    )
+  }
+
   return (
     <>
       {services.length > 0 && (
-        <>
-          <ServicesContainer>
-            {services.map((service, index) => {
-              return (
-                <ServiceContainer
-                  key={service.name}
-                  service={service}
-                  chart={chart}
-                  editService={(newService: Service) =>
-                    setServices(
-                      services.map((s, i) => (i === index ? newService : s))
-                    )
-                  }
-                  deleteService={() =>
-                    setServices(services.filter((_, i) => i !== index))
-                  }
-                  defaultExpanded={defaultExpanded}
-                />
-              );
-            })}
-          </ServicesContainer>
-          <Spacer y={0.5} />
-        </>
+        <ServicesContainer>
+          {services.map((service, index) => {
+            return (
+              <ServiceContainer
+                key={service.name}
+                service={service}
+                chart={chart}
+                editService={(newService: Service) =>
+                  setServices(
+                    services.map((s, i) => (i === index ? newService : s))
+                  )
+                }
+                deleteService={() =>
+                  setServices(services.filter((_, i) => i !== index))
+                }
+                defaultExpanded={defaultExpanded}
+              />
+            );
+          })}
+        </ServicesContainer>
       )}
-      <AddServiceButton
-        onClick={() => {
-          setShowAddServiceModal(true);
-          setServiceType("web");
-        }}
-      >
-        <i className="material-icons add-icon">add_icon</i>
-        Add a new service
-      </AddServiceButton>
+      {maybeRenderAddServicesButton()}
       {showAddServiceModal && (
         <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
-          <Text size={16}>Add a new service</Text>
+          <Text size={16}>{addNewText}</Text>
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
           <Spacer y={0.5} />
@@ -120,10 +134,7 @@ const Services: React.FC<ServicesProps> = ({ services, setServices, chart, defau
             onClick={() => {
               setServices([
                 ...services,
-                Service.default(serviceName, serviceType, {
-                  readOnly: false,
-                  value: "",
-                }),
+                Service.default(serviceName, serviceType),
               ]);
               setShowAddServiceModal(false);
               setServiceName("");

+ 19 - 4
dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx

@@ -1,6 +1,6 @@
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import * as z from "zod";
-import { Service } from "./serviceTypes";
+import { JobService, ReleaseService, Service, WebService, WorkerService } from "./serviceTypes";
 import { overrideObjectValues } from "./utils";
 
 const appConfigSchema = z.object({
@@ -40,18 +40,20 @@ export const PorterYamlSchema = z.object({
     build: BuildSchema.optional(),
     env: EnvSchema.optional(),
     apps: AppsSchema,
-    release: z.string().optional(),
+    release: appConfigSchema.optional(),
 });
 
 export const createFinalPorterYaml = (
     services: Service[],
+    releaseJob: Service[],
     dashboardSetEnvVariables: KeyValueType[],
     porterJson: PorterJson | undefined,
 ): PorterJson => {
     return {
         version: "v1stack",
         env: combineEnv(dashboardSetEnvVariables, porterJson?.env),
-        apps: createApps(services, porterJson),
+        apps: createApps(services.filter(Service.isNonRelease), porterJson),
+        release: createRelease(releaseJob.find(Service.isRelease)),
     };
 };
 
@@ -72,7 +74,7 @@ const combineEnv = (
 };
 
 const createApps = (
-    serviceList: Service[],
+    serviceList: (WorkerService | WebService | JobService)[],
     porterJson: PorterJson | undefined,
 ): z.infer<typeof AppsSchema> => {
     const apps: z.infer<typeof AppsSchema> = {};
@@ -101,4 +103,17 @@ const createApps = (
     return apps;
 };
 
+const createRelease = (
+    release: ReleaseService | undefined,
+): z.infer<typeof appConfigSchema> => {
+    if (release == null) {
+        return {};
+    }
+    return {
+        type: 'job',
+        run: release.startCommand.value,
+        config: Service.serialize(release),
+    }
+}
+
 export type PorterJson = z.infer<typeof PorterYamlSchema>;

+ 66 - 15
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -3,8 +3,8 @@ import { overrideObjectValues } from "./utils";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { PorterJson } from "./schema";
 
-export type Service = WorkerService | WebService | JobService;
-export type ServiceType = 'web' | 'worker' | 'job';
+export type Service = WorkerService | WebService | JobService | ReleaseService;
+export type ServiceType = 'web' | 'worker' | 'job' | 'release';
 
 type ServiceString = {
     readOnly: boolean;
@@ -14,6 +14,19 @@ type ServiceBoolean = {
     readOnly: boolean;
     value: boolean;
 }
+type Ingress = {
+    enabled: ServiceBoolean;
+    hosts: ServiceString;
+    porterHosts: ServiceString;
+}
+
+type Autoscaling = {
+    enabled: ServiceBoolean,
+    minReplicas: ServiceString,
+    maxReplicas: ServiceString,
+    targetCPUUtilizationPercentage: ServiceString,
+    targetMemoryUtilizationPercentage: ServiceString,
+}
 
 const ServiceField = {
     string: (defaultValue: string, overrideValue?: string): ServiceString => {
@@ -238,10 +251,51 @@ const JobService = {
     }
 }
 
+export type ReleaseService = SharedServiceParams & {
+    type: 'release';
+};
+const ReleaseService = {
+    default: (porterJson?: PorterJson): ReleaseService => ({
+        name: 'release',
+        cpu: ServiceField.string('100', porterJson?.release?.config?.resources?.requests?.cpu ? porterJson?.release?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.release?.config?.resources?.requests?.memory ? porterJson?.release?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.release?.run),
+        type: 'release',
+        canDelete: true,
+    }),
+
+    serialize: (service: ReleaseService) => {
+        return {
+            container: {
+                command: service.startCommand.value,
+            },
+            resources: {
+                requests: {
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
+                }
+            },
+        }
+    },
+
+    deserialize: (name: string, values: any, porterJson?: PorterJson): ReleaseService => {
+        return {
+            name,
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', ''), porterJson?.release?.config?.resources?.requests?.cpu ? porterJson?.release?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.release?.config?.resources?.requests?.memory ? porterJson?.release?.config?.resources?.requests?.memory.replace('Mi', '') : undefined),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.release?.run),
+            type: 'release',
+            canDelete: porterJson?.release == null,
+        }
+    }
+}
+
+
 const TYPE_TO_SUFFIX: Record<ServiceType, string> = {
     'web': '-web',
     'worker': '-wkr',
     'job': '-job',
+    'release': '',
 }
 const SUFFIX_TO_TYPE: Record<string, ServiceType> = {
     '-web': 'web',
@@ -259,6 +313,8 @@ export const Service = {
                 return WorkerService.default(name, porterJson);
             case 'job':
                 return JobService.default(name, porterJson);
+            case 'release':
+                return ReleaseService.default(porterJson);
         }
     },
 
@@ -271,6 +327,8 @@ export const Service = {
                 return WorkerService.serialize(service);
             case 'job':
                 return JobService.serialize(service);
+            case 'release':
+                return ReleaseService.serialize(service);
         }
     },
 
@@ -300,11 +358,17 @@ export const Service = {
             }
         }).filter((service: Service | undefined): service is Service => service != null);
     },
+    // TODO: consolidate these
+    deserializeRelease: (helmValues: any, porterJson?: PorterJson): ReleaseService => {
+        return ReleaseService.deserialize('pre-deploy', helmValues, porterJson);
+    },
 
     // standard typeguards
     isWeb: (service: Service): service is WebService => service.type === 'web',
     isWorker: (service: Service): service is WorkerService => service.type === 'worker',
     isJob: (service: Service): service is JobService => service.type === 'job',
+    isRelease: (service: Service): service is ReleaseService => service.type === 'release',
+    isNonRelease: (service: Service): service is Exclude<Service, ReleaseService> => service.type !== 'release',
 
     // required because of https://github.com/helm/helm/issues/9214
     toHelmName: (service: Service): string => {
@@ -361,16 +425,3 @@ export const Service = {
     }
 }
 
-type Ingress = {
-    enabled: ServiceBoolean;
-    hosts: ServiceString;
-    porterHosts: ServiceString;
-}
-
-type Autoscaling = {
-    enabled: ServiceBoolean,
-    minReplicas: ServiceString,
-    maxReplicas: ServiceString,
-    targetCPUUtilizationPercentage: ServiceString,
-    targetMemoryUtilizationPercentage: ServiceString,
-}