Kaynağa Gözat

implement env set command and handle relevant api changes (#4402)

ianedwards 2 yıl önce
ebeveyn
işleme
a7676e87c7

+ 27 - 0
api/client/env_groups.go

@@ -23,3 +23,30 @@ func (c *Client) GetLatestEnvGroupVariables(
 
 	return resp, err
 }
+
+// UpdateEnvGroupInput is the input for the UpdateEnvGroup method
+type UpdateEnvGroupInput struct {
+	ProjectID    uint
+	ClusterID    uint
+	EnvGroupName string
+	Variables    map[string]string
+	Secrets      map[string]string
+}
+
+// UpdateEnvGroup creates or updates an environment group with the provided variables
+func (c *Client) UpdateEnvGroup(
+	ctx context.Context,
+	inp UpdateEnvGroupInput,
+) error {
+	req := &environment_groups.UpdateEnvironmentGroupRequest{
+		Name:            inp.EnvGroupName,
+		Variables:       inp.Variables,
+		SecretVariables: inp.Secrets,
+	}
+
+	return c.postRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/environment-groups", inp.ProjectID, inp.ClusterID),
+		req,
+		nil,
+	)
+}

+ 30 - 56
api/client/porter_app.go

@@ -199,19 +199,22 @@ func (c *Client) GetAppManifests(
 
 // UpdateAppInput is the input struct to UpdateApp
 type UpdateAppInput struct {
-	ProjectID          uint
-	ClusterID          uint
-	Name               string
-	ImageTagOverride   string
-	GitSource          porter_app.GitSource
-	DeploymentTargetId string
-	CommitSHA          string
-	AppRevisionID      string
-	Base64AppProto     string
-	Base64PorterYAML   string
-	IsEnvOverride      bool
-	WithPredeploy      bool
-	Exact              bool
+	ProjectID            uint
+	ClusterID            uint
+	Name                 string
+	ImageTagOverride     string
+	GitSource            porter_app.GitSource
+	DeploymentTargetId   string
+	DeploymentTargetName string
+	CommitSHA            string
+	AppRevisionID        string
+	Base64AppProto       string
+	Base64PorterYAML     string
+	IsEnvOverride        bool
+	WithPredeploy        bool
+	Exact                bool
+	Variables            map[string]string
+	Secrets              map[string]string
 }
 
 // UpdateApp updates a porter app
@@ -222,17 +225,20 @@ func (c *Client) UpdateApp(
 	resp := &porter_app.UpdateAppResponse{}
 
 	req := &porter_app.UpdateAppRequest{
-		Name:               inp.Name,
-		GitSource:          inp.GitSource,
-		DeploymentTargetId: inp.DeploymentTargetId,
-		CommitSHA:          inp.CommitSHA,
-		ImageTagOverride:   inp.ImageTagOverride,
-		AppRevisionID:      inp.AppRevisionID,
-		Base64AppProto:     inp.Base64AppProto,
-		Base64PorterYAML:   inp.Base64PorterYAML,
-		IsEnvOverride:      inp.IsEnvOverride,
-		WithPredeploy:      inp.WithPredeploy,
-		Exact:              inp.Exact,
+		Name:                 inp.Name,
+		GitSource:            inp.GitSource,
+		DeploymentTargetId:   inp.DeploymentTargetId,
+		DeploymentTargetName: inp.DeploymentTargetName,
+		CommitSHA:            inp.CommitSHA,
+		ImageTagOverride:     inp.ImageTagOverride,
+		AppRevisionID:        inp.AppRevisionID,
+		Base64AppProto:       inp.Base64AppProto,
+		Base64PorterYAML:     inp.Base64PorterYAML,
+		IsEnvOverride:        inp.IsEnvOverride,
+		WithPredeploy:        inp.WithPredeploy,
+		Exact:                inp.Exact,
+		Variables:            inp.Variables,
+		Secrets:              inp.Secrets,
 	}
 
 	err := c.postRequest(
@@ -574,38 +580,6 @@ func (c *Client) ReportRevisionStatus(
 	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,
-	Base64AppProto string,
-) (*porter_app.UpdateAppEnvironmentResponse, error) {
-	resp := &porter_app.UpdateAppEnvironmentResponse{}
-
-	req := &porter_app.UpdateAppEnvironmentRequest{
-		DeploymentTargetID: deploymentTargetID,
-		Variables:          variables,
-		Secrets:            secrets,
-		HardUpdate:         false,
-		Base64AppProto:     Base64AppProto,
-	}
-
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/clusters/%d/apps/%s/update-environment",
-			projectID, clusterID, appName,
-		),
-		req,
-		resp,
-	)
-
-	return resp, err
-}
-
 // PorterYamlV2Pods gets all pods for a given deployment target id and app name
 func (c *Client) PorterYamlV2Pods(
 	ctx context.Context,

+ 17 - 24
api/server/handlers/environment_groups/create.go

@@ -13,7 +13,6 @@ import (
 	"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/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
@@ -49,6 +48,9 @@ type UpdateEnvironmentGroupRequest struct {
 
 	// SecretVariables are sensitive values. All values must be a string due to a kubernetes limitation.
 	SecretVariables map[string]string `json:"secret_variables"`
+
+	// IsEnvOverride is a flag to determine if provided variables should override or merge with existing variables
+	IsEnvOverride bool `json:"is_env_override"`
 }
 type UpdateEnvironmentGroupResponse struct {
 	// Name of the env group to create or update
@@ -78,14 +80,6 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 		telemetry.AttributeKV{Key: "environment-group-type", Value: request.Type},
 	)
 
-	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
-	}
-
-	var envGroup environment_groups.EnvironmentGroup
 	switch request.Type {
 	case "doppler":
 		_, err := c.Config().ClusterControlPlaneClient.CreateOrUpdateEnvGroup(ctx, connect.NewRequest(&porterv1.CreateOrUpdateEnvGroupRequest{
@@ -101,29 +95,28 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 			return
 		}
 
-		envGroup = environment_groups.EnvironmentGroup{
-			Name:         request.Name,
-			CreatedAtUTC: time.Now().UTC(),
-		}
 	default:
-		envGroup := environment_groups.EnvironmentGroup{
-			Name:            request.Name,
-			Variables:       request.Variables,
-			SecretVariables: request.SecretVariables,
-			CreatedAtUTC:    time.Now().UTC(),
-		}
-
-		err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, nil)
+		_, err := c.Config().ClusterControlPlaneClient.CreateOrUpdateEnvGroup(ctx, connect.NewRequest(&porterv1.CreateOrUpdateEnvGroupRequest{
+			ProjectId:            int64(cluster.ProjectID),
+			ClusterId:            int64(cluster.ID),
+			EnvGroupProviderType: porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_PORTER,
+			EnvGroupName:         request.Name,
+			EnvVars: &porterv1.EnvGroupVariables{
+				Normal: request.Variables,
+				Secret: request.SecretVariables,
+			},
+			IsEnvOverride: request.IsEnvOverride,
+		}))
 		if err != nil {
-			err := telemetry.Error(ctx, span, err, "unable to create or update environment group")
+			err := telemetry.Error(ctx, span, err, "unable to create environment group")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 	}
 
 	envGroupResponse := &UpdateEnvironmentGroupResponse{
-		Name:      envGroup.Name,
-		CreatedAt: envGroup.CreatedAtUTC,
+		Name:      request.Name,
+		CreatedAt: time.Now().UTC(),
 	}
 	c.WriteResult(w, r, envGroupResponse)
 }

+ 21 - 11
api/server/handlers/porter_app/update_app.go

@@ -60,6 +60,8 @@ type UpdateAppRequest struct {
 	GitSource GitSource `json:"git_source,omitempty"`
 	// DeploymentTargetId is the ID of the deployment target to apply the update to
 	DeploymentTargetId string `json:"deployment_target_id"`
+	// DeploymentTargetName is the name of the deployment target to apply the update to
+	DeploymentTargetName string `json:"deployment_target_name"`
 	// Variables is a map of environment variable names to values
 	Variables map[string]string `json:"variables"`
 	// Secrets is a map of secret names to values
@@ -116,12 +118,21 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	if request.DeploymentTargetId == "" {
-		err := telemetry.Error(ctx, span, nil, "deployment target id is empty")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
+
 	deploymentTargetID := request.DeploymentTargetId
+	deploymentTargetName := request.DeploymentTargetName
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+		telemetry.AttributeKV{Key: "deployment-target-name", Value: deploymentTargetName},
+	)
+
+	var deploymentTargetIdentifer *porterv1.DeploymentTargetIdentifier
+	if deploymentTargetID != "" || deploymentTargetName != "" {
+		deploymentTargetIdentifer = &porterv1.DeploymentTargetIdentifier{
+			Id:   deploymentTargetID,
+			Name: deploymentTargetName,
+		}
+	}
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "name", Value: request.Name},
@@ -258,12 +269,11 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	updateReq := connect.NewRequest(&porterv1.UpdateAppRequest{
-		ProjectId: int64(project.ID),
-		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
-			Id: deploymentTargetID,
-		},
-		App:           appProto,
-		AppRevisionId: request.AppRevisionID,
+		ProjectId:                  int64(project.ID),
+		ClusterId:                  int64(cluster.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifer,
+		App:                        appProto,
+		AppRevisionId:              request.AppRevisionID,
 		AppEnv: &porterv1.EnvGroupVariables{
 			Normal: envVariables,
 			Secret: request.Secrets,

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

@@ -1,476 +0,0 @@
-package porter_app
-
-import (
-	"context"
-	"encoding/base64"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/porter-dev/porter/internal/deployment_target"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	"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"
-
-	"github.com/porter-dev/api-contracts/generated/go/helpers"
-	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),
-	}
-}
-
-const (
-	// LabelKey_AppName is the label key for the app name
-	LabelKey_AppName = "porter.run/app-name"
-	// LabelKey_DeploymentTargetID is the label key for the deployment target id
-	LabelKey_DeploymentTargetID = "porter.run/deployment-target-id"
-	// LabelKey_PorterManaged is the label key signifying the resource is managed by porter
-	LabelKey_PorterManaged = "porter.run/managed"
-)
-
-// UpdateAppEnvironmentRequest represents the accepted fields on a request to the /apps/{porter_app_name}/environment-group endpoint
-type UpdateAppEnvironmentRequest struct {
-	Base64AppProto     string            `json:"b64_app_proto"`
-	DeploymentTargetID string            `json:"deployment_target_id"`
-	Variables          map[string]string `json:"variables"`
-	Secrets            map[string]string `json:"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 `json:"remove_missing"`
-}
-
-// UpdateAppEnvironmentResponse represents the fields on the response object from the /apps/{porter_app_name}/environment-group endpoint
-type UpdateAppEnvironmentResponse struct {
-	Base64AppProto string                                `json:"b64_app_proto"`
-	EnvGroups      []environment_groups.EnvironmentGroup `json:"env_groups"`
-}
-
-// 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
-	}
-	porterApp, err := c.Config().Repo.PorterApp().ReadPorterAppByName(cluster.ID, appName)
-	if err != nil {
-		err := telemetry.Error(ctx, span, nil, "error getting porter app by name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if porterApp.ID == 0 {
-		err := telemetry.Error(ctx, span, nil, "porter app not found")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApp.ID})
-
-	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})
-
-	appProto := &porterv1.PorterApp{}
-
-	if request.Base64AppProto == "" {
-		if appName == "" {
-			err := telemetry.Error(ctx, span, nil, "app name is empty and no base64 proto provided")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		appProto.Name = appName
-	} else {
-		decoded, err := base64.StdEncoding.DecodeString(request.Base64AppProto)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error decoding base yaml")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		err = helpers.UnmarshalContractObject(decoded, appProto)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error unmarshalling app proto")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-	}
-
-	if appProto.Name == "" {
-		err := telemetry.Error(ctx, span, nil, "app proto name is empty")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
-		ProjectID:          int64(project.ID),
-		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: request.DeploymentTargetID,
-		CCPClient:          c.Config().ClusterControlPlaneClient,
-	})
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	namespace := deploymentTarget.Namespace
-	isPreview := deploymentTarget.IsPreview
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "is-preview", Value: isPreview})
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "hard-update", Value: request.HardUpdate})
-
-	appEnvGroupName, 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, appEnvGroupName)
-	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 != ""})
-
-	previewTemplateEnvName, err := porter_app.AppTemplateEnvGroupName(ctx, appName, cluster.ID, c.Repo().PorterApp())
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting preview template env name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	// filter out preview template and app env groups
-	filteredEnvGroups := []*porterv1.EnvGroup{}
-	for _, envGroup := range appProto.EnvGroups {
-		if envGroup.GetName() != previewTemplateEnvName && envGroup.GetName() != appEnvGroupName {
-			filteredEnvGroups = append(filteredEnvGroups, envGroup)
-		}
-	}
-
-	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 {
-			// We cannot check if the values are the same because the existing secrets are substituted with dummy values. However, if the new value is a dummy value, then it is unchanged.
-			if _, ok := latestEnvironmentGroup.SecretVariables[key]; !ok || newValue != environment_groups.EnvGroupSecretDummyValue {
-				sameEnvGroup = false
-			}
-		}
-		if request.HardUpdate {
-			for key := range latestEnvironmentGroup.Variables {
-				if _, ok := request.Variables[key]; !ok {
-					sameEnvGroup = false
-				}
-			}
-			for key := range latestEnvironmentGroup.SecretVariables {
-				if _, ok := request.Secrets[key]; !ok {
-					sameEnvGroup = false
-				}
-			}
-		}
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "same-env-group", Value: sameEnvGroup})
-
-		if sameEnvGroup {
-			// even if the env group is the same, we still need to sync the latest versions of the other env groups
-			syncInp := syncLatestEnvGroupVersionsInput{
-				envGroups:          filteredEnvGroups,
-				appName:            appName,
-				namespace:          namespace,
-				deploymentTargetID: request.DeploymentTargetID,
-				k8sAgent:           agent,
-			}
-			latestEnvGroups, err := syncLatestEnvGroupVersions(ctx, syncInp)
-			if err != nil {
-				err := telemetry.Error(ctx, span, err, "error syncing latest env group versions")
-				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-				return
-			}
-
-			latestEnvGroups = append(latestEnvGroups, environment_groups.EnvironmentGroup{
-				Name:    latestEnvironmentGroup.Name,
-				Version: latestEnvironmentGroup.Version,
-			})
-
-			var protoEnvGroups []*porterv1.EnvGroup
-			for _, envGroup := range latestEnvGroups {
-				protoEnvGroups = append(protoEnvGroups, &porterv1.EnvGroup{
-					Name:    envGroup.Name,
-					Version: int64(envGroup.Version),
-				})
-			}
-			appProto.EnvGroups = protoEnvGroups
-
-			encodedApp, err := encodeAppProto(ctx, appProto)
-			if err != nil {
-				err := telemetry.Error(ctx, span, err, "error encoding app proto")
-				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-				return
-			}
-
-			res := &UpdateAppEnvironmentResponse{
-				EnvGroups:      latestEnvGroups,
-				Base64AppProto: encodedApp,
-			}
-
-			c.WriteResult(w, r, res)
-			return
-		}
-	}
-
-	// if this app does not have a default env group for this deployment target and is a preview
-	// then use the preview template env group as the default
-	// this should only run when the app is first deployed to a given deployment target
-	if latestEnvironmentGroup.Name == "" && isPreview {
-		latestEnvironmentGroup, err = environment_groups.LatestBaseEnvironmentGroup(ctx, agent, previewTemplateEnvName)
-		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
-		}
-	}
-
-	variables := make(map[string]string)
-	secrets := make(map[string]string)
-
-	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 {
-		if len(key) > 0 && len(value) > 0 {
-			variables[key] = value
-		}
-	}
-	for key, value := range request.Secrets {
-		if len(key) > 0 && len(value) > 0 {
-			secrets[key] = value
-		}
-	}
-
-	envGroup := environment_groups.EnvironmentGroup{
-		Name:            appEnvGroupName,
-		Variables:       variables,
-		SecretVariables: secrets,
-		CreatedAtUTC:    time.Now().UTC(),
-	}
-
-	additionalEnvGroupLabels := map[string]string{
-		LabelKey_AppName:                                  appName,
-		LabelKey_DeploymentTargetID:                       request.DeploymentTargetID,
-		environment_groups.LabelKey_DefaultAppEnvironment: "true",
-		LabelKey_PorterManaged:                            "true",
-	}
-
-	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, additionalEnvGroupLabels)
-	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: appEnvGroupName,
-		TargetNamespace:          namespace,
-	}
-
-	syncedAppEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, agent, inp, additionalEnvGroupLabels)
-	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: syncedAppEnvironment.EnvironmentGroupVersionedName})
-
-	syncInp := syncLatestEnvGroupVersionsInput{
-		envGroups:          filteredEnvGroups,
-		appName:            appName,
-		namespace:          namespace,
-		deploymentTargetID: request.DeploymentTargetID,
-		k8sAgent:           agent,
-	}
-	latestEnvGroups, err := syncLatestEnvGroupVersions(ctx, syncInp)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error syncing latest env group versions")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	split := strings.Split(syncedAppEnvironment.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
-	}
-
-	latestEnvGroups = append(latestEnvGroups, environment_groups.EnvironmentGroup{
-		Name:    split[0],
-		Version: version,
-	})
-
-	var protoEnvGroups []*porterv1.EnvGroup
-	for _, envGroup := range latestEnvGroups {
-		protoEnvGroups = append(protoEnvGroups, &porterv1.EnvGroup{
-			Name:    envGroup.Name,
-			Version: int64(envGroup.Version),
-		})
-	}
-	appProto.EnvGroups = protoEnvGroups
-
-	encodedApp, err := encodeAppProto(ctx, appProto)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error encoding app proto")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	res := &UpdateAppEnvironmentResponse{
-		EnvGroups:      latestEnvGroups,
-		Base64AppProto: encodedApp,
-	}
-
-	c.WriteResult(w, r, res)
-}
-
-type syncLatestEnvGroupVersionsInput struct {
-	// envGroups is the list of env groups to sync. We only need the names and will get the latest version of each from the porter-env-group ns
-	envGroups []*porterv1.EnvGroup
-	// appName is the name of the app
-	appName string
-	// namespace is the namespace to sync the latest versions to
-	namespace string
-	// deploymentTargetID is the id of the deployment target
-	deploymentTargetID string
-	// k8sAgent is the kubernetes agent
-	k8sAgent *kubernetes.Agent
-}
-
-// syncLatestEnvGroupVersions syncs the latest versions of the env groups to the namespace where an app is deployed
-func syncLatestEnvGroupVersions(ctx context.Context, inp syncLatestEnvGroupVersionsInput) ([]environment_groups.EnvironmentGroup, error) {
-	ctx, span := telemetry.NewSpan(ctx, "sync-latest-env-group-versions")
-	defer span.End()
-
-	var envGroups []environment_groups.EnvironmentGroup
-
-	if inp.deploymentTargetID == "" {
-		return envGroups, telemetry.Error(ctx, span, nil, "deployment target id is empty")
-	}
-	if inp.appName == "" {
-		return envGroups, telemetry.Error(ctx, span, nil, "app name is empty")
-	}
-	if inp.namespace == "" {
-		return envGroups, telemetry.Error(ctx, span, nil, "namespace is empty")
-	}
-	if inp.k8sAgent == nil {
-		return envGroups, telemetry.Error(ctx, span, nil, "k8s agent is nil")
-	}
-
-	for _, envGroup := range inp.envGroups {
-		if envGroup == nil {
-			continue
-		}
-
-		additionalEnvGroupLabels := map[string]string{
-			LabelKey_AppName:            inp.appName,
-			LabelKey_DeploymentTargetID: inp.deploymentTargetID,
-			LabelKey_PorterManaged:      "true",
-		}
-
-		syncedEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, inp.k8sAgent, environment_groups.SyncLatestVersionToNamespaceInput{
-			TargetNamespace:          inp.namespace,
-			BaseEnvironmentGroupName: envGroup.GetName(),
-		}, additionalEnvGroupLabels)
-		if err != nil {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-name", Value: envGroup.GetName()})
-			return envGroups, telemetry.Error(ctx, span, err, "error syncing latest version to namespace")
-		}
-
-		split := strings.Split(syncedEnvironment.EnvironmentGroupVersionedName, ".")
-		if len(split) != 2 {
-			return envGroups, telemetry.Error(ctx, span, err, "unexpected environment group versioned name")
-		}
-
-		version, err := strconv.Atoi(split[1])
-		if err != nil {
-			return envGroups, telemetry.Error(ctx, span, err, "error converting environment group version to int")
-		}
-
-		envGroups = append(envGroups, environment_groups.EnvironmentGroup{
-			Name:    split[0],
-			Version: version,
-		})
-	}
-
-	return envGroups, nil
-}

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

@@ -1502,35 +1502,6 @@ 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,
-	})
-
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/env -> porter_app.NewGetAppEnvHandler
 	getAppEnvEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 106 - 12
cli/cmd/commands/env.go

@@ -26,13 +26,31 @@ type envVariables struct {
 func registerCommand_Env(cliConf config.CLIConfig) *cobra.Command {
 	envCmd := &cobra.Command{
 		Use:   "env",
-		Args:  cobra.MinimumNArgs(1),
+		Args:  cobra.MinimumNArgs(0),
 		Short: "Manage environment variables for a project",
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			if len(cmd.Commands()) == 1 {
+				return nil
+			}
+
+			if appName == "" && envGroupName == "" {
+				return fmt.Errorf("must specify either --app or --group")
+			}
+			if appName != "" && envGroupName != "" {
+				return fmt.Errorf("only one of --app or --group can be specified")
+			}
+
+			return nil
+		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			return cmd.Help()
 		},
 	}
 
+	envCmd.PersistentFlags().StringVarP(&appName, "app", "a", "", "app name")
+	envCmd.PersistentFlags().StringVarP(&envGroupName, "group", "g", "", "environment group name")
+	envCmd.PersistentFlags().StringVarP(&deploymentTargetName, "target", "x", "", "the name of the deployment target for the app")
+
 	pullCommand := &cobra.Command{
 		Use:   "pull",
 		Short: "Pull environment variables for an app or environment group",
@@ -44,25 +62,44 @@ Optionally, specify a file to write the environment variables to. Otherwise the
 			return checkLoginAndRunWithConfig(cmd, cliConf, args, pullEnv)
 		},
 	}
-
-	pullCommand.Flags().StringVarP(&appName, "app", "a", "", "app name")
-	pullCommand.Flags().StringVarP(&envGroupName, "group", "g", "", "environment group name")
 	pullCommand.Flags().StringVarP(&envFilePath, "file", "f", "", "file to write environment variables to")
-	pullCommand.Flags().StringVarP(&deploymentTargetName, "target", "x", "", "the name of the deployment target for the app")
+
+	setCommand := &cobra.Command{
+		Use:   "set",
+		Short: "Set environment variables for an app or environment group",
+		Long: `Set environment variables for an app or environment group.
+
+Both variables and secrets can be specified as key-value pairs.`,
+		Args: cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return checkLoginAndRunWithConfig(cmd, cliConf, args, setEnv)
+		},
+	}
+	setCommand.Flags().StringToStringP("variables", "v", nil, "variables to set")
+	setCommand.Flags().StringToStringP("secrets", "s", nil, "secrets to set")
+
+	unsetCommand := &cobra.Command{
+		Use:   "unset",
+		Short: "Unset environment variables for an app or environment group",
+		Long: `Unset environment variables for an app or environment group.
+
+Both variables and secrets can be specified as keys.`,
+		Args: cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return checkLoginAndRunWithConfig(cmd, cliConf, args, unsetEnv)
+		},
+	}
+	unsetCommand.Flags().StringSliceP("variables", "v", nil, "variables to unset")
+	unsetCommand.Flags().StringSliceP("secrets", "s", nil, "secrets to unset")
 
 	envCmd.AddCommand(pullCommand)
+	envCmd.AddCommand(setCommand)
+	envCmd.AddCommand(unsetCommand)
 
 	return envCmd
 }
 
 func pullEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
-	if appName == "" && envGroupName == "" {
-		return fmt.Errorf("must specify either --app or --group")
-	}
-	if appName != "" && envGroupName != "" {
-		return fmt.Errorf("only one of --app or --group can be specified")
-	}
-
 	var envVars envVariables
 
 	if appName != "" {
@@ -117,6 +154,63 @@ func pullEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, clie
 	return nil
 }
 
+func setEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	var envVars envVariables
+
+	variables, err := cmd.Flags().GetStringToString("variables")
+	if err != nil {
+		return fmt.Errorf("could not get variables: %w", err)
+	}
+
+	secrets, err := cmd.Flags().GetStringToString("secrets")
+	if err != nil {
+		return fmt.Errorf("could not get secrets: %w", err)
+	}
+
+	envVars = envVariables{
+		Variables: variables,
+		Secrets:   secrets,
+	}
+
+	if appName != "" {
+		color.New(color.FgGreen).Printf("Setting environment variables for app %s...\n", appName) // nolint:errcheck,gosec
+
+		_, err := client.UpdateApp(ctx, api.UpdateAppInput{
+			ProjectID:            cliConf.Project,
+			ClusterID:            cliConf.Cluster,
+			Name:                 appName,
+			DeploymentTargetName: deploymentTargetName,
+			Variables:            envVars.Variables,
+			Secrets:              envVars.Secrets,
+		})
+		if err != nil {
+			return fmt.Errorf("could not set app env variables: %w", err)
+		}
+	}
+
+	if envGroupName != "" {
+		color.New(color.FgGreen).Printf("Setting environment variables for environment group %s...\n", envGroupName) // nolint:errcheck,gosec
+
+		err := client.UpdateEnvGroup(ctx, api.UpdateEnvGroupInput{
+			ProjectID:    cliConf.Project,
+			ClusterID:    cliConf.Cluster,
+			EnvGroupName: envGroupName,
+			Variables:    envVars.Variables,
+			Secrets:      envVars.Secrets,
+		})
+		if err != nil {
+			return fmt.Errorf("could not set env group env variables: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func unsetEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	fmt.Println("This command is not supported for your project. Contact support@porter.run for more information.")
+	return nil
+}
+
 func writeEnvFile(envFilePath string, envVars envVariables) error {
 	// open existing file or create new file: https://pkg.go.dev/os#example-OpenFile-Append
 	envFile, err := os.OpenFile(envFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) // nolint:gosec

+ 38 - 53
dashboard/src/main/home/env-dashboard/CreateEnvGroup.tsx

@@ -1,26 +1,29 @@
-import React, { useState, useEffect, useContext, useMemo } from 'react';
-import styled from 'styled-components';
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
 import { FormProvider, useForm } from "react-hook-form";
 import { withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
 
-import api from 'shared/api';
-import { Context } from 'shared/Context';
-import { type EnvGroupFormData, envGroupFormValidator } from 'lib/env-groups/types';
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import {
+  envGroupFormValidator,
+  type EnvGroupFormData,
+} from "lib/env-groups/types";
 
-import envGrad from 'assets/env-group-grad.svg';
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { envGroupPath } from "shared/util";
+import envGrad from "assets/env-group-grad.svg";
 
-import Error from "components/porter/Error";
-import Back from "components/porter/Back";
-import DashboardHeader from '../cluster-dashboard/DashboardHeader';
-import VerticalSteps from 'components/porter/VerticalSteps';
-import Text from 'components/porter/Text';
-import Spacer from 'components/porter/Spacer';
-import { ControlledInput } from 'components/porter/ControlledInput';
-import Button from 'components/porter/Button';
-import EnvGroupArray, { type KeyValueType } from './EnvGroupArray';
-import axios from 'axios';
-import {envGroupPath} from "shared/util";
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import EnvGroupArray, { type KeyValueType } from "./EnvGroupArray";
 
 const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
   const { currentProject, currentCluster } = useContext(Context);
@@ -37,12 +40,12 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
           hidden: false,
           locked: false,
           deleted: false,
-        }
-      ]
-    }
+        },
+      ],
+    },
   });
 
-  const { 
+  const {
     formState: { isValidating, isSubmitting, errors },
     register,
     watch,
@@ -77,30 +80,6 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
     const secretEnvVariables: Record<string, string> = {};
     const envVariable = data.envVariables;
     try {
-
-      // Create env group namespace if it doesn't exist
-      const res = await api.getNamespaces(
-        "<token>",
-        {},
-        {
-          id: currentProject?.id ?? -1,
-          cluster_id: currentCluster?.id ?? -1,
-        }
-      );
-      const namespaceExists = res.data.some((n: { name: string }) => n.name === "porter-env-group");
-      if (!namespaceExists) {
-        await api.createNamespace(
-          "<token>",
-          {
-            name: "porter-env-group",
-          },
-          {
-            id: currentProject?.id ?? -1,
-            cluster_id: currentCluster?.id ?? -1,
-          }
-        );
-      }
-
       // Old env var create logic
       envVariable
         .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
@@ -137,14 +116,17 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
           name: data.name,
           variables: apiEnvVariables,
           secret_variables: secretEnvVariables,
+          is_env_override: true,
         },
         {
           id: currentProject?.id ?? -1,
           cluster_id: currentCluster?.id ?? -1,
         }
-      )
-        
-      history.push(envGroupPath(currentProject, `/${data.name}/env-vars?created=true`));
+      );
+
+      history.push(
+        envGroupPath(currentProject, `/${data.name}/env-vars?created=true`)
+      );
     } catch (err) {
       const errorMessage =
         axios.isAxiosError(err) && err.response?.data?.error
@@ -192,7 +174,9 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                       placeholder="ex: academic-sophon-db"
                       type="text"
                       width="320px"
-                      error={name?.length > 0 ? errors.name?.message : undefined}
+                      error={
+                        name?.length > 0 ? errors.name?.message : undefined
+                      }
                       {...register("name")}
                     />
                   </>,
@@ -200,7 +184,8 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                     <Text size={16}>Environment variables</Text>
                     <Spacer y={0.5} />
                     <Text color="helper">
-                      Set environment-specific configuration including evironment variables and secrets.
+                      Set environment-specific configuration including
+                      evironment variables and secrets.
                     </Text>
                     <Spacer height="15px" />
                     <EnvGroupArray
@@ -220,7 +205,7 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                     width="140px"
                   >
                     Deploy env group
-                  </Button>
+                  </Button>,
                 ]}
               />
             </form>
@@ -230,7 +215,7 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
       </Div>
     </CenterWrapper>
   );
-}
+};
 
 export default withRouter(CreateEnvGroup);
 
@@ -270,4 +255,4 @@ const CenterWrapper = styled.div`
 const DarkMatter = styled.div<{ antiHeight?: string }>`
   width: 100%;
   margin-top: ${(props) => props.antiHeight || "-5px"};
-`;
+`;

+ 3 - 2
dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx

@@ -71,8 +71,8 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
     ).map(([key, value]) => ({
       key,
       value,
-      hidden: (value as string).includes("PORTERSECRET"),
-      locked: (value as string).includes("PORTERSECRET"),
+      hidden: (value ).includes("PORTERSECRET"),
+      locked: (value ).includes("PORTERSECRET"),
       deleted: false,
     }));
     const secretVariables = Object.entries(
@@ -139,6 +139,7 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
             name: envGroup.name,
             variables: apiEnvVariables,
             secret_variables: secretEnvVariables,
+            is_env_override: true,
           },
           {
             id: currentProject?.id ?? -1,

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

@@ -2295,6 +2295,7 @@ const createEnvironmentGroups = baseApi<
     secret_variables?: Record<string, string>;
     type?: string;
     auth_token?: string;
+    is_env_override?: boolean;
   },
   {
     id: number;

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.118
+	github.com/porter-dev/api-contracts v0.2.120
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1523,8 +1523,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.118 h1:xiDEn+KMYMmKSpSUm27wgBYnOY2cA+bDdIpEJHf54Bo=
-github.com/porter-dev/api-contracts v0.2.118/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.120 h1:0PgDKwq9879NsTTvObtVxFVQI8rL7B0OA1i91+zfzs0=
+github.com/porter-dev/api-contracts v0.2.120/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=