Преглед изворни кода

create or update app env group on cli run (#3577)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town пре 2 година
родитељ
комит
29f4853c7b

+ 30 - 0
api/client/porter_app.go

@@ -437,3 +437,33 @@ func (c *Client) GetBuildEnv(
 
 	return resp, err
 }
+
+// CreateOrUpdateAppEnvironment updates the app environment group and creates it if it doesn't exist
+func (c *Client) CreateOrUpdateAppEnvironment(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	appName string,
+	deploymentTargetID string,
+	variables map[string]string,
+	secrets map[string]string,
+) (*porter_app.UpdateAppEnvironmentResponse, error) {
+	resp := &porter_app.UpdateAppEnvironmentResponse{}
+
+	req := &porter_app.UpdateAppEnvironmentRequest{
+		DeploymentTargetID: deploymentTargetID,
+		Variables:          variables,
+		Secrets:            secrets,
+		HardUpdate:         false,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/update-environment",
+			projectID, clusterID, appName,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 6 - 3
api/server/handlers/porter_app/parse_yaml.go

@@ -42,7 +42,9 @@ type ParsePorterYAMLToProtoRequest struct {
 
 // ParsePorterYAMLToProtoResponse is the response object for the /apps/parse endpoint
 type ParsePorterYAMLToProtoResponse struct {
-	B64AppProto string `json:"b64_app_proto"`
+	B64AppProto  string            `json:"b64_app_proto"`
+	EnvVariables map[string]string `json:"env_variables"`
+	EnvSecrets   map[string]string `json:"env_secrets"`
 }
 
 // ServeHTTP receives a base64-encoded porter.yaml, parses the version, and then translates it into a base64-encoded app proto object
@@ -83,7 +85,7 @@ func (c *ParsePorterYAMLToProtoHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
-	appProto, err := porter_app.ParseYAML(ctx, yaml, request.AppName)
+	appProto, envVariables, err := porter_app.ParseYAML(ctx, yaml, request.AppName)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error parsing yaml")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
@@ -105,7 +107,8 @@ func (c *ParsePorterYAMLToProtoHandler) ServeHTTP(w http.ResponseWriter, r *http
 	b64 := base64.StdEncoding.EncodeToString(by)
 
 	response := &ParsePorterYAMLToProtoResponse{
-		B64AppProto: b64,
+		B64AppProto:  b64,
+		EnvVariables: envVariables,
 	}
 
 	c.WriteResult(w, r, response)

+ 247 - 0
api/server/handlers/porter_app/update_app_environment_group.go

@@ -0,0 +1,247 @@
+package porter_app
+
+import (
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/internal/porter_app"
+
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// UpdateAppEnvironmentHandler handles the /apps/{porter_app_name}/update-environment endpoint
+type UpdateAppEnvironmentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewUpdateAppEnvironmentHandler returns a new UpdateAppEnvironmentHandler
+func NewUpdateAppEnvironmentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateAppEnvironmentHandler {
+	return &UpdateAppEnvironmentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// UpdateAppEnvironmentRequest represents the accepted fields on a request to the /apps/{porter_app_name}/environment-group endpoint
+type UpdateAppEnvironmentRequest struct {
+	DeploymentTargetID string            `schema:"deployment_target_id"`
+	Variables          map[string]string `schema:"variables"`
+	Secrets            map[string]string `schema:"secrets"`
+	// HardUpdate is used to remove any variables that are not specified in the request.  If false, the request will only update the variables specified in the request,
+	// and leave all other variables untouched.
+	HardUpdate bool `schema:"remove_missing"`
+}
+
+// UpdateAppEnvironmentResponse represents the fields on the response object from the /apps/{porter_app_name}/environment-group endpoint
+type UpdateAppEnvironmentResponse struct {
+	EnvGroupName    string `schema:"env_group_name"`
+	EnvGroupVersion int    `schema:"env_group_version"`
+}
+
+// ServeHTTP updates or creates the environment group for an app
+func (c *UpdateAppEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-app-env-group")
+	defer span.End()
+	r = r.Clone(ctx)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	request := &UpdateAppEnvironmentRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "hard-update", Value: request.HardUpdate})
+
+	envGroupName, err := porter_app.AppEnvGroupName(ctx, appName, request.DeploymentTargetID, cluster.ID, c.Repo().PorterApp())
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting app env group name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	latestEnvironmentGroup, err := environment_groups.LatestBaseEnvironmentGroup(ctx, agent, envGroupName)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to get latest base environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-exists", Value: latestEnvironmentGroup.Name != ""})
+
+	if latestEnvironmentGroup.Name != "" {
+		sameEnvGroup := true
+		for key, newValue := range request.Variables {
+			if existingValue, ok := latestEnvironmentGroup.Variables[key]; !ok || existingValue != newValue {
+				sameEnvGroup = false
+			}
+		}
+		for key, newValue := range request.Secrets {
+			if existingValue, ok := latestEnvironmentGroup.SecretVariables[key]; !ok || string(existingValue) != newValue {
+				sameEnvGroup = false
+			}
+		}
+		if request.HardUpdate {
+			for key, existingValue := range latestEnvironmentGroup.Variables {
+				if newValue, ok := request.Variables[key]; !ok || existingValue != newValue {
+					sameEnvGroup = false
+				}
+			}
+			for key, existingValue := range latestEnvironmentGroup.SecretVariables {
+				if newValue, ok := request.Secrets[key]; !ok || string(existingValue) != newValue {
+					sameEnvGroup = false
+				}
+			}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "same-env-group", Value: sameEnvGroup})
+
+		if sameEnvGroup {
+			res := &UpdateAppEnvironmentResponse{
+				EnvGroupName:    latestEnvironmentGroup.Name,
+				EnvGroupVersion: latestEnvironmentGroup.Version,
+			}
+
+			c.WriteResult(w, r, res)
+			return
+		}
+	}
+
+	variables := make(map[string]string)
+	secrets := make(map[string][]byte)
+
+	if !request.HardUpdate {
+		for key, value := range latestEnvironmentGroup.Variables {
+			variables[key] = value
+		}
+		for key, value := range latestEnvironmentGroup.SecretVariables {
+			secrets[key] = value
+		}
+	}
+
+	for key, value := range request.Variables {
+		variables[key] = value
+	}
+	for key, value := range request.Secrets {
+		secrets[key] = []byte(value)
+	}
+
+	envGroup := environment_groups.EnvironmentGroup{
+		Name:            envGroupName,
+		Variables:       variables,
+		SecretVariables: secrets,
+		CreatedAtUTC:    time.Now().UTC(),
+	}
+
+	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to create or update base environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	inp := environment_groups.SyncLatestVersionToNamespaceInput{
+		BaseEnvironmentGroupName: envGroupName,
+		TargetNamespace:          namespace,
+	}
+
+	syncedEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, agent, inp)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to create or update synced environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-versioned-name", Value: syncedEnvironment.EnvironmentGroupVersionedName})
+
+	split := strings.Split(syncedEnvironment.EnvironmentGroupVersionedName, ".")
+	if len(split) != 2 {
+		err := telemetry.Error(ctx, span, err, "unexpected environment group versioned name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	version, err := strconv.Atoi(split[1])
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error converting environment group version to int")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := &UpdateAppEnvironmentResponse{
+		EnvGroupName:    split[0],
+		EnvGroupVersion: version,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -1007,5 +1007,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/update-environment -> porter_app.NewUpdateAppEnvironmentHandler
+	updateAppEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/update-environment", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updateAppEnvironmentGroupHandler := porter_app.NewUpdateAppEnvironmentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateAppEnvironmentGroupEndpoint,
+		Handler:  updateAppEnvironmentGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 71 - 21
cli/cmd/v2/apply.go

@@ -26,7 +26,16 @@ import (
 // Apply implements the functionality of the `porter apply` command for validate apply v2 projects
 func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, porterYamlPath string, appName string) error {
 	const forceBuild = true
-	var yamlB64 string
+	var b64AppProto string
+
+	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
+	if err != nil {
+		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
+	}
+
+	if targetResp.DeploymentTargetID == "" {
+		return errors.New("deployment target id is empty")
+	}
 
 	if len(porterYamlPath) != 0 {
 		porterYaml, err := os.ReadFile(filepath.Clean(porterYamlPath))
@@ -45,7 +54,18 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		if parseResp.B64AppProto == "" {
 			return errors.New("b64 app proto is empty")
 		}
-		yamlB64 = parseResp.B64AppProto
+		b64AppProto = parseResp.B64AppProto
+
+		// we only need to create the app if a porter yaml is provided (otherwise it must already exist)
+		createPorterAppDBEntryInp, err := createPorterAppDbEntryInputFromProtoAndEnv(parseResp.B64AppProto)
+		if err != nil {
+			return fmt.Errorf("error creating porter app db entry input from proto: %w", err)
+		}
+
+		err = client.CreatePorterAppDBEntry(ctx, cliConf.Project, cliConf.Cluster, createPorterAppDBEntryInp)
+		if err != nil {
+			return fmt.Errorf("error creating porter app db entry: %w", err)
+		}
 
 		// override app name if provided
 		appName, err = appNameFromB64AppProto(parseResp.B64AppProto)
@@ -53,16 +73,17 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("error getting app name from b64 app proto: %w", err)
 		}
 
-		color.New(color.FgGreen).Printf("Successfully parsed Porter YAML: applying app \"%s\"\n", appName) // nolint:errcheck,gosec
-	}
+		envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, targetResp.DeploymentTargetID, parseResp.EnvVariables, parseResp.EnvSecrets)
+		if err != nil {
+			return fmt.Errorf("error calling create or update app environment group endpoint: %w", err)
+		}
 
-	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
-	if err != nil {
-		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
-	}
+		b64AppProto, err = updateAppEnvGroupInProto(ctx, b64AppProto, envGroupResp.EnvGroupName, envGroupResp.EnvGroupVersion)
+		if err != nil {
+			return fmt.Errorf("error updating app env group in proto: %w", err)
+		}
 
-	if targetResp.DeploymentTargetID == "" {
-		return errors.New("deployment target id is empty")
+		color.New(color.FgGreen).Printf("Successfully parsed Porter YAML: applying app \"%s\"\n", appName) // nolint:errcheck,gosec
 	}
 
 	var commitSHA string
@@ -74,7 +95,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		commitSHA = commit.Sha
 	}
 
-	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, appName, yamlB64, targetResp.DeploymentTargetID, commitSHA)
+	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, appName, b64AppProto, targetResp.DeploymentTargetID, commitSHA)
 	if err != nil {
 		return fmt.Errorf("error calling validate endpoint: %w", err)
 	}
@@ -84,16 +105,6 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	}
 	base64AppProto := validateResp.ValidatedBase64AppProto
 
-	createPorterAppDBEntryInp, err := createPorterAppDbEntryInputFromProtoAndEnv(validateResp.ValidatedBase64AppProto)
-	if err != nil {
-		return fmt.Errorf("error creating porter app db entry input from proto: %w", err)
-	}
-
-	err = client.CreatePorterAppDBEntry(ctx, cliConf.Project, cliConf.Cluster, createPorterAppDBEntryInp)
-	if err != nil {
-		return fmt.Errorf("error creating porter app db entry: %w", err)
-	}
-
 	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, base64AppProto, targetResp.DeploymentTargetID, "", forceBuild)
 	if err != nil {
 		return fmt.Errorf("error calling apply endpoint: %w", err)
@@ -343,3 +354,42 @@ func imageTagFromBase64AppProto(base64AppProto string) (string, error) {
 
 	return app.Image.Tag, nil
 }
+
+func updateAppEnvGroupInProto(ctx context.Context, base64AppProto string, envGroupName string, envGroupVersion int) (string, error) {
+	var editedB64AppProto string
+
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return editedB64AppProto, fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return editedB64AppProto, fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	envGroupExists := false
+	for _, envGroup := range app.EnvGroups {
+		if envGroup.Name == envGroupName {
+			envGroup.Version = int64(envGroupVersion)
+			envGroupExists = true
+			break
+		}
+	}
+	if !envGroupExists {
+		app.EnvGroups = append(app.EnvGroups, &porterv1.EnvGroup{
+			Name:    envGroupName,
+			Version: int64(envGroupVersion),
+		})
+	}
+
+	marshalled, err := helpers.MarshalContractObject(ctx, app)
+	if err != nil {
+		return editedB64AppProto, fmt.Errorf("unable to marshal app back to json: %w", err)
+	}
+
+	editedB64AppProto = base64.StdEncoding.EncodeToString(marshalled)
+
+	return editedB64AppProto, nil
+}

+ 35 - 0
internal/porter_app/environment.go

@@ -2,11 +2,13 @@ package porter_app
 
 import (
 	"context"
+	"fmt"
 
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
@@ -80,3 +82,36 @@ func AppEnvironmentFromProto(ctx context.Context, inp AppEnvironmentFromProtoInp
 
 	return envGroups, nil
 }
+
+// AppEnvGroupName returns the name of the environment group for the app
+func AppEnvGroupName(ctx context.Context, appName string, deploymentTargetId string, clusterID uint, porterAppRepository repository.PorterAppRepository) (string, error) {
+	ctx, span := telemetry.NewSpan(ctx, "app-env-group-name")
+	defer span.End()
+
+	if appName == "" {
+		return "", telemetry.Error(ctx, span, nil, "app name is empty")
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	if deploymentTargetId == "" {
+		return "", telemetry.Error(ctx, span, nil, "deployment target id is empty")
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetId})
+
+	if clusterID == 0 {
+		return "", telemetry.Error(ctx, span, nil, "cluster id is empty")
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: clusterID})
+
+	porterApp, err := porterAppRepository.ReadPorterAppByName(clusterID, appName)
+	if err != nil {
+		return "", telemetry.Error(ctx, span, err, "error reading porter app by name")
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApp.ID})
+
+	if len(deploymentTargetId) < 6 {
+		return "", telemetry.Error(ctx, span, nil, "deployment target id is too short")
+	}
+
+	return fmt.Sprintf("%d-%s", porterApp.ID, deploymentTargetId[:6]), nil
+}

+ 12 - 11
internal/porter_app/parse.go

@@ -23,48 +23,49 @@ const (
 )
 
 // ParseYAML converts a Porter YAML file into a PorterApp proto object
-func ParseYAML(ctx context.Context, porterYaml []byte, appName string) (*porterv1.PorterApp, error) {
+func ParseYAML(ctx context.Context, porterYaml []byte, appName string) (*porterv1.PorterApp, map[string]string, error) {
 	ctx, span := telemetry.NewSpan(ctx, "porter-app-parse-yaml")
 	defer span.End()
 
 	if porterYaml == nil {
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml input is nil")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml input is nil")
 	}
 
 	version := &yamlVersion{}
 	err := yaml.Unmarshal(porterYaml, version)
 	if err != nil {
-		return nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+		return nil, nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
 	}
 
 	var appProto *porterv1.PorterApp
+	var envVariables map[string]string
 
 	switch version.Version {
 	case PorterYamlVersion_V2:
-		appProto, err = v2.AppProtoFromYaml(ctx, porterYaml, appName)
+		appProto, envVariables, err = v2.AppProtoFromYaml(ctx, porterYaml, appName)
 		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "error converting v2 yaml to proto")
+			return nil, nil, telemetry.Error(ctx, span, err, "error converting v2 yaml to proto")
 		}
 	// backwards compatibility for old porter.yaml files
 	// track this span in telemetry and reach out to customers who are still using old porter.yaml if they exist.
 	// once no one is converting from old porter.yaml, we can remove this code
 	case PorterYamlVersion_V1, "":
 		if appName == "" {
-			return nil, telemetry.Error(ctx, span, nil, "v1 porter yaml requires externally-provided app name")
+			return nil, nil, telemetry.Error(ctx, span, nil, "v1 porter yaml requires externally-provided app name")
 		}
-		appProto, err = v1.AppProtoFromYaml(ctx, porterYaml, appName)
+		appProto, envVariables, err = v1.AppProtoFromYaml(ctx, porterYaml, appName)
 		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "error converting v1 yaml to proto")
+			return nil, nil, telemetry.Error(ctx, span, err, "error converting v1 yaml to proto")
 		}
 	default:
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml version not supported")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml version not supported")
 	}
 
 	if appProto == nil {
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml output is nil")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml output is nil")
 	}
 
-	return appProto, nil
+	return appProto, envVariables, nil
 }
 
 // yamlVersion is a struct used to unmarshal the version field of a Porter YAML file

+ 6 - 9
internal/porter_app/parse_test.go

@@ -30,10 +30,15 @@ func TestParseYAML(t *testing.T) {
 			want, err := os.ReadFile(fmt.Sprintf("testdata/%s.yaml", tt.porterYamlFileName))
 			is.NoErr(err) // no error expected reading test file
 
-			got, err := ParseYAML(context.Background(), want, "test-app")
+			got, env, err := ParseYAML(context.Background(), want, "test-app")
 			is.NoErr(err) // umbrella chart values should convert to map[string]any without issues
 
 			diffProtoWithFailTest(t, is, tt.want, got)
+
+			is.Equal(env, map[string]string{
+				"PORT":     "8080",
+				"NODE_ENV": "production",
+			})
 		})
 	}
 }
@@ -98,10 +103,6 @@ var result_nobuild = &porterv1.PorterApp{
 			Type: 1,
 		},
 	},
-	Env: map[string]string{
-		"PORT":     "8080",
-		"NODE_ENV": "production",
-	},
 	Predeploy: &porterv1.Service{
 		Run:          "ls",
 		Instances:    0,
@@ -177,10 +178,6 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 			Type: 1,
 		},
 	},
-	Env: map[string]string{
-		"PORT":     "8080",
-		"NODE_ENV": "production",
-	},
 	Predeploy: &porterv1.Service{
 		Run:          "ls",
 		Instances:    0,

+ 11 - 12
internal/porter_app/v1/yaml.go

@@ -15,28 +15,27 @@ import (
 )
 
 // AppProtoFromYaml converts an old version Porter YAML file into a PorterApp proto object
-func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName string) (*porterv1.PorterApp, error) {
+func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName string) (*porterv1.PorterApp, map[string]string, error) {
 	ctx, span := telemetry.NewSpan(ctx, "v1-app-proto-from-yaml")
 	defer span.End()
 
 	if appName == "" {
-		return nil, telemetry.Error(ctx, span, nil, "app name is empty")
+		return nil, nil, telemetry.Error(ctx, span, nil, "app name is empty")
 	}
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
 
 	if porterYamlBytes == nil {
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
 	}
 
 	porterYaml := &PorterYAML{}
 	err := yaml.Unmarshal(porterYamlBytes, porterYaml)
 	if err != nil {
-		return nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+		return nil, nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
 	}
 
 	appProto := &porterv1.PorterApp{
 		Name: appName,
-		Env:  porterYaml.Env,
 	}
 
 	if porterYaml.Build != nil {
@@ -58,12 +57,12 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 			}
 		} else {
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "image", Value: porterYaml.Build.Image})
-			return nil, telemetry.Error(ctx, span, err, "error parsing image")
+			return nil, nil, telemetry.Error(ctx, span, err, "error parsing image")
 		}
 	}
 
 	if porterYaml.Apps != nil && porterYaml.Services != nil {
-		return nil, telemetry.Error(ctx, span, nil, "'apps' and 'services' are synonymous but both were defined")
+		return nil, nil, telemetry.Error(ctx, span, nil, "'apps' and 'services' are synonymous but both were defined")
 	}
 	var services map[string]Service
 	if porterYaml.Apps != nil {
@@ -75,7 +74,7 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 	}
 
 	if services == nil {
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
 	}
 
 	serviceProtoMap := make(map[string]*porterv1.Service, 0)
@@ -83,13 +82,13 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 		serviceType, err := protoEnumFromType(name, service)
 		if err != nil {
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "failing-service-name", Value: name})
-			return nil, telemetry.Error(ctx, span, err, "error getting service type")
+			return nil, nil, telemetry.Error(ctx, span, err, "error getting service type")
 		}
 
 		serviceProto, err := serviceProtoFromConfig(service, serviceType)
 		if err != nil {
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "failing-service-name", Value: name})
-			return nil, telemetry.Error(ctx, span, err, "error casting service config")
+			return nil, nil, telemetry.Error(ctx, span, err, "error casting service config")
 		}
 
 		serviceProtoMap[name] = serviceProto
@@ -99,12 +98,12 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 	if porterYaml.Release != nil {
 		predeployProto, err := serviceProtoFromConfig(*porterYaml.Release, porterv1.ServiceType_SERVICE_TYPE_JOB)
 		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "error casting predeploy config")
+			return nil, nil, telemetry.Error(ctx, span, err, "error casting predeploy config")
 		}
 		appProto.Predeploy = predeployProto
 	}
 
-	return appProto, nil
+	return appProto, porterYaml.Env, nil
 }
 
 func protoEnumFromType(name string, service Service) (porterv1.ServiceType, error) {

+ 8 - 9
internal/porter_app/v2/yaml.go

@@ -12,18 +12,18 @@ import (
 )
 
 // AppProtoFromYaml converts a Porter YAML file into a PorterApp proto object
-func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName string) (*porterv1.PorterApp, error) {
+func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName string) (*porterv1.PorterApp, map[string]string, error) {
 	ctx, span := telemetry.NewSpan(ctx, "v2-app-proto-from-yaml")
 	defer span.End()
 
 	if porterYamlBytes == nil {
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
 	}
 
 	porterYaml := &PorterYAML{}
 	err := yaml.Unmarshal(porterYamlBytes, porterYaml)
 	if err != nil {
-		return nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+		return nil, nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
 	}
 
 	// if the porter yaml is missing a name field, use the app name that is provided in the request
@@ -33,7 +33,6 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 
 	appProto := &porterv1.PorterApp{
 		Name: porterYaml.Name,
-		Env:  porterYaml.Env,
 	}
 
 	if porterYaml.Build != nil {
@@ -54,19 +53,19 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 	}
 
 	if porterYaml.Services == nil {
-		return nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
+		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
 	}
 
 	services := make(map[string]*porterv1.Service, 0)
 	for name, service := range porterYaml.Services {
 		serviceType, err := protoEnumFromType(name, service)
 		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "error getting service type")
+			return nil, nil, telemetry.Error(ctx, span, err, "error getting service type")
 		}
 
 		serviceProto, err := serviceProtoFromConfig(service, serviceType)
 		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "error casting service config")
+			return nil, nil, telemetry.Error(ctx, span, err, "error casting service config")
 		}
 
 		services[name] = serviceProto
@@ -76,12 +75,12 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, appName strin
 	if porterYaml.Predeploy != nil {
 		predeployProto, err := serviceProtoFromConfig(*porterYaml.Predeploy, porterv1.ServiceType_SERVICE_TYPE_JOB)
 		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "error casting predeploy config")
+			return nil, nil, telemetry.Error(ctx, span, err, "error casting predeploy config")
 		}
 		appProto.Predeploy = predeployProto
 	}
 
-	return appProto, nil
+	return appProto, porterYaml.Env, nil
 }
 
 // PorterYAML represents all the possible fields in a Porter YAML file