d-g-town hace 2 años
padre
commit
89d9a47cc6

+ 242 - 0
api/server/handlers/porter_app/yaml_from_revision.go

@@ -1,8 +1,20 @@
 package porter_app
 
 import (
+	"context"
 	"encoding/base64"
 	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
+	"github.com/porter-dev/porter/internal/deployment_target"
+	"github.com/porter-dev/porter/internal/repository"
+
+	"github.com/porter-dev/porter/internal/porter_app"
 
 	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
@@ -39,6 +51,11 @@ func NewPorterYAMLFromRevisionHandler(
 	}
 }
 
+// PorterYAMLFromRevisionRequest is the request object for the /apps/{porter_app_name}/revisions/{app_revision_id}/yaml endpoint
+type PorterYAMLFromRevisionRequest struct {
+	ShouldFormatForExport bool `schema:"should_format_for_export"`
+}
+
 // PorterYAMLFromRevisionResponse is the response object for the /apps/{porter_app_name}/revisions/{app_revision_id}/yaml endpoint
 type PorterYAMLFromRevisionResponse struct {
 	B64PorterYAML string `json:"b64_porter_yaml"`
@@ -50,6 +67,7 @@ func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http
 	defer span.End()
 
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
 	appRevisionID, reqErr := requestutils.GetURLParamString(r, types.URLParamAppRevisionID)
 	if reqErr != nil {
@@ -58,6 +76,13 @@ func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	request := &PorterYAMLFromRevisionRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
 	getRevisionReq := connect.NewRequest(&porterv1.GetAppRevisionRequest{
 		ProjectId:     int64(project.ID),
 		AppRevisionId: appRevisionID,
@@ -88,6 +113,35 @@ func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting agent for cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	appRevisionUUID, err := uuid.Parse(appRevisionID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error parsing app revision id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	env, defaultEnvGroupName, err := defaultEnvGroup(ctx, formatDefaultEnvGroupInput{
+		ProjectID:                 project.ID,
+		Cluster:                   cluster,
+		AppRevisionID:             appRevisionUUID,
+		appYAML:                   v2.PorterApp{},
+		K8sAgent:                  agent,
+		ClusterControlPlaneClient: c.Config().ClusterControlPlaneClient,
+		PorterAppRepository:       c.Repo().PorterApp(),
+	})
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error formatting default env group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	app, err := v2.AppFromProto(appProto)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error converting app proto to porter yaml")
@@ -95,6 +149,22 @@ func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	var envGroups []string
+	for _, envGroup := range app.EnvGroups {
+		if !strings.Contains(envGroup, defaultEnvGroupName) {
+			envGroups = append(envGroups, envGroup)
+		}
+	}
+
+	app.Env = env
+	app.EnvGroups = envGroups
+
+	app = zeroOutValues(app)
+
+	if request.ShouldFormatForExport {
+		app = formatForExport(app)
+	}
+
 	porterYAMLString, err := yaml.Marshal(app)
 	if err != nil {
 		err = telemetry.Error(ctx, span, err, "error marshaling porter yaml")
@@ -110,3 +180,175 @@ func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http
 
 	c.WriteResult(w, r, response)
 }
+
+type formatDefaultEnvGroupInput struct {
+	ProjectID     uint
+	Cluster       *models.Cluster
+	AppRevisionID uuid.UUID
+	appYAML       v2.PorterApp
+
+	K8sAgent                  *kubernetes.Agent
+	ClusterControlPlaneClient porterv1connect.ClusterControlPlaneServiceClient
+	PorterAppRepository       repository.PorterAppRepository
+}
+
+func defaultEnvGroup(ctx context.Context, input formatDefaultEnvGroupInput) (map[string]string, string, error) {
+	ctx, span := telemetry.NewSpan(ctx, "format-default-env-group")
+	defer span.End()
+
+	env := map[string]string{}
+
+	revision, err := porter_app.GetAppRevision(ctx, porter_app.GetAppRevisionInput{
+		AppRevisionID: input.AppRevisionID,
+		ProjectID:     input.ProjectID,
+		CCPClient:     input.ClusterControlPlaneClient,
+	})
+	if err != nil {
+		return env, "", telemetry.Error(ctx, span, err, "error getting app revision")
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(revision.B64AppProto)
+	if err != nil {
+		return env, "", telemetry.Error(ctx, span, err, "error decoding base proto")
+	}
+
+	appProto := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, appProto)
+	if err != nil {
+		return env, "", telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+	}
+
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(input.ProjectID),
+		ClusterID:          int64(input.Cluster.ID),
+		DeploymentTargetID: revision.DeploymentTargetID,
+		CCPClient:          input.ClusterControlPlaneClient,
+	})
+	if err != nil {
+		return env, "", telemetry.Error(ctx, span, err, "error getting deployment target details")
+	}
+
+	revisionWithEnv, err := porter_app.AttachEnvToRevision(ctx, porter_app.AttachEnvToRevisionInput{
+		ProjectID:           input.ProjectID,
+		ClusterID:           int(input.Cluster.ID),
+		Revision:            revision,
+		DeploymentTarget:    deploymentTarget,
+		K8SAgent:            input.K8sAgent,
+		PorterAppRepository: input.PorterAppRepository,
+	})
+	if err != nil {
+		return env, "", telemetry.Error(ctx, span, err, "error attaching env to revision")
+	}
+
+	for key, val := range revisionWithEnv.Env.Variables {
+		env[key] = val
+	}
+	for key, val := range revisionWithEnv.Env.SecretVariables {
+		env[key] = val
+	}
+
+	return env, revisionWithEnv.Env.Name, nil
+}
+
+func formatForExport(app v2.PorterApp) v2.PorterApp {
+	// don't show image or commit sha if build is present
+	if app.Build != nil {
+		app.Image = nil
+		app.Build.CommitSHA = ""
+	}
+
+	// remove env secrets from env
+	for key, val := range app.Env {
+		if val == "********" {
+			delete(app.Env, key)
+		}
+	}
+
+	// don't show env group versions
+	for i := range app.EnvGroups {
+		app.EnvGroups[i] = strings.Split(app.EnvGroups[i], ":")[0]
+	}
+
+	return app
+}
+
+func zeroOutValues(app v2.PorterApp) v2.PorterApp {
+	for i := range app.Services {
+		// remove smart optimization
+		app.Services[i].SmartOptimization = nil
+
+		// remove launcher
+		if app.Services[i].Run != nil {
+			launcherLess := strings.TrimPrefix(*app.Services[i].Run, "launcher ")
+			launcherLess = strings.TrimPrefix(launcherLess, "/cnb/lifecycle/launcher ")
+			app.Services[i].Run = &launcherLess
+		}
+
+		switch app.Services[i].Type {
+		case v2.ServiceType_Web:
+			// remove autoscaling if not enabled
+			if app.Services[i].Autoscaling != nil && !app.Services[i].Autoscaling.Enabled {
+				app.Services[i].Autoscaling = nil
+			}
+			// remove health if not enabled
+			if app.Services[i].HealthCheck != nil && !app.Services[i].HealthCheck.Enabled {
+				app.Services[i].HealthCheck = nil
+			}
+			// don't show disableTLS if not enabled
+			if app.Services[i].DisableTLS != nil && !*app.Services[i].DisableTLS {
+				app.Services[i].DisableTLS = nil
+			}
+			// remove private if not enabled
+			if app.Services[i].Private != nil && !*app.Services[i].Private {
+				app.Services[i].Private = nil
+			}
+		case v2.ServiceType_Worker:
+			// remove autoscaling if not enabled
+			if app.Services[i].Autoscaling != nil && !app.Services[i].Autoscaling.Enabled {
+				app.Services[i].Autoscaling = nil
+			}
+			// remove port
+			app.Services[i].Port = 0
+		case v2.ServiceType_Job:
+			// remove port
+			app.Services[i].Port = 0
+			// remove instances
+			app.Services[i].Instances = nil
+			// remove suspendCron if not enabled
+			if app.Services[i].SuspendCron != nil && !*app.Services[i].SuspendCron {
+				app.Services[i].SuspendCron = nil
+			}
+			// remove allowConcurrency if not enabled
+			if app.Services[i].AllowConcurrent != nil && !*app.Services[i].AllowConcurrent {
+				app.Services[i].AllowConcurrent = nil
+			}
+		}
+	}
+
+	if app.Predeploy != nil {
+		// remove name
+		app.Predeploy.Name = ""
+		// remove type
+		app.Predeploy.Type = ""
+		// remove smart optimization
+		app.Predeploy.SmartOptimization = nil
+		// remove launcher
+		if app.Predeploy.Run != nil {
+			launcherLess := strings.TrimPrefix(*app.Predeploy.Run, "launcher ")
+			launcherLess = strings.TrimPrefix(launcherLess, "/cnb/lifecycle/launcher ")
+			app.Predeploy.Run = &launcherLess
+		}
+		// remove port
+		app.Predeploy.Port = 0
+		// remove instances
+		app.Predeploy.Instances = nil
+		// remove suspendCron
+		app.Predeploy.SuspendCron = nil
+		// remove allowConcurrency
+		app.Predeploy.AllowConcurrent = nil
+		// remove timeout
+		app.Predeploy.TimeoutSeconds = 0
+	}
+
+	return app
+}

+ 128 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/ExportAppModal.tsx

@@ -0,0 +1,128 @@
+import React from "react";
+import { useQuery } from "@tanstack/react-query";
+import styled from "styled-components";
+import { z } from "zod";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Loading from "components/Loading";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import YamlEditor from "components/YamlEditor";
+
+import api from "shared/api";
+
+import { useLatestRevision } from "../LatestRevisionContext";
+
+type Props = {
+  closeModal: () => void;
+};
+
+const ExportAppModal: React.FC<Props> = ({ closeModal }) => {
+  const { porterApp, clusterId, projectId, latestRevision } =
+    useLatestRevision();
+
+  const { data: yamlResp } = useQuery(
+    [
+      "getExportablePorterYamlFromRevision",
+      projectId,
+      clusterId,
+      latestRevision.id,
+    ],
+    async () => {
+      const yamlResp = await api.porterYamlFromRevision(
+        "<token>",
+        {
+          should_format_for_export: true,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: porterApp.name,
+          revision_id: latestRevision.id,
+        }
+      );
+
+      const parsedBase = z
+        .object({ b64_porter_yaml: z.string() })
+        .parse(yamlResp.data);
+      const decodedBase = atob(parsedBase.b64_porter_yaml);
+
+      return decodedBase;
+    }
+  );
+
+  if (!yamlResp) {
+    return (
+      <Modal closeModal={closeModal}>
+        <Loading />
+      </Modal>
+    );
+  }
+
+  return (
+    <Modal closeModal={closeModal}>
+      <Spacer y={1} />
+      Note: Secret environment variables are not included in the exported YAML.
+      <Spacer y={0.5} />
+      <StyledValuesYaml>
+        <Wrapper>
+          <YamlEditor
+            value={yamlResp}
+            height="calc(100vh - 412px)"
+            readOnly={true}
+          />
+        </Wrapper>
+        <CopyWrapper>
+          Copy to clipboard:
+          <Spacer inline x={0.25} />
+          <CopyToClipboard
+            as="i"
+            text={yamlResp}
+            wrapperProps={{
+              className: "material-icons",
+            }}
+          >
+            content_copy
+          </CopyToClipboard>
+        </CopyWrapper>
+      </StyledValuesYaml>
+    </Modal>
+  );
+};
+
+export default ExportAppModal;
+
+const Wrapper = styled.div`
+  overflow: auto;
+  border-radius: 8px;
+  margin-bottom: 30px;
+  border: 1px solid #ffffff33;
+`;
+
+const StyledValuesYaml = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: calc(100vh - 350px);
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const CopyWrapper = styled.div`
+  display: flex;
+  justify-content: flex-end;
+`;

+ 94 - 51
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -1,35 +1,36 @@
 import React, { useCallback, useContext, useEffect, useState } from "react";
-import styled from "styled-components";
+import { useQueryClient } from "@tanstack/react-query";
+import { Controller, useFormContext } from "react-hook-form";
 import { useHistory } from "react-router";
+import styled from "styled-components";
 
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
 import Button from "components/porter/Button";
-import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
+import Checkbox from "components/porter/Checkbox";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { type PorterAppFormData } from "lib/porter-apps";
 
-import { useLatestRevision } from "../LatestRevisionContext";
 import api from "shared/api";
-import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
-import { useQueryClient } from "@tanstack/react-query";
 import { Context } from "shared/Context";
+import document from "assets/document.svg";
+
+import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
+import { useLatestRevision } from "../LatestRevisionContext";
+import ExportAppModal from "./ExportAppModal";
 import PreviewEnvironmentSettings from "./preview-environments/PreviewEnvironmentSettings";
-import { Controller, useFormContext } from "react-hook-form";
-import { PorterAppFormData } from "lib/porter-apps";
-import Checkbox from "components/porter/Checkbox";
 
 const Settings: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
   const queryClient = useQueryClient();
   const history = useHistory();
   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+  const [isExportModalOpen, setIsExportModalOpen] = useState(false);
   const { porterApp, clusterId, projectId, latestProto } = useLatestRevision();
   const { updateAppStep } = useAppAnalytics();
   const [isDeleting, setIsDeleting] = useState(false);
-  const {
-    control,
-    setValue,
-    watch
-  } = useFormContext<PorterAppFormData>();
+  const { control } = useFormContext<PorterAppFormData>();
   const [githubWorkflowFilename, setGithubWorkflowFilename] = useState(
     `porter_stack_${porterApp.name}.yml`
   );
@@ -66,14 +67,14 @@ const Settings: React.FC = () => {
   }, [porterApp.name, clusterId, projectId]);
 
   useEffect(() => {
-    const checkWorkflowExists = async () => {
+    const checkWorkflowExists = async (): Promise<void> => {
       const exists = await workflowFileExists();
       if (!exists) {
         setGithubWorkflowFilename("");
       }
     };
 
-    checkWorkflowExists();
+    checkWorkflowExists().catch(() => {});
   }, [workflowFileExists]);
 
   const onDelete = useCallback(
@@ -125,7 +126,7 @@ const Settings: React.FC = () => {
             step: "stack-deletion",
             deleteWorkflow: true,
             appName: porterApp.name,
-          });
+          }).catch(() => {});
           history.push("/apps");
           return;
         }
@@ -134,7 +135,7 @@ const Settings: React.FC = () => {
           step: "stack-deletion",
           deleteWorkflow: false,
           appName: porterApp.name,
-        });
+        }).catch(() => {});
         history.push("/apps");
       } catch (err) {
       } finally {
@@ -150,37 +151,71 @@ const Settings: React.FC = () => {
         <PreviewEnvironmentSettings />
       ) : null}
 
-      {(currentCluster?.cloud_provider == "AWS" && currentProject?.efs_enabled) && <>
-        <Text size={16}>Enable shared storage across services for "{porterApp.name}"</Text>
-        <Spacer y={0.5} />
-        <Spacer y={.5} />
-        <Controller
-          name={`app.efsStorage`}
-          control={control}
-          render={({ field: { value, onChange } }) => (
-            <Checkbox
-              checked={value.enabled}
-              toggleChecked={() => {
-                onChange({
-                  ...value,
-                  enabled: !value.enabled,
-                },
-                );
-              }}
-              disabled={value.readOnly}
-              disabledTooltip={
-                "You may only edit this field in your porter.yaml."
-              }
-            >
-              <Text color="helper">
-                Enable EFS Storage
-              </Text>
-            </Checkbox>
-          )} />
-        <Spacer y={1} />
-      </>}
-      <Text size={16}>Delete "{porterApp.name}"</Text>
-      <Spacer y={.5} />
+      {currentCluster?.cloud_provider === "AWS" &&
+        currentProject?.efs_enabled && (
+          <>
+            <Text size={16}>
+              Enable shared storage across services for &quot;{porterApp.name}
+              &quot;
+            </Text>
+            <Spacer y={0.5} />
+            <Spacer y={0.5} />
+            <Controller
+              name={`app.efsStorage`}
+              control={control}
+              render={({ field: { value, onChange } }) => (
+                <Checkbox
+                  checked={value.enabled}
+                  toggleChecked={() => {
+                    onChange({
+                      ...value,
+                      enabled: !value.enabled,
+                    });
+                  }}
+                  disabled={value.readOnly}
+                  disabledTooltip={
+                    "You may only edit this field in your porter.yaml."
+                  }
+                >
+                  <Text color="helper">Enable EFS Storage</Text>
+                </Checkbox>
+              )}
+            />
+            <Spacer y={1} />
+          </>
+        )}
+      <Text size={16}>Export &quot;{porterApp.name}&quot;</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">Export this application as Porter YAML.</Text>
+      <a
+        href="https://docs.porter.run/deploy/configuration-as-code/overview"
+        target="_blank"
+        rel="noreferrer"
+      >
+        &nbsp;(?)
+      </a>
+      <Spacer y={0.5} />
+      <div
+        style={{ width: "fit-content", fontSize: "14px" }}
+        onClick={() => {
+          setIsExportModalOpen(true);
+        }}
+      >
+        <Tag>
+          <TagIcon src={document} />
+          Export
+        </Tag>
+      </div>
+      {isExportModalOpen && (
+        <ExportAppModal
+          closeModal={() => {
+            setIsExportModalOpen(false);
+          }}
+        />
+      )}
+      <Spacer y={0.75} />
+      <Text size={16}>Delete &quot;{porterApp.name}&quot;</Text>
+      <Spacer y={0.5} />
       <Text color="helper">
         Delete this application and all of its resources.
       </Text>
@@ -196,7 +231,9 @@ const Settings: React.FC = () => {
       </Button>
       {isDeleteModalOpen && (
         <DeleteApplicationModal
-          closeModal={() => setIsDeleteModalOpen(false)}
+          closeModal={() => {
+            setIsDeleteModalOpen(false);
+          }}
           githubWorkflowFilename={githubWorkflowFilename}
           deleteApplication={onDelete}
           loading={isDeleting}
@@ -211,3 +248,9 @@ export default Settings;
 const StyledSettingsTab = styled.div`
   width: 100%;
 `;
+
+const TagIcon = styled.img`
+  margin-top: 2px;
+  height: 12px;
+  margin-right: 3px;
+`;

+ 3 - 1
dashboard/src/shared/api.tsx

@@ -1141,7 +1141,9 @@ const getRevision = baseApi<
 });
 
 const porterYamlFromRevision = baseApi<
-  {},
+  {
+      should_format_for_export: boolean;
+  },
   {
     project_id: number;
     cluster_id: number;

+ 2 - 2
internal/porter_app/v2/addons.go

@@ -37,8 +37,8 @@ func ProtoFromAddon(ctx context.Context, addon Addon) (*porterv1.Addon, error) {
 
 	for _, envGroup := range addon.EnvGroups {
 		eg := &porterv1.EnvGroup{
-			Name:    envGroup.Name,
-			Version: int64(envGroup.Version),
+			Name:    envGroup,
+			Version: 0, // this is updated to latest when applied to cluster
 		}
 		envGroups = append(envGroups, eg)
 	}

+ 19 - 33
internal/porter_app/v2/yaml.go

@@ -96,12 +96,6 @@ const (
 	ServiceType_Job ServiceType = "job"
 )
 
-// EnvGroup is a struct containing the name and version of an environment group
-type EnvGroup struct {
-	Name    string `yaml:"name"`
-	Version int    `yaml:"version"`
-}
-
 // PorterApp represents all the possible fields in a Porter YAML file
 type PorterApp struct {
 	Version  string            `yaml:"version,omitempty"`
@@ -112,7 +106,7 @@ type PorterApp struct {
 	Env      map[string]string `yaml:"env,omitempty"`
 
 	Predeploy    *Service      `yaml:"predeploy,omitempty"`
-	EnvGroups    []EnvGroup    `yaml:"envGroups,omitempty"`
+	EnvGroups    []string      `yaml:"envGroups,omitempty"`
 	EfsStorage   *EfsStorage   `yaml:"efsStorage,omitempty"`
 	RequiredApps []RequiredApp `yaml:"requiredApps,omitempty"`
 }
@@ -131,12 +125,12 @@ type PorterYAML struct {
 
 // Addon represents an addon that should be installed alongside a Porter app
 type Addon struct {
-	Name             string     `yaml:"name"`
-	Type             string     `yaml:"type"`
-	EnvGroups        []EnvGroup `yaml:"envGroups,omitempty"`
-	CpuCores         float32    `yaml:"cpuCores,omitempty"`
-	RamMegabytes     int        `yaml:"ramMegabytes,omitempty"`
-	StorageGigabytes float32    `yaml:"storageGigabytes,omitempty"`
+	Name             string   `yaml:"name"`
+	Type             string   `yaml:"type"`
+	EnvGroups        []string `yaml:"envGroups,omitempty"`
+	CpuCores         float32  `yaml:"cpuCores,omitempty"`
+	RamMegabytes     int      `yaml:"ramMegabytes,omitempty"`
+	StorageGigabytes float32  `yaml:"storageGigabytes,omitempty"`
 }
 
 // RequiredApp specifies another porter app that this app expects to be deployed alongside it
@@ -152,12 +146,12 @@ type EfsStorage struct {
 
 // Build represents the build settings for a Porter app
 type Build struct {
-	Context    string   `yaml:"context" validate:"dir"`
-	Method     string   `yaml:"method" validate:"required,oneof=pack docker registry"`
-	Builder    string   `yaml:"builder" validate:"required_if=Method pack"`
-	Buildpacks []string `yaml:"buildpacks"`
-	Dockerfile string   `yaml:"dockerfile" validate:"required_if=Method docker"`
-	CommitSHA  string   `yaml:"commitSha"`
+	Context    string   `yaml:"context,omitempty" validate:"dir"`
+	Method     string   `yaml:"method,omitempty" validate:"required,oneof=pack docker registry"`
+	Builder    string   `yaml:"builder,omitempty" validate:"required_if=Method pack"`
+	Buildpacks []string `yaml:"buildpacks,omitempty"`
+	Dockerfile string   `yaml:"dockerfile,omitempty" validate:"required_if=Method docker"`
+	CommitSHA  string   `yaml:"commitSha,omitempty"`
 }
 
 // Image is the repository and tag for an app's build image
@@ -266,16 +260,12 @@ func ProtoFromApp(ctx context.Context, porterApp PorterApp) (*porterv1.PorterApp
 		appProto.Predeploy = predeployProto
 	}
 
-	envGroups := make([]*porterv1.EnvGroup, 0)
-	if porterApp.EnvGroups != nil {
-		for _, envGroup := range porterApp.EnvGroups {
-			envGroups = append(envGroups, &porterv1.EnvGroup{
-				Name:    envGroup.Name,
-				Version: int64(envGroup.Version),
-			})
-		}
+	for _, envGroup := range porterApp.EnvGroups {
+		appProto.EnvGroups = append(appProto.EnvGroups, &porterv1.EnvGroup{
+			Name:    envGroup,
+			Version: 0, // this will be updated to latest when applied
+		})
 	}
-	appProto.EnvGroups = envGroups
 
 	if porterApp.EfsStorage != nil {
 		appProto.EfsStorage = &porterv1.EFS{
@@ -475,12 +465,8 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 		porterApp.Predeploy = &appPredeploy
 	}
 
-	porterApp.EnvGroups = make([]EnvGroup, 0)
 	for _, envGroup := range appProto.EnvGroups {
-		porterApp.EnvGroups = append(porterApp.EnvGroups, EnvGroup{
-			Name:    envGroup.Name,
-			Version: int(envGroup.Version),
-		})
+		porterApp.EnvGroups = append(porterApp.EnvGroups, fmt.Sprintf("%s:v%d", envGroup.Name, envGroup.Version))
 	}
 
 	if appProto.EfsStorage != nil {