Przeglądaj źródła

resolve confl in expandedapp

Justin Rhee 3 lat temu
rodzic
commit
beb08278a7

+ 23 - 15
api/server/handlers/stacks/create.go

@@ -43,29 +43,35 @@ func (c *CreateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	stackName := request.StackName
 	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-	porterYamlBase64 := request.PorterYAMLBase64
-	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
 		return
 	}
 
-	imageInfo := request.ImageInfo
-	chart, values, err := parse(porterYaml, imageInfo, c.Config(), cluster.ProjectID)
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error with test: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
 		return
 	}
 
-	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
 		return
 	}
-
-	k8sAgent, err := c.GetAgent(r, cluster, namespace)
+	imageInfo := request.ImageInfo
+	chart, values, err := parse(porterYaml, imageInfo, c.Config(), cluster.ProjectID, SubdomainCreateOpts{
+		k8sAgent:       k8sAgent,
+		dnsRepo:        c.Repo().DNSRecord(),
+		powerDnsClient: c.Config().PowerDNSClient,
+		appRootDomain:  c.Config().ServerConf.AppRootDomain,
+		stackName:      stackName,
+	})
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing porter yaml into chart and values: %w", err)))
 		return
 	}
 
@@ -94,10 +100,12 @@ func (c *CreateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error installing a new chart: %s", err.Error()),
-			http.StatusBadRequest,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deploying app: %s", err.Error())))
+
+		_, err = helmAgent.UninstallChart(stackName)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error uninstalling chart: %w", err)))
+		}
 
 		return
 	}

+ 124 - 6
api/server/handlers/stacks/parse.go

@@ -7,8 +7,13 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/stefanmcshane/helm/pkg/chart"
+
 	"gopkg.in/yaml.v2"
 )
 
@@ -35,7 +40,15 @@ type App struct {
 	Type   *string                `yaml:"type" validate:"required, oneof=web worker job"`
 }
 
-func parse(porterYaml []byte, imageInfo types.ImageInfo, config *config.Config, projectID uint) (*chart.Chart, map[string]interface{}, error) {
+type SubdomainCreateOpts struct {
+	k8sAgent       *kubernetes.Agent
+	dnsRepo        repository.DNSRecordRepository
+	powerDnsClient *powerdns.Client
+	appRootDomain  string
+	stackName      string
+}
+
+func parse(porterYaml []byte, imageInfo types.ImageInfo, config *config.Config, projectID uint, opts SubdomainCreateOpts) (*chart.Chart, map[string]interface{}, error) {
 	parsed := &PorterStackYAML{}
 
 	err := yaml.Unmarshal(porterYaml, parsed)
@@ -43,27 +56,32 @@ func parse(porterYaml []byte, imageInfo types.ImageInfo, config *config.Config,
 		return nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
 	}
 
-	values, err := buildStackValues(parsed, imageInfo)
+	values, err := buildStackValues(parsed, imageInfo, opts)
 	if err != nil {
 		return nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
 	}
-	convertedValues := convertMap(values)
+	convertedValues := convertMap(values).(map[string]interface{})
 
 	chart, err := buildStackChart(parsed, config, projectID)
 	if err != nil {
 		return nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
 	}
 
-	return chart, convertedValues.(map[string]interface{}), nil
+	return chart, convertedValues, nil
 }
 
-func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo) (map[string]interface{}, error) {
+func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, opts SubdomainCreateOpts) (map[string]interface{}, error) {
 	values := make(map[string]interface{})
 
 	for name, app := range parsed.Apps {
 		appType := getType(name, app)
 		defaultValues := getDefaultValues(app, parsed.Env, appType)
-		helm_values := utils.CoalesceValues(defaultValues, app.Config)
+		convertedConfig := convertMap(app.Config).(map[string]interface{})
+		helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
+		err := createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
+		if err != nil {
+			return nil, err
+		}
 		values[name] = helm_values
 	}
 
@@ -232,3 +250,103 @@ func CopyEnv(env map[string]string) map[string]string {
 
 	return envCopy
 }
+
+func createSubdomainIfRequired(
+	mergedValues map[string]interface{},
+	opts SubdomainCreateOpts,
+) error {
+	// look for ingress.enabled and no custom domains set
+	ingressMap, err := getNestedMap(mergedValues, "ingress")
+	if err == nil {
+		enabledVal, enabledExists := ingressMap["enabled"]
+		customDomVal, customDomExists := ingressMap["custom_domain"]
+
+		if enabledExists && customDomExists {
+			enabled, eOK := enabledVal.(bool)
+			customDomain, cOK := customDomVal.(bool)
+
+			if eOK && cOK && enabled && !customDomain {
+				// in the case of ingress enabled but no custom domain, create subdomain
+				dnsRecord, err := createDNSRecord(opts)
+				if err != nil {
+					return fmt.Errorf("error creating subdomain: %s", err.Error())
+				}
+
+				subdomain := dnsRecord.ExternalURL
+
+				if ingressVal, ok := mergedValues["ingress"]; !ok {
+					mergedValues["ingress"] = map[string]interface{}{
+						"porter_hosts": []string{
+							subdomain,
+						},
+					}
+				} else {
+					ingressValMap := ingressVal.(map[string]interface{})
+
+					ingressValMap["porter_hosts"] = []string{
+						subdomain,
+					}
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func createDNSRecord(opts SubdomainCreateOpts) (*types.DNSRecord, error) {
+	endpoint, found, err := domain.GetNGINXIngressServiceIP(opts.k8sAgent.Clientset)
+	if err != nil {
+		return nil, err
+	}
+	if !found {
+		return nil, fmt.Errorf("target cluster does not have nginx ingress")
+	}
+
+	createDomain := domain.CreateDNSRecordConfig{
+		ReleaseName: opts.stackName,
+		RootDomain:  opts.appRootDomain,
+		Endpoint:    endpoint,
+	}
+
+	record := createDomain.NewDNSRecordForEndpoint()
+
+	record, err = opts.dnsRepo.CreateDNSRecord(record)
+
+	if err != nil {
+		return nil, err
+	}
+
+	_record := domain.DNSRecord(*record)
+
+	err = _record.CreateDomain(opts.powerDnsClient)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return record.ToDNSRecordType(), nil
+}
+
+func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+
+	for _, field := range fields {
+		objField, ok := curr[field]
+
+		if !ok {
+			return nil, fmt.Errorf("%s not found", field)
+		}
+
+		res, ok = objField.(map[string]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	return res, nil
+}

+ 20 - 8
api/server/handlers/stacks/update.go

@@ -43,23 +43,35 @@ func (c *UpdateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	stackName := request.StackName
 	namespace := fmt.Sprintf("porter-stack-%s", stackName)
-	porterYamlBase64 := request.PorterYAMLBase64
-	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
 		return
 	}
 
-	imageInfo := request.ImageInfo
-	chart, values, err := parse(porterYaml, imageInfo, c.Config(), cluster.ProjectID)
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error with test: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
 		return
 	}
 
-	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding porter yaml: %w", err)))
+		return
+	}
+	imageInfo := request.ImageInfo
+	chart, values, err := parse(porterYaml, imageInfo, c.Config(), cluster.ProjectID, SubdomainCreateOpts{
+		k8sAgent:       k8sAgent,
+		dnsRepo:        c.Repo().DNSRecord(),
+		powerDnsClient: c.Config().PowerDNSClient,
+		appRootDomain:  c.Config().ServerConf.AppRootDomain,
+		stackName:      stackName,
+	})
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing porter yaml into chart and values: %w", err)))
 		return
 	}
 

+ 14 - 26
dashboard/src/components/repo-selector/DetectContentsList.tsx

@@ -223,32 +223,20 @@ const DetectContentsList: React.FC<PropsType> = (props) => {
   };
   return (
     <>
-      {renderContentList() &&
-      props.dockerfilePath != "" &&
-      props.dockerfilePath != null ? (
-        <AdvancedBuildSettings
-          dockerfilePath={props.dockerfilePath}
-          setDockerfilePath={props.setDockerfilePath}
-          setBuildConfig={props.setBuildConfig}
-          autoBuildPack={autoBuildpack}
-          showSettings={false}
-          buildView={"buildpacks"}
-          actionConfig={props.actionConfig}
-          branch={props.branch}
-          folderPath={props.folderPath}
-        />
-      ) : (
-        <AdvancedBuildSettings
-          dockerfilePath={props.dockerfilePath}
-          setDockerfilePath={props.setDockerfilePath}
-          setBuildConfig={props.setBuildConfig}
-          autoBuildPack={autoBuildpack}
-          showSettings={false}
-          buildView={"docker"}
-          actionConfig={props.actionConfig}
-          branch={props.branch}
-          folderPath={props.folderPath}
-        />
+      {renderContentList() && (
+        <>
+          <AdvancedBuildSettings
+            dockerfilePath={props.dockerfilePath}
+            setDockerfilePath={props.setDockerfilePath}
+            setBuildConfig={props.setBuildConfig}
+            autoBuildPack={autoBuildpack}
+            showSettings={false}
+            buildView={props.dockerfilePath ? "dockerfile" : "buildpacks"}
+            actionConfig={props.actionConfig}
+            branch={props.branch}
+            folderPath={props.folderPath}
+          />
+        </>
       )}
     </>
   );

+ 27 - 33
dashboard/src/main/home/Home.tsx

@@ -191,7 +191,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -365,7 +365,9 @@ const Home: React.FC<Props> = (props) => {
 
   const { cluster, baseRoute } = props.match.params as any;
   return (
-    <ThemeProvider theme={currentProject?.simplified_view_enabled ? midnight : standard}>
+    <ThemeProvider
+      theme={currentProject?.simplified_view_enabled ? midnight : standard}
+    >
       <StyledHome>
         <ModalHandler setRefreshClusters={setForceRefreshClusters} />
         {currentOverlay &&
@@ -401,27 +403,19 @@ const Home: React.FC<Props> = (props) => {
           />
 
           <Switch>
-            <Route
-              path="/apps/new"
-            >
+            <Route path="/apps/new/app">
               <NewAppFlow />
             </Route>
             <Route path="/apps/:appName">
               <ExpandedApp />
             </Route>
-            <Route
-              path="/apps"
-            >
+            <Route path="/apps">
               <AppDashboard />
             </Route>
-            <Route
-              path="/addons/new"
-            >
+            <Route path="/addons/new">
               <NewAddOnFlow />
             </Route>
-            <Route
-              path="/addons"
-            >
+            <Route path="/addons">
               <AddOnDashboard />
             </Route>
             <Route
@@ -440,17 +434,17 @@ const Home: React.FC<Props> = (props) => {
               overrideInfraTabEnabled({
                 projectID: currentProject?.id,
               })) && (
-                <Route
-                  path="/infrastructure"
-                  render={() => {
-                    return (
-                      <DashboardWrapper>
-                        <InfrastructureRouter />
-                      </DashboardWrapper>
-                    );
-                  }}
-                />
-              )}
+              <Route
+                path="/infrastructure"
+                render={() => {
+                  return (
+                    <DashboardWrapper>
+                      <InfrastructureRouter />
+                    </DashboardWrapper>
+                  );
+                }}
+              />
+            )}
             <Route
               path="/dashboard"
               render={() => {
@@ -519,26 +513,26 @@ const Home: React.FC<Props> = (props) => {
           />,
           document.body
         )}
-        {showWrongEmailModal &&
+        {showWrongEmailModal && (
           <Modal>
             <Text size={16}>
               Oops! This invite link wasn't for {user?.email}
             </Text>
             <Spacer y={1} />
             <Text color="helper">
-              Your account email does not match the email associated with this project invite.
-              Please log out and sign up again with the correct email using the invite link.
+              Your account email does not match the email associated with this
+              project invite. Please log out and sign up again with the correct
+              email using the invite link.
             </Text>
             <Spacer y={1} />
             <Text color="helper">
-              You should reach out to the person who sent you the invite link to get the correct email.
+              You should reach out to the person who sent you the invite link to
+              get the correct email.
             </Text>
             <Spacer y={1} />
-            <Button onClick={props.logOut}>
-              Log out
-            </Button>
+            <Button onClick={props.logOut}>Log out</Button>
           </Modal>
-        }
+        )}
       </StyledHome>
     </ThemeProvider>
   );

+ 60 - 64
dashboard/src/main/home/app-dashboard/AppDashboard.tsx

@@ -28,8 +28,7 @@ import PorterLink from "components/porter/Link";
 import Loading from "components/Loading";
 import Fieldset from "components/porter/Fieldset";
 
-type Props = {
-};
+type Props = {};
 
 const icons = [
   "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
@@ -49,8 +48,7 @@ const namespaceBlacklist = [
   "monitoring",
 ];
 
-const AppDashboard: React.FC<Props> = ({
-}) => {
+const AppDashboard: React.FC<Props> = ({}) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [apps, setApps] = useState([]);
   const [charts, setCharts] = useState([]);
@@ -61,14 +59,10 @@ const AppDashboard: React.FC<Props> = ({
   const [shouldLoadTime, setShouldLoadTime] = useState(true);
 
   const filteredApps = useMemo(() => {
-    const filteredBySearch = search(
-      apps ?? [],
-      searchValue,
-      {
-        keys: ["name"],
-        isCaseSensitive: false,
-      }
-    );
+    const filteredBySearch = search(apps ?? [], searchValue, {
+      keys: ["name"],
+      isCaseSensitive: false,
+    });
 
     return _.sortBy(filteredBySearch);
   }, [apps, searchValue]);
@@ -83,14 +77,14 @@ const AppDashboard: React.FC<Props> = ({
           project_id: currentProject.id,
           cluster_id: currentCluster.id,
         }
-      )
+      );
       const apps = res.data;
       const timeRes = await Promise.all(
         apps.map((app: any) => {
           return api.getCharts(
             "<token>",
             {
-              limit: 1, 
+              limit: 1,
               skip: 0,
               byDate: false,
               statusFilter: [
@@ -108,16 +102,17 @@ const AppDashboard: React.FC<Props> = ({
               cluster_id: currentCluster.id,
               namespace: `porter-stack-${app.name}`,
             }
-          )
+          );
         })
       );
       apps.forEach((app: any, i: number) => {
-        app["last_deployed"] = readableDate(timeRes[i].data[0]?.info?.last_deployed);
+        app["last_deployed"] = readableDate(
+          timeRes[i].data[0]?.info?.last_deployed
+        );
       });
       setApps(apps.reverse());
       setIsLoading(false);
-    }
-    catch (err) {
+    } catch (err) {
       setError(err);
       setIsLoading(false);
     }
@@ -130,7 +125,7 @@ const AppDashboard: React.FC<Props> = ({
   }, [currentCluster, currentProject]);
 
   const renderSource = (app: any) => {
-    return(
+    return (
       <>
         {app.repo_name ? (
           <>
@@ -139,7 +134,11 @@ const AppDashboard: React.FC<Props> = ({
           </>
         ) : (
           <>
-            <SmallIcon opacity="0.7" height="18px" src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
+            <SmallIcon
+              opacity="0.7"
+              height="18px"
+              src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+            />
             {app.image_repo_uri}
           </>
         )}
@@ -169,13 +168,7 @@ const AppDashboard: React.FC<Props> = ({
       }
     }
     return (
-      <>
-        {size === "larger" ? (
-          <MidIcon src={src} />
-        ) : (
-          <Icon src={src} />
-        )}
-      </>
+      <>{size === "larger" ? <MidIcon src={src} /> : <Icon src={src} />}</>
     );
   };
 
@@ -188,7 +181,7 @@ const AppDashboard: React.FC<Props> = ({
         disableLineBreak
       />
       <Container row spaced>
-        <SearchBar 
+        <SearchBar
           value={searchValue}
           setValue={setSearchValue}
           placeholder="Search applications . . ."
@@ -204,14 +197,14 @@ const AppDashboard: React.FC<Props> = ({
           setActive={setView}
         />
         <Spacer inline x={2} />
-        <PorterLink to="/apps/new">
+        <PorterLink to="/apps/new/app">
           <Button onClick={() => {}} height="30px" width="160px">
             <I className="material-icons">add</I> New application
           </Button>
         </PorterLink>
       </Container>
       <Spacer y={1} />
-      {(!isLoading && filteredApps.length === 0) && (
+      {!isLoading && filteredApps.length === 0 && (
         <Fieldset>
           <Container row>
             <PlaceholderIcon src={notFound} />
@@ -219,34 +212,36 @@ const AppDashboard: React.FC<Props> = ({
           </Container>
         </Fieldset>
       )}
-      {isLoading ? <Loading offset="-150px" /> : view === "grid" ? (
+      {isLoading ? (
+        <Loading offset="-150px" />
+      ) : view === "grid" ? (
         <GridList>
-         {(filteredApps ?? []).map((app: any, i: number) => {
-           if (!namespaceBlacklist.includes(app.name)) {
-             return (
-              <Link to={`/apps/${app.name}`} key={i}>
-                <Block>
-                  <Container row>
-                    <Text size={14}>
-                      {renderIcon(app["build_packs"])}
-                      {app.name}
+          {(filteredApps ?? []).map((app: any, i: number) => {
+            if (!namespaceBlacklist.includes(app.name)) {
+              return (
+                <Link to={`/apps/${app.name}`} key={i}>
+                  <Block>
+                    <Container row>
+                      <Text size={14}>
+                        {renderIcon(app["build_packs"])}
+                        {app.name}
+                      </Text>
+                      <Spacer inline x={2} />
+                    </Container>
+                    <StatusIcon src={healthy} />
+                    <Text size={13} color="#ffffff44">
+                      {renderSource(app)}
                     </Text>
-                    <Spacer inline x={2} />
-                  </Container>
-                  <StatusIcon src={healthy} />
-                  <Text size={13} color="#ffffff44">
-                    {renderSource(app)}
-                  </Text>
-                  <Text size={13} color="#ffffff44">
-                    <SmallIcon opacity="0.4" src={time} />
-                    {app.last_deployed}
-                  </Text>
-                </Block>
-              </Link>
-             );
-           }
-         })}
-       </GridList>
+                    <Text size={13} color="#ffffff44">
+                      <SmallIcon opacity="0.4" src={time} />
+                      {app.last_deployed}
+                    </Text>
+                  </Block>
+                </Link>
+              );
+            }
+          })}
+        </GridList>
       ) : (
         <List>
           {(filteredApps ?? []).map((app: any, i: number) => {
@@ -290,8 +285,9 @@ const PlaceholderIcon = styled.img`
 const Row = styled.div<{ isAtBottom?: boolean }>`
   cursor: pointer;
   padding: 15px;
-  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${props => props.theme.clickable.bg};
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;
@@ -327,10 +323,10 @@ const MidIcon = styled.img`
   margin-left: 1px;
 `;
 
-const SmallIcon = styled.img<{ opacity?: string, height?: string }>`
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   margin-left: 2px;
-  height: ${props => props.height || "14px"};
-  opacity: ${props => props.opacity || 1};
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
   filter: grayscale(100%);
   margin-right: 10px;
 `;
@@ -342,10 +338,10 @@ const Block = styled.div`
   justify-content: space-between;
   cursor: pointer;
   padding: 20px;
-  color: ${props => props.theme.text.primary};
+  color: ${(props) => props.theme.text.primary};
   position: relative;
   border-radius: 5px;
-  background: ${props => props.theme.clickable.bg};
+  background: ${(props) => props.theme.clickable.bg};
   border: 1px solid #494b4f;
   :hover {
     border: 1px solid #7a7b80;
@@ -381,4 +377,4 @@ const I = styled.i`
 const StyledAppDashboard = styled.div`
   width: 100%;
   height: 100%;
-`;
+`;

+ 87 - 36
dashboard/src/main/home/app-dashboard/expanded-app/BuildSettingsTabStack.tsx

@@ -35,6 +35,7 @@ import { BuildpackStack } from "components/repo-selector/BuildpackStack";
 import MultiSaveButton from "components/MultiSaveButton";
 import api from "shared/api";
 import { AxiosError } from "axios";
+import InputRow from "components/form-components/InputRow";
 type Props = {
   appData: any;
   setAppData: Dispatch<any>;
@@ -227,7 +228,7 @@ const BuildSettingsTabStack: React.FC<Props> = ({
   return (
     <>
       <Text size={16}>Build settings</Text>
-      <ActionConfEditorStack
+      {/* <ActionConfEditorStack
         actionConfig={actionConfig}
         setActionConfig={(actionConfig: ActionConfigType) => {
           setActionConfig((currentActionConfig: ActionConfigType) => ({
@@ -239,9 +240,15 @@ const BuildSettingsTabStack: React.FC<Props> = ({
         setBranch={setBranch}
         setDockerfilePath={setDockerfilePath}
         setFolderPath={setFolderPath}
+      /> */}
+      <InputRow
+        disabled={true}
+        label="Git repository"
+        type="text"
+        width="100%"
+        value={actionConfig?.git_repo}
       />
-      <DarkMatter antiHeight="-4px" />
-      <br />
+      {/* <DarkMatter antiHeight="-1px" /> */}
       {actionConfig.git_repo && (
         <>
           <ActionConfBranchSelector
@@ -260,16 +267,21 @@ const BuildSettingsTabStack: React.FC<Props> = ({
           />
         </>
       )}
-      <Spacer y={1} />
-      <Text color="helper">Specify your application root path.</Text>
-      <Spacer y={0.5} />
-      <Input
-        disabled={!branch ? true : false}
-        placeholder="ex: ./"
-        value={folderPath}
-        width="100%"
-        setValue={setFolderPath}
-      />
+
+      {actionConfig.git_repo && branch && (
+        <>
+          <Spacer y={1} />
+          <Text color="helper">Specify your application root path.</Text>
+          <Spacer y={0.5} />
+          <Input
+            disabled={!branch ? true : false}
+            placeholder="ex: ./"
+            value={folderPath}
+            width="100%"
+            setValue={setFolderPath}
+          />
+        </>
+      )}
       <StyledAdvancedBuildSettings
         showSettings={showSettings}
         isCurrent={true}
@@ -300,30 +312,22 @@ const BuildSettingsTabStack: React.FC<Props> = ({
           )}
         </StyledSourceBox>
       </AnimateHeight>
+      <Spacer y={0.5} />
 
-      <MultiSaveButton
-        options={[
-          {
-            text: "Save",
-            onClick: handleSave,
-            description:
-              "Save the build settings to be used in the next workflow run",
-          },
-          {
-            text: "Save and Redeploy",
-            onClick: handleSaveAndReDeploy,
-            description:
-              "Immediately trigger a workflow run with updated build settings",
-          },
-        ]}
-        disabled={false}
-        makeFlush={true}
-        clearPosition={true}
-        statusPosition="left"
-        expandTo="left"
-        saveText=""
-        status={buttonStatus}
-      ></MultiSaveButton>
+      <StyledButtonWrapper>
+        <StyledButton
+          data-description="Save the build settings to be used in the next workflow run"
+          onClick={handleSave}
+        >
+          Save
+        </StyledButton>
+        <StyledButton
+          data-description="Immediately trigger a workflow run with updated build settings"
+          onClick={handleSaveAndReDeploy}
+        >
+          Save and Redeploy
+        </StyledButton>
+      </StyledButtonWrapper>
     </>
   );
 };
@@ -385,3 +389,50 @@ const StyledSourceBox = styled.div`
   border-top-left-radius: 0px;
   border-top-right-radius: 0px;
 `;
+
+const StyledButtonWrapper = styled.div`
+  display: flex;
+  gap: 10px;
+`;
+
+const StyledButton = styled.button`
+  background: #3a48ca;
+  border: 1px solid #494b4f;
+  color: #ffffffff;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 8px 12px;
+  position: relative;
+  border-radius: 5px;
+  margin-bottom: 35px;
+  text-align: center;
+  transition: border 0.3s, color 0.3s;
+
+  &:hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+
+  &::after {
+    content: attr(data-description);
+    background-color: #333;
+    border-radius: 4px;
+    bottom: calc(100% + 8px);
+    color: #fff;
+    font-size: 12px;
+    opacity: 0;
+    padding: 8px;
+    position: absolute;
+    left: 60%;
+    top: 100%;
+    transform: translateY(0);
+    white-space: nowrap;
+    pointer-events: none;
+  }
+
+  &:hover::after {
+    opacity: 1;
+    bottom: auto;
+    top: 120%;
+  }
+`;

+ 54 - 27
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -33,7 +33,7 @@ import ConfirmOverlay from "components/porter/ConfirmOverlay";
 import Fieldset from "components/porter/Fieldset";
 import Banner from "components/porter/Banner";
 import AppEvents from "./AppEvents";
-import { createFinalPorterYaml } from "../new-app-flow/schema";
+import { PorterJson, createFinalPorterYaml } from "../new-app-flow/schema";
 import EnvGroupArray, { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { PorterYamlSchema } from "../new-app-flow/schema";
 import GHABanner from "./GHABanner";
@@ -52,7 +52,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
   );
-  const [rawYaml, setRawYaml] = useState<string>("");
   const [isLoading, setIsLoading] = useState(true);
   const [deleting, setDeleting] = useState(false);
   const [appData, setAppData] = useState(null);
@@ -65,7 +64,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
   const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
 
-  const [tab, setTab] = useState("events");
+  const [tab, setTab] = useState("overview");
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
   const [loading, setLoading] = useState<boolean>(false);
   const [components, setComponents] = useState<ResourceType[]>([]);
@@ -86,6 +85,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     setIsLoading(true);
     const { appName } = props.match.params as any;
     try {
+      if (!currentCluster || !currentProject) {
+        return;
+      }
       const resPorterApp = await api.getPorterApp(
         "<token>",
         {},
@@ -106,23 +108,17 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           revision: 0,
         }
       );
-
       const newAppData = {
         app: resPorterApp?.data,
         chart: resChartData?.data,
       };
+      const porterJson = await fetchPorterYamlContent('porter.yaml', newAppData);
+      setPorterJson(porterJson);
       setAppData(newAppData);
-
-      const helmValues = resChartData?.data?.config;
-      const defaultValues = resChartData?.data?.chart?.values;
-      if ((defaultValues && Object.keys(defaultValues).length > 0) || (helmValues && Object.keys(helmValues).length > 0)) {
-        const svcs = Service.deserialize(helmValues, defaultValues);
-        setServices(svcs);
-        console.log(helmValues);
-      }
-      console.log(newAppData);
+      updateServicesAndEnvVariables(resChartData?.data, porterJson);
     } catch (err) {
       setError(err);
+      console.log(err);
     } finally {
       setIsLoading(false);
     }
@@ -169,8 +165,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       ) {
         const finalPorterYaml = createFinalPorterYaml(
           services,
-          [],
-          undefined,
+          envVars,
+          porterJson,
           appData.app.name,
           currentProject.id,
           currentCluster.id,
@@ -202,7 +198,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   }
 
-  const fetchPorterYamlContent = async (porterYaml: string) => {
+  const fetchPorterYamlContent = async (porterYaml: string, appData: any): Promise<PorterJson | undefined> => {
     try {
       const res = await api.getPorterYamlContents(
         "<token>",
@@ -218,13 +214,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           branch: appData.app.git_branch,
         }
       );
-      setRawYaml(atob(res.data));
-      let parsedYaml;
-      parsedYaml = yaml.load(rawYaml);
+      if (res.data == null || res.data == "") {
+        return undefined;
+      }
+      const parsedYaml = yaml.load(atob(res.data));
       const parsedData = PorterYamlSchema.parse(parsedYaml);
-      const porterYamlToJson = parsedData as z.infer<typeof PorterYamlSchema>;
-      setPorterJson(porterYamlToJson);
-      console.log(porterJson);
+      const porterYamlToJson = parsedData as PorterJson;
+      return porterYamlToJson;
     } catch (err) {
       console.log(err);
     }
@@ -254,6 +250,19 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     return <Icon src={src} />;
   };
 
+  const updateServicesAndEnvVariables = async (currentChart?: ChartType, porterJson?: PorterJson) => {
+    const helmValues = currentChart?.config;
+    const defaultValues = currentChart?.chart?.values;
+    if ((defaultValues && Object.keys(defaultValues).length > 0) || (helmValues && Object.keys(helmValues).length > 0)) {
+      const svcs = Service.deserialize(helmValues, defaultValues, porterJson);
+      setServices(svcs);
+      if (helmValues && Object.keys(helmValues).length > 0) {
+        const envs = Service.retrieveEnvFromHelmValues(helmValues);
+        setEnvVars(envs)
+      }
+    }
+  }
+
   const updateComponents = async (currentChart: ChartType) => {
     setLoading(true);
     try {
@@ -269,6 +278,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         }
       );
       setComponents(res.data.Objects);
+      updateServicesAndEnvVariables(currentChart, porterJson);
       setLoading(false);
     } catch (error) {
       console.log(error);
@@ -305,7 +315,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
     const updatedChart = res.data;
 
-    setAppData({ chart: updatedChart });
+    if (appData != null && updatedChart != null) {
+      setAppData({ ...appData, chart: updatedChart });
+    }
 
     updateComponents(updatedChart).finally(() => setIsLoadingChartData(false));
   };
@@ -321,6 +333,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     // setIsPreview(!isCurrent);
     getChartData(chart);
   };
+
   const appUpgradeVersion = useCallback(
     async (version: string, cb: () => void) => {
       // convert current values to yaml
@@ -398,6 +411,17 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "overview":
         return (
           <>
+            {(!isLoading && services.length === 0) && (
+              <>
+                <Fieldset>
+                  <Container row>
+                    <PlaceholderIcon src={notFound} />
+                    <Text color="helper">No services were found.</Text>
+                  </Container>
+                </Fieldset>
+                <Spacer y={0.5} />
+              </>
+            )}
             <Services
               setServices={setServices}
               services={services}
@@ -411,6 +435,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                 <Error message={updateError} />
               ) : undefined}
               loadingText={"Updating..."}
+              disabled={services.length === 0}
             >
               Update app
             </Button>
@@ -461,7 +486,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             </Text>
             <EnvGroupArray
               values={envVars}
-              setValues={setEnvVars}
+              setValues={(x: any) => setEnvVars(x)}
               fileUpload={true}
             />
             <Spacer y={0.5} />
@@ -500,7 +525,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           <Link to="/apps">Return to dashboard</Link>
         </Placeholder>
       )}
-      {appData && (
+      {appData && appData.app && (
         <StyledExpandedApp>
           <Back to="/apps" />
           <Container row>
@@ -592,16 +617,17 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               )}
               <Spacer y={1} />
               <TabSelector
-                options={
-                  appData.app.git_repo_id ? (workflowCheckPassed ? [
+                options={appData.app.git_repo_id ? (workflowCheckPassed ? [
                     { label: "Events", value: "events" },
                     { label: "Logs", value: "logs" },
                     { label: "Metrics", value: "metrics" },
                     { label: "Overview", value: "overview" },
+                    { label: "Environment variables", value: "environment-variables" },
                     { label: "Build settings", value: "build-settings" },
                     { label: "Settings", value: "settings" },
                   ] : [
                     { label: "Overview", value: "overview" },
+                    { label: "Environment variables", value: "environment-variables" },
                     { label: "Build settings", value: "build-settings" },
                     { label: "Settings", value: "settings" },
                   ]) : [
@@ -609,6 +635,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     { label: "Logs", value: "logs" },
                     { label: "Metrics", value: "metrics" },
                     { label: "Overview", value: "overview" },
+                    { label: "Environment variables", value: "environment-variables" },
                     { label: "Settings", value: "settings" },
                   ]
                 }

+ 25 - 22
dashboard/src/main/home/app-dashboard/expanded-app/SharedBuildSettings.tsx

@@ -89,29 +89,32 @@ const SharedBuildSettings: React.FC<Props> = ({
           />
         </>
       )}
-      <Spacer y={1} />
-      <Text color="helper">Specify your application root path.</Text>
-      <Spacer y={0.5} />
-      <Input
-        disabled={!branch ? true : false}
-        placeholder="ex: ./"
-        value={folderPath}
-        width="100%"
-        setValue={setFolderPath}
-      />
+
       {actionConfig.git_repo && branch && (
-        <DetectContentsList
-          actionConfig={actionConfig}
-          branch={branch}
-          dockerfilePath={dockerfilePath}
-          folderPath={folderPath}
-          setActionConfig={setActionConfig}
-          setDockerfilePath={setDockerfilePath}
-          setFolderPath={setFolderPath}
-          setBuildConfig={setBuildConfig}
-          porterYaml={porterYaml}
-          setPorterYaml={setPorterYaml}
-        />
+        <>
+          <Spacer y={1} />
+          <Text color="helper">Specify your application root path.</Text>
+          <Spacer y={0.5} />
+          <Input
+            disabled={!branch ? true : false}
+            placeholder="ex: ./"
+            value={folderPath}
+            width="100%"
+            setValue={setFolderPath}
+          />
+          <DetectContentsList
+            actionConfig={actionConfig}
+            branch={branch}
+            dockerfilePath={dockerfilePath}
+            folderPath={folderPath}
+            setActionConfig={setActionConfig}
+            setDockerfilePath={setDockerfilePath}
+            setFolderPath={setFolderPath}
+            setBuildConfig={setBuildConfig}
+            porterYaml={porterYaml}
+            setPorterYaml={setPorterYaml}
+          />
+        </>
       )}
     </>
   );

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

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
@@ -43,6 +43,13 @@ const AdvancedBuildSettings: React.FC<AdvancedBuildSettingsProps> = (props) => {
   const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
     setBuildView(e.target.value);
   };
+  useEffect(() => {
+    if (props.dockerfilePath && props.dockerfilePath != "") {
+      setBuildView("docker");
+    } else {
+      setBuildView("buildpacks");
+    }
+  }, [props.dockerfilePath]);
   const createDockerView = () => {
     return (
       <>

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

@@ -36,9 +36,10 @@ const JobTabs: React.FC<Props> = ({
         <Input
           label="Cron schedule (leave blank to run manually)"
           placeholder="ex: */5 * * * *"
-          value={service.cronSchedule}
+          value={service.cronSchedule.value}
+          disabled={service.cronSchedule.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cronSchedule: e }) }}
+          setValue={(e) => { editService({ ...service, cronSchedule: { readOnly: false, value: e } }) }}
         />
       </>
     )
@@ -49,19 +50,21 @@ const JobTabs: React.FC<Props> = ({
       <>
         <Spacer y={1} />
         <Input
-          label="CPUs"
+          label="CPUs (Mi)"
           placeholder="ex: 0.5"
-          value={service.cpu}
+          value={service.cpu.value}
+          disabled={service.cpu.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cpu: e }) }}
+          setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="RAM (MB)"
           placeholder="ex: 1"
-          value={service.ram}
+          value={service.ram.value}
+          disabled={service.ram.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, ram: e }) }}
+          setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
         />
       </>
     )
@@ -72,8 +75,8 @@ const JobTabs: React.FC<Props> = ({
       <>
         <Spacer y={1} />
         <Checkbox
-          checked={service.jobsExecuteConcurrently}
-          toggleChecked={() => { editService({ ...service, jobsExecuteConcurrently: !service.jobsExecuteConcurrently }) }}
+          checked={service.jobsExecuteConcurrently.value}
+          toggleChecked={() => { editService({ ...service, jobsExecuteConcurrently: { readOnly: false, value: !service.jobsExecuteConcurrently.value } }) }}
         >
           <Text color="helper">Allow jobs to execute concurrently</Text>
         </Checkbox>

+ 31 - 39
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -25,7 +25,7 @@ import GithubActionModal from "./GithubActionModal";
 import { GithubActionConfigType } from "shared/types";
 import Error from "components/porter/Error";
 import { z } from "zod";
-import { PorterYamlSchema, createFinalPorterYaml } from "./schema";
+import { PorterJson, PorterYamlSchema, createFinalPorterYaml } from "./schema";
 import { Service } from "./serviceTypes";
 
 type Props = RouteComponentProps & {};
@@ -92,9 +92,9 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const [buildConfig, setBuildConfig] = useState({});
   const [porterYaml, setPorterYaml] = useState("");
   const [showGHAModal, setShowGHAModal] = useState<boolean>(false);
-  const [porterJson, setPorterJson] = useState<
-    z.infer<typeof PorterYamlSchema> | undefined
-  >(undefined);
+  const [porterJson, setPorterJson] = useState<PorterJson | undefined>(
+    undefined
+  );
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
 
   const validatePorterYaml = (yamlString: string) => {
@@ -102,31 +102,18 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
     try {
       parsedYaml = yaml.load(yamlString);
       const parsedData = PorterYamlSchema.parse(parsedYaml);
-      const porterYamlToJson = parsedData as z.infer<typeof PorterYamlSchema>;
+      const porterYamlToJson = parsedData as PorterJson;
       setPorterJson(porterYamlToJson);
-      // go through key value pairs and create services from them, if they don't already exist
       const newServices = [];
       const existingServices = formState.serviceList.map((s) => s.name);
       for (const [name, app] of Object.entries(porterYamlToJson.apps)) {
         if (!existingServices.includes(name)) {
           if (app.type) {
-            newServices.push(
-              Service.default(name, app.type, {
-                readOnly: true,
-                value: app.run,
-              })
-            );
+            newServices.push(Service.default(name, app.type, porterYamlToJson));
           } else if (name.includes("web")) {
-            newServices.push(
-              Service.default(name, "web", { readOnly: true, value: app.run })
-            );
+            newServices.push(Service.default(name, "web", porterYamlToJson));
           } else {
-            newServices.push(
-              Service.default(name, "worker", {
-                readOnly: true,
-                value: app.run,
-              })
-            );
+            newServices.push(Service.default(name, "worker", porterYamlToJson));
           }
         }
       }
@@ -160,10 +147,9 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
 
   // Deploys a Helm chart and writes build settings to the DB
   const isAppNameValid = (name: string) => {
-    const regex = /^[a-z0-9-]+$/;
+    const regex = /^[a-z0-9-]{1,61}$/;
     return regex.test(name);
   };
-
   const handleAppNameChange = (name: string) => {
     setCurrentStep(currentStep);
     setFormState({ ...formState, applicationName: name });
@@ -178,7 +164,8 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const shouldHighlightAppNameInput = () => {
     return (
       formState.applicationName !== "" &&
-      !isAppNameValid(formState.applicationName)
+      (!isAppNameValid(formState.applicationName) ||
+        formState.applicationName.length > 61)
     );
   };
 
@@ -204,6 +191,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         currentProject.id,
         currentCluster.id
       );
+
       const yamlString = yaml.dump(finalPorterYaml);
       const base64Encoded = btoa(yamlString);
       const imageInfo = imageUrl
@@ -215,7 +203,21 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
           }
         : {};
 
-      // write to the db
+      // create the dummy chart
+      await api.createPorterStack(
+        "<token>",
+        {
+          stack_name: formState.applicationName,
+          porter_yaml: base64Encoded,
+          ...imageInfo,
+        },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      );
+
+      // if success, write to the db
       await api.createPorterApp(
         "<token>",
         {
@@ -235,18 +237,6 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         }
       );
 
-      await api.createPorterStack(
-        "<token>",
-        {
-          stack_name: formState.applicationName,
-          porter_yaml: base64Encoded,
-          ...imageInfo,
-        },
-        {
-          cluster_id: currentCluster.id,
-          project_id: currentProject.id,
-        }
-      );
       if (!actionConfig?.git_repo) {
         props.history.push(`/apps/${formState.applicationName}`);
       }
@@ -287,19 +277,21 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 <Text color="helper">
                   Lowercase letters, numbers, and "-" only.
                 </Text>
-                <Spacer y={0.5}></Spacer>
                 <Input
                   placeholder="ex: academic-sophon"
                   value={formState.applicationName}
                   width="300px"
                   error={
                     shouldHighlightAppNameInput() &&
-                    'Lowercase letters, numbers, and "-" only.'
+                    (formState.applicationName.length > 61
+                      ? "Maximum 61 characters allowed."
+                      : 'Lowercase letters, numbers, and "-" only.')
                   }
                   setValue={(e) => {
                     handleAppNameChange(e);
                   }}
                 />
+
                 {shouldHighlightAppNameInput()}
               </>,
               <>

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

@@ -62,11 +62,11 @@ const ServiceContainer: React.FC<ServiceProps> = ({
           {renderIcon(service)}
           {service.name.trim().length > 0 ? service.name : "New Service"}
         </ServiceTitle>
-        <ActionButton onClick={(e) => {
+        {service.canDelete && <ActionButton onClick={(e) => {
           deleteService();
         }}>
           <span className="material-icons">delete</span>
-        </ActionButton>
+        </ActionButton>}
       </ServiceHeader>
       <AnimateHeight
         height={showExpanded ? height : 0}

+ 13 - 6
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -60,10 +60,12 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
           <Spacer y={0.5} />
         </>
       )}
-      <AddServiceButton onClick={() => {
-        setShowAddServiceModal(true);
-        setServiceType("web");
-      }}>
+      <AddServiceButton
+        onClick={() => {
+          setShowAddServiceModal(true);
+          setServiceType("web");
+        }}
+      >
         <i className="material-icons add-icon">add_icon</i>
         Add a new service
       </AddServiceButton>
@@ -101,6 +103,7 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
               (serviceName != "" &&
                 !isServiceNameValid(serviceName) &&
                 'Lowercase letters, numbers, and "-" only.') ||
+              (serviceName.length > 61 && "Must be 61 characters or less.") ||
               (isServiceNameDuplicate(serviceName) &&
                 "Service name is duplicate")
             }
@@ -111,7 +114,10 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
             onClick={() => {
               setServices([
                 ...services,
-                Service.default(serviceName, serviceType, { readOnly: false, value: '' }),
+                Service.default(serviceName, serviceType, {
+                  readOnly: false,
+                  value: "",
+                }),
               ]);
               setShowAddServiceModal(false);
               setServiceName("");
@@ -119,7 +125,8 @@ const Services: React.FC<ServicesProps> = ({ services, setServices }) => {
             }}
             disabled={
               !isServiceNameValid(serviceName) ||
-              isServiceNameDuplicate(serviceName)
+              isServiceNameDuplicate(serviceName) ||
+              serviceName?.length > 61
             }
           >
             <I className="material-icons">add</I> Add service

+ 32 - 22
dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx

@@ -36,14 +36,16 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="Container port"
           placeholder="ex: 80"
-          value={service.port}
+          value={service.port.value}
+          disabled={service.port.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, port: e }) }}
+          setValue={(e) => { editService({ ...service, port: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Checkbox
-          checked={service.generateUrlForExternalTraffic}
-          toggleChecked={() => { editService({ ...service, generateUrlForExternalTraffic: !service.generateUrlForExternalTraffic }) }}
+          checked={service.generateUrlForExternalTraffic.value}
+          // disabled={service.generateUrlForExternalTraffic.readOnly}
+          toggleChecked={() => { editService({ ...service, generateUrlForExternalTraffic: { readOnly: false, value: !service.generateUrlForExternalTraffic.value } }) }}
         >
           <Text color="helper">Generate a Porter URL for external traffic</Text>
         </Checkbox>
@@ -58,30 +60,33 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="CPUs"
           placeholder="ex: 0.5"
-          value={service.cpu}
+          value={service.cpu.value}
+          disabled={service.cpu.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cpu: e }) }}
+          setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="RAM (MB)"
           placeholder="ex: 1"
-          value={service.ram}
+          value={service.ram.value}
+          disabled={service.ram.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, ram: e }) }}
+          setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Replicas"
           placeholder="ex: 1"
-          value={service.replicas}
+          value={service.replicas.value}
+          disabled={service.replicas.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, replicas: e }) }}
+          setValue={(e) => { editService({ ...service, replicas: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Checkbox
-          checked={service.autoscalingOn}
-          toggleChecked={() => { editService({ ...service, autoscalingOn: !service.autoscalingOn }) }}
+          checked={service.autoscalingOn.value}
+          toggleChecked={() => { editService({ ...service, autoscalingOn: { readOnly: false, value: !service.autoscalingOn.value } }) }}
         >
           <Text color="helper">Enable autoscaling (overrides replicas)</Text>
         </Checkbox>
@@ -89,33 +94,37 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="Min replicas"
           placeholder="ex: 1"
-          value={service.minReplicas}
+          value={service.minReplicas.value}
+          disabled={service.minReplicas.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, minReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, minReplicas: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Max replicas"
           placeholder="ex: 10"
-          value={service.maxReplicas}
+          value={service.maxReplicas.value}
+          disabled={service.maxReplicas.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, maxReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, maxReplicas: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Target CPU utilization (%)"
           placeholder="ex: 50"
-          value={service.targetCPUUtilizationPercentage}
+          value={service.targetCPUUtilizationPercentage.value}
+          disabled={service.targetCPUUtilizationPercentage.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Target RAM utilization (%)"
           placeholder="ex: 50"
-          value={service.targetRAMUtilizationPercentage}
+          value={service.targetRAMUtilizationPercentage.value}
+          disabled={service.targetRAMUtilizationPercentage.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: { readOnly: false, value: e } }) }}
         />
       </>
     )
@@ -128,9 +137,10 @@ const WebTabs: React.FC<Props> = ({
         <Input
           label="Custom domain"
           placeholder="ex: my-app.my-domain.com"
-          value={service.customDomain ?? ''}
+          value={service.customDomain.value}
+          disabled={service.customDomain.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, customDomain: e }) }}
+          setValue={(e) => { editService({ ...service, customDomain: { readOnly: false, value: e } }) }}
         />
       </>
     );

+ 23 - 25
dashboard/src/main/home/app-dashboard/new-app-flow/WorkerTabs.tsx

@@ -43,30 +43,33 @@ const WorkerTabs: React.FC<Props> = ({
         <Input
           label="CPUs"
           placeholder="ex: 0.5"
-          value={service.cpu}
+          value={service.cpu.value}
+          disabled={service.cpu.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, cpu: e }) }}
+          setValue={(e) => { editService({ ...service, cpu: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="RAM (MB)"
           placeholder="ex: 1"
-          value={service.ram}
+          value={service.ram.value}
+          disabled={service.ram.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, ram: e }) }}
+          setValue={(e) => { editService({ ...service, ram: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Replicas"
           placeholder="ex: 1"
-          value={service.replicas}
+          value={service.replicas.value}
+          disabled={service.replicas.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, replicas: e }) }}
+          setValue={(e) => { editService({ ...service, replicas: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Checkbox
-          checked={service.autoscalingOn}
-          toggleChecked={() => { editService({ ...service, autoscalingOn: !service.autoscalingOn }) }}
+          checked={service.autoscalingOn.value}
+          toggleChecked={() => { editService({ ...service, autoscalingOn: { readOnly: false, value: !service.autoscalingOn.value } }) }}
         >
           <Text color="helper">Enable autoscaling (overrides replicas)</Text>
         </Checkbox>
@@ -74,52 +77,48 @@ const WorkerTabs: React.FC<Props> = ({
         <Input
           label="Min replicas"
           placeholder="ex: 1"
-          value={service.minReplicas}
+          value={service.minReplicas.value}
+          disabled={service.minReplicas.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, minReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, minReplicas: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Max replicas"
           placeholder="ex: 10"
-          value={service.maxReplicas}
+          value={service.maxReplicas.value}
+          disabled={service.maxReplicas.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, maxReplicas: e }) }}
+          setValue={(e) => { editService({ ...service, maxReplicas: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Target CPU utilization (%)"
           placeholder="ex: 50"
-          value={service.targetCPUUtilizationPercentage}
+          value={service.targetCPUUtilizationPercentage.value}
+          disabled={service.targetCPUUtilizationPercentage.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetCPUUtilizationPercentage: { readOnly: false, value: e } }) }}
         />
         <Spacer y={1} />
         <Input
           label="Target RAM utilization (%)"
           placeholder="ex: 50"
-          value={service.targetRAMUtilizationPercentage}
+          value={service.targetRAMUtilizationPercentage.value}
+          disabled={service.targetRAMUtilizationPercentage.readOnly}
           width="300px"
-          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: e }) }}
+          setValue={(e) => { editService({ ...service, targetRAMUtilizationPercentage: { readOnly: false, value: e } }) }}
         />
       </>
     )
   };
 
-  const renderAdvanced = () => {
-    return (
-      <>
-      </>
-    );
-  };
-
   return (
     <>
       <TabSelector
         options={[
           { label: 'Main', value: 'main' },
           { label: 'Resources', value: 'resources' },
-          // { label: 'Advanced', value: 'advanced' },
         ]}
         currentTab={currentTab}
         setCurrentTab={(value: string) => {
@@ -133,7 +132,6 @@ const WorkerTabs: React.FC<Props> = ({
       />
       {currentTab === 'main' && renderMain()}
       {currentTab === 'resources' && renderResources()}
-      {/* currentTab === 'advanced' && renderAdvanced() */}
     </>
   )
 }

+ 5 - 3
dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx

@@ -46,11 +46,11 @@ export const PorterYamlSchema = z.object({
 export const createFinalPorterYaml = (
     services: Service[],
     dashboardSetEnvVariables: KeyValueType[],
-    porterJson: z.infer<typeof PorterYamlSchema> | undefined,
+    porterJson: PorterJson | undefined,
     stackName: string,
     projectId: number,
     clusterId: number,
-): z.infer<typeof PorterYamlSchema> => {
+): PorterJson => {
     return {
         version: "v1stack",
         env: combineEnv(dashboardSetEnvVariables, porterJson?.env),
@@ -76,7 +76,7 @@ const combineEnv = (
 
 const createApps = (
     serviceList: Service[],
-    porterJson: z.infer<typeof PorterYamlSchema> | undefined,
+    porterJson: PorterJson | undefined,
     stackName: string,
     projectId: number,
     clusterId: number,
@@ -117,3 +117,5 @@ const createApps = (
 
     return apps;
 };
+
+export type PorterJson = z.infer<typeof PorterYamlSchema>;

+ 182 - 133
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -1,211 +1,230 @@
 import _ from "lodash";
-import api from "shared/api";
-import { ChartType } from "shared/types";
 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';
 
-type ServiceReadOnlyField = {
+type ServiceString = {
     readOnly: boolean;
     value: string;
 }
+type ServiceBoolean = {
+    readOnly: boolean;
+    value: boolean;
+}
+
+const ServiceField = {
+    string: (defaultValue: string, overrideValue?: string): ServiceString => {
+        return {
+            readOnly: overrideValue != null,
+            value: overrideValue ?? defaultValue,
+        }
+    },
+    boolean: (defaultValue: boolean, overrideValue?: boolean): ServiceBoolean => {
+        return {
+            readOnly: overrideValue != null,
+            value: overrideValue ?? defaultValue,
+        }
+    },
+}
 
 type SharedServiceParams = {
     name: string;
-    cpu: string;
-    ram: string;
-    startCommand: ServiceReadOnlyField;
+    cpu: ServiceString;
+    ram: ServiceString;
+    startCommand: ServiceString;
     type: ServiceType;
+    canDelete: boolean;
 }
 
 export type WorkerService = SharedServiceParams & {
     type: 'worker';
-    replicas: string;
-    autoscalingOn: boolean;
-    minReplicas: string;
-    maxReplicas: string;
-    targetCPUUtilizationPercentage: string;
-    targetRAMUtilizationPercentage: string;
+    replicas: ServiceString;
+    autoscalingOn: ServiceBoolean;
+    minReplicas: ServiceString;
+    maxReplicas: ServiceString;
+    targetCPUUtilizationPercentage: ServiceString;
+    targetRAMUtilizationPercentage: ServiceString;
 }
 const WorkerService = {
-    default: (name: string, startCommand: ServiceReadOnlyField): WorkerService => ({
+    default: (name: string, porterJson?: PorterJson): WorkerService => ({
         name,
-        cpu: '100',
-        ram: '256',
-        startCommand: startCommand,
+        cpu: ServiceField.string('100', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.apps?.[name]?.config?.resources?.requests?.ram ? porterJson?.apps?.[name]?.config?.resources?.requests?.ram.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'worker',
-        replicas: '1',
-        autoscalingOn: false,
-        minReplicas: '1',
-        maxReplicas: '10',
-        targetCPUUtilizationPercentage: '50',
-        targetRAMUtilizationPercentage: '50',
+        replicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.replicaCount),
+        autoscalingOn: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+        minReplicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+        maxReplicas: ServiceField.string('10', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+        targetCPUUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+        targetRAMUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+        canDelete: porterJson?.apps?.[name] == null,
     }),
     serialize: (service: WorkerService) => {
-        const autoscaling = service.autoscalingOn ? {
+        const autoscaling = service.autoscalingOn.value ? {
             autoscaling: {
                 enabled: true,
-                minReplicas: service.minReplicas,
-                maxReplicas: service.maxReplicas,
-                targetCPUUtilizationPercentage: service.targetCPUUtilizationPercentage,
-                targetMemoryUtilizationPercentage: service.targetRAMUtilizationPercentage,
+                minReplicas: service.minReplicas.value,
+                maxReplicas: service.maxReplicas.value,
+                targetCPUUtilizationPercentage: service.targetCPUUtilizationPercentage.value,
+                targetMemoryUtilizationPercentage: service.targetRAMUtilizationPercentage.value,
             }
         } : {};
         return {
-            replicaCount: service.replicas,
+            replicaCount: service.replicas.value,
             container: {
                 command: service.startCommand.value,
             },
             resources: {
                 requests: {
-                    cpu: service.cpu + 'm',
-                    memory: service.ram + 'Mi',
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
                 }
             },
             ...autoscaling,
         }
     },
-    deserialize: (name: string, values: any): WorkerService => {
+    deserialize: (name: string, values: any, porterJson?: PorterJson): WorkerService => {
         return {
             name,
-            cpu: values.resources?.requests?.cpu?.replace('m', '') ?? '',
-            ram: values.resources?.requests?.memory?.replace('Mi', '') ?? '',
-            startCommand: {
-                readOnly: false,
-                value: values.container?.command ?? '',
-            },
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', ''), porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.ram ? porterJson?.apps?.[name]?.config?.resources?.requests?.ram.replace('Mi', '') : undefined),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.apps?.[name]?.run),
             type: 'worker',
-            replicas: values.replicaCount ?? '',
-            autoscalingOn: values.autoscaling?.enabled ?? false,
-            minReplicas: values.autoscaling?.minReplicas ?? '',
-            maxReplicas: values.autoscaling?.maxReplicas ?? '',
-            targetCPUUtilizationPercentage: values.autoscaling?.targetCPUUtilizationPercentage ?? '',
-            targetRAMUtilizationPercentage: values.autoscaling?.targetMemoryUtilizationPercentage ?? '',
+            replicas: ServiceField.string(values.replicaCount ?? '', porterJson?.apps?.[name]?.config?.replicaCount),
+            autoscalingOn: ServiceField.boolean(values.autoscaling?.enabled ?? false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+            minReplicas: ServiceField.string(values.autoscaling?.minReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+            maxReplicas: ServiceField.string(values.autoscaling?.maxReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+            targetCPUUtilizationPercentage: ServiceField.string(values.autoscaling?.targetCPUUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+            targetRAMUtilizationPercentage: ServiceField.string(values.autoscaling?.targetMemoryUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+            canDelete: porterJson?.apps?.[name] == null,
         }
     }
 }
 
 export type WebService = SharedServiceParams & Omit<WorkerService, 'type'> & {
     type: 'web';
-    port: string;
-    generateUrlForExternalTraffic: boolean;
-    customDomain: string;
+    port: ServiceString;
+    generateUrlForExternalTraffic: ServiceBoolean;
+    customDomain: ServiceString;
 }
 const WebService = {
-    default: (name: string, startCommand: ServiceReadOnlyField): WebService => ({
+    default: (name: string, porterJson?: PorterJson): WebService => ({
         name,
-        cpu: '100',
-        ram: '256',
-        startCommand: startCommand,
+        cpu: ServiceField.string('100', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.apps?.[name]?.config?.resources?.requests?.ram ? porterJson?.apps?.[name]?.config?.resources?.requests?.ram.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'web',
-        replicas: '1',
-        autoscalingOn: false,
-        minReplicas: '1',
-        maxReplicas: '10',
-        targetCPUUtilizationPercentage: '50',
-        targetRAMUtilizationPercentage: '50',
-        port: '80',
-        generateUrlForExternalTraffic: false,
-        customDomain: '',
+        replicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.replicaCount),
+        autoscalingOn: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+        minReplicas: ServiceField.string('1', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+        maxReplicas: ServiceField.string('10', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+        targetCPUUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+        targetRAMUtilizationPercentage: ServiceField.string('50', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+        port: ServiceField.string('8080', porterJson?.apps?.[name]?.config?.container?.port),
+        generateUrlForExternalTraffic: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.ingress?.enabled),
+        customDomain: ServiceField.string('', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
+        canDelete: porterJson?.apps?.[name] == null,
     }),
     serialize: (service: WebService) => {
-        const autoscaling = service.autoscalingOn ? {
+        const autoscaling = service.autoscalingOn.value ? {
             autoscaling: {
                 enabled: true,
-                minReplicas: service.minReplicas,
-                maxReplicas: service.maxReplicas,
-                targetCPUUtilizationPercentage: service.targetCPUUtilizationPercentage,
-                targetMemoryUtilizationPercentage: service.targetRAMUtilizationPercentage,
+                minReplicas: service.minReplicas.value,
+                maxReplicas: service.maxReplicas.value,
+                targetCPUUtilizationPercentage: service.targetCPUUtilizationPercentage.value,
+                targetMemoryUtilizationPercentage: service.targetRAMUtilizationPercentage.value,
             }
         } : {};
         return {
-            replicaCount: service.replicas,
+            replicaCount: service.replicas.value,
             resources: {
                 requests: {
-                    cpu: service.cpu + 'm',
-                    memory: service.ram + 'Mi',
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
                 }
             },
             container: {
                 command: service.startCommand.value,
-                port: service.port,
+                port: service.port.value,
             },
             service: {
-                port: service.port,
+                port: service.port.value,
             },
             ...autoscaling,
         }
     },
-    deserialize: (name: string, values: any): WebService => {
+    deserialize: (name: string, values: any, porterJson?: PorterJson): WebService => {
         return {
             name,
-            cpu: values.resources?.requests?.cpu?.replace('m', '') ?? '',
-            ram: values.resources?.requests?.memory?.replace('Mi', '') ?? '',
-            startCommand: {
-                readOnly: false,
-                value: values.container?.command ?? ''
-            },
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', ''), porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.ram ? porterJson?.apps?.[name]?.config?.resources?.requests?.ram.replace('Mi', '') : undefined),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.apps?.[name]?.run),
             type: 'web',
-            replicas: values.replicaCount ?? '',
-            autoscalingOn: values.autoscaling?.enabled ?? false,
-            minReplicas: values.autoscaling?.minReplicas ?? '',
-            maxReplicas: values.autoscaling?.maxReplicas ?? '',
-            targetCPUUtilizationPercentage: values.autoscaling?.targetCPUUtilizationPercentage ?? '',
-            targetRAMUtilizationPercentage: values.autoscaling?.targetMemoryUtilizationPercentage ?? '',
-            port: values.container?.port ?? '',
-            generateUrlForExternalTraffic: values.ingress?.enabled ?? false,
-            customDomain: values.ingress?.hosts?.length ? values.ingress.hosts[0] : '',
+            replicas: ServiceField.string(values.replicaCount ?? '', porterJson?.apps?.[name]?.config?.replicaCount),
+            autoscalingOn: ServiceField.boolean(values.autoscaling?.enabled ?? false, porterJson?.apps?.[name]?.config?.autoscaling?.enabled),
+            minReplicas: ServiceField.string(values.autoscaling?.minReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.minReplicas),
+            maxReplicas: ServiceField.string(values.autoscaling?.maxReplicas ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.maxReplicas),
+            targetCPUUtilizationPercentage: ServiceField.string(values.autoscaling?.targetCPUUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetCPUUtilizationPercentage),
+            targetRAMUtilizationPercentage: ServiceField.string(values.autoscaling?.targetMemoryUtilizationPercentage ?? '', porterJson?.apps?.[name]?.config?.autoscaling?.targetMemoryUtilizationPercentage),
+            port: ServiceField.string(values.container?.port ?? '', porterJson?.apps?.[name]?.config?.container?.port),
+            generateUrlForExternalTraffic: ServiceField.boolean(values.ingress?.enabled ?? false, porterJson?.apps?.[name]?.config?.ingress?.enabled),
+            customDomain: ServiceField.string(values.ingress?.hosts?.length ? values.ingress.hosts[0] : '', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
+            canDelete: porterJson?.apps?.[name] == null,
         }
     }
 }
 
 export type JobService = SharedServiceParams & {
     type: 'job';
-    jobsExecuteConcurrently: boolean;
-    cronSchedule: string;
+    jobsExecuteConcurrently: ServiceBoolean;
+    cronSchedule: ServiceString;
 }
 const JobService = {
-    default: (name: string, startCommand: ServiceReadOnlyField): JobService => ({
+    default: (name: string, porterJson?: PorterJson): JobService => ({
         name,
-        cpu: '100',
-        ram: '256',
-        startCommand: startCommand,
+        cpu: ServiceField.string('100', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu ? porterJson?.apps?.[name]?.config?.resources?.requests?.cpu.replace('m', '') : undefined),
+        ram: ServiceField.string('256', porterJson?.apps?.[name]?.config?.resources?.requests?.ram ? porterJson?.apps?.[name]?.config?.resources?.requests?.ram.replace('Mi', '') : undefined),
+        startCommand: ServiceField.string('', porterJson?.apps?.[name]?.run),
         type: 'job',
-        jobsExecuteConcurrently: false,
-        cronSchedule: '',
+        jobsExecuteConcurrently: ServiceField.boolean(false, porterJson?.apps?.[name]?.config?.allowConcurrent),
+        cronSchedule: ServiceField.string('', porterJson?.apps?.[name]?.config?.schedule?.value),
+        canDelete: porterJson?.apps?.[name] == null,
     }),
     serialize: (service: JobService) => {
-        const schedule = service.cronSchedule ? {
-            enabled: true,
-            value: service.cronSchedule,
+        const schedule = service.cronSchedule.value ? {
+            schedule: {
+                enabled: true,
+                value: service.cronSchedule.value,
+            }
         } : {};
         return {
-            allowConcurrent: service.jobsExecuteConcurrently,
+            allowConcurrent: service.jobsExecuteConcurrently.value,
             container: {
                 command: service.startCommand.value,
             },
             resources: {
                 requests: {
-                    cpu: service.cpu + 'm',
-                    memory: service.ram + 'Mi',
+                    cpu: service.cpu.value + 'm',
+                    memory: service.ram.value + 'Mi',
                 }
             },
             ...schedule,
         }
     },
-    deserialize: (name: string, values: any): JobService => {
+    deserialize: (name: string, values: any, porterJson?: PorterJson): JobService => {
         return {
             name,
-            cpu: values.resources?.requests?.cpu?.replace('m', '') ?? '',
-            ram: values.resources?.requests?.memory?.replace('Mi', '') ?? '',
-            startCommand: {
-                readOnly: false,
-                value: values.container?.command ?? ''
-            },
+            cpu: ServiceField.string(values.resources?.requests?.cpu?.replace('m', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.cpu),
+            ram: ServiceField.string(values.resources?.requests?.memory?.replace('Mi', '') ?? '', porterJson?.apps?.[name]?.config?.resources?.requests?.ram),
+            startCommand: ServiceField.string(values.container?.command ?? '', porterJson?.apps?.[name]?.run),
             type: 'job',
-            jobsExecuteConcurrently: values.allowConcurrent ?? false,
-            cronSchedule: values.schedule?.value ?? '',
+            jobsExecuteConcurrently: ServiceField.boolean(values.allowConcurrent ?? false, porterJson?.apps?.[name]?.config?.allowConcurrent),
+            cronSchedule: ServiceField.string(values.schedule?.value ?? '', porterJson?.apps?.[name]?.config?.schedule?.value),
+            canDelete: porterJson?.apps?.[name] == null,
         }
     }
 }
@@ -222,16 +241,19 @@ const SUFFIX_TO_TYPE: Record<string, ServiceType> = {
 }
 
 export const Service = {
-    default: (name: string, type: ServiceType, startCommand: ServiceReadOnlyField) => {
+    // populates an empty service
+    default: (name: string, type: ServiceType, porterJson?: PorterJson) => {
         switch (type) {
             case 'web':
-                return WebService.default(name, startCommand);
+                return WebService.default(name, porterJson);
             case 'worker':
-                return WorkerService.default(name, startCommand);
+                return WorkerService.default(name, porterJson);
             case 'job':
-                return JobService.default(name, startCommand);
+                return JobService.default(name, porterJson);
         }
     },
+
+    // converts a service to a helm values object
     serialize: (service: Service) => {
         switch (service.type) {
             case 'web':
@@ -242,11 +264,9 @@ export const Service = {
                 return JobService.serialize(service);
         }
     },
-    deserialize: (helmValues: any, defaultValues: any): Service[] => {
-        // console.log("helm values")
-        // console.log(helmValues)
-        // console.log("default values")
-        // console.log(defaultValues)
+
+    // converts a helm values object and porter json (from their repo) to a service
+    deserialize: (helmValues: any, defaultValues: any, porterJson?: PorterJson): Service[] => {
         return Object.keys(defaultValues).map((name: string) => {
             const suffix = name.slice(-4);
             if (suffix in SUFFIX_TO_TYPE) {
@@ -258,34 +278,40 @@ export const Service = {
                 );
                 switch (type) {
                     case 'web':
-                        return WebService.deserialize(appName, coalescedValues);
+                        return WebService.deserialize(appName, coalescedValues, porterJson);
                     case 'worker':
-                        return WorkerService.deserialize(appName, coalescedValues);
+                        return WorkerService.deserialize(appName, coalescedValues, porterJson);
                     case 'job':
-                        return JobService.deserialize(appName, coalescedValues);
+                        return JobService.deserialize(appName, coalescedValues, porterJson);
                 }
             }
         }).filter((service: Service | undefined): service is Service => service != null);
     },
+
+    // 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',
+
+    // augments ingress of a web service, will be phased out
     handleWebIngress: (service: WebService, stackName: string, projectId?: number, clusterId?: number) => {
         if (projectId == null || clusterId == null) {
             throw new Error('Project ID and Cluster ID must be provided to handle web ingress');
         }
-        if (!service.generateUrlForExternalTraffic) {
+        if (!service.generateUrlForExternalTraffic.value) {
             return {}
         }
         const ingress: Ingress = {
-            enabled: true,
-            hosts: [],
-            custom_domain: false,
-            porter_hosts: [],
+            ingress: {
+                enabled: true,
+                hosts: [],
+                custom_domain: false,
+                porter_hosts: [],
+            }
         };
-        if (service.customDomain) {
-            ingress.hosts.push(service.customDomain);
-            ingress.custom_domain = true;
+        if (service.customDomain.value) {
+            ingress.ingress.hosts.push(service.customDomain.value);
+            ingress.ingress.custom_domain = true;
         } else {
             // const res = await api
             //     .createSubdomain(
@@ -302,20 +328,43 @@ export const Service = {
             //     throw new Error('Failed to create subdomain for web service');
             // }
             // ingress.porter_hosts.push(res.data.external_url)
-            throw new Error('Generating external URLs without custom subdomains not yet supported!');
+            //throw new Error('Generating external URLs without custom subdomains not yet supported!');
         }
 
         return ingress;
     },
+
     // required because of https://github.com/helm/helm/issues/9214
     toHelmName: (service: Service): string => {
         return service.name + TYPE_TO_SUFFIX[service.type]
     },
+
+    retrieveEnvFromHelmValues: (helmValues: any): KeyValueType[] => {
+        const firstService = Object.keys(helmValues)[0];
+        const env = helmValues[firstService]?.container?.env?.normal;
+        if (env == null) {
+            return [];
+        }
+        try {
+            return Object.keys(env).map((key: string) => ({
+                key,
+                value: env[key],
+                hidden: false,
+                locked: false,
+                deleted: false,
+            }));
+        } catch (err) {
+            // TODO: handle error
+            return [];
+        }
+    }
 }
 
 type Ingress = {
-    enabled: boolean;
-    hosts: string[];
-    custom_domain: boolean;
-    porter_hosts: string[];
+    ingress: {
+        enabled: boolean;
+        hosts: string[];
+        custom_domain: boolean;
+        porter_hosts: string[];
+    }
 }

+ 1 - 3
dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts

@@ -2,11 +2,9 @@ export const overrideObjectValues = (obj1: any, obj2: any) => {
   // Iterate over the keys in obj2
   for (const key in obj2) {
     // Check if the key exists in obj1 and if its value is an object
-    if (key in obj1 && typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
-      // Recursively call the function to handle the nested object
+    if (key in obj1 && obj1[key] !== null && typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
       obj1[key] = overrideObjectValues(obj1[key], obj2[key]);
     } else {
-      // Otherwise, just assign the value from obj2 to obj1
       obj1[key] = obj2[key];
     }
   }

+ 29 - 0
internal/templater/utils/values.go

@@ -47,6 +47,35 @@ func CoalesceValues(base, override map[string]interface{}) map[string]interface{
 	return override
 }
 
+func DeepCoalesceValues(base, override map[string]interface{}) map[string]interface{} {
+	if base == nil && override != nil {
+		return override
+	} else if override == nil {
+		return base
+	}
+
+	for key, val := range base {
+		if oVal, ok := override[key]; ok {
+			if oVal == nil {
+				delete(override, key)
+			} else if isYAMLTable(oVal) && isYAMLTable(val) {
+				oMapVal, _ := oVal.(map[string]interface{})
+				bMapVal, _ := val.(map[string]interface{})
+
+				override[key] = mergeMaps(bMapVal, oMapVal)
+			} else if _, ok := val.(map[string]interface{}); ok {
+				override[key] = CoalesceValues(val.(map[string]interface{}), oVal.(map[string]interface{}))
+			} else {
+				override[key] = oVal
+			}
+		} else {
+			override[key] = val
+		}
+	}
+
+	return override
+}
+
 func isYAMLTable(v interface{}) bool {
 	_, ok := v.(map[string]interface{})
 	return ok