Przeglądaj źródła

fix merge conflicts with master

Alexander Belanger 4 lat temu
rodzic
commit
fe8f896145

+ 21 - 0
api/client/k8s.go

@@ -91,6 +91,27 @@ func (c *Client) GetEnvGroup(
 	return resp, err
 }
 
+func (c *Client) CloneEnvGroup(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace string,
+	req *types.CloneEnvGroupRequest,
+) (*types.EnvGroup, error) {
+	resp := &types.EnvGroup{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/envgroup/clone",
+			projectID, clusterID,
+			namespace,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) GetRelease(
 	ctx context.Context,
 	projectID, clusterID uint,

+ 2 - 2
api/server/handlers/namespace/clone_env_group.go

@@ -46,7 +46,7 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, request.Namespace, request.Version)
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, request.Version)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -59,7 +59,7 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
 		Name:      request.CloneName,
-		Namespace: namespace,
+		Namespace: request.Namespace,
 		Variables: envGroup.Variables,
 	})
 

+ 9 - 9
api/server/handlers/namespace/get_env_group.go

@@ -1,9 +1,9 @@
 package namespace
 
 import (
-	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -11,7 +11,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"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -51,13 +50,14 @@ func (c *GetEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, request.Version)
 
-	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("env group not found"),
-			http.StatusNotFound,
-		))
-		return
-	} else if err != nil {
+	if err != nil {
+		if strings.Contains(err.Error(), "not found") {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("env group not found"),
+				http.StatusNotFound),
+			)
+			return
+		}
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 5 - 5
api/server/router/namespace.go

@@ -63,7 +63,7 @@ func getNamespaceRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/envgroups/list",
+				RelativePath: relPath + "/envgroup/list",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -85,14 +85,14 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroups/clone -> namespace.NewCloneEnvGroupHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup/clone -> namespace.NewCloneEnvGroupHandler
 	cloneEnvGroupEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/envgroups/clone",
+				RelativePath: relPath + "/envgroup/clone",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,

+ 219 - 108
cli/cmd/apply.go

@@ -98,25 +98,62 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []str
 	worker.RegisterDriver("porter.deploy", NewPorterDriver)
 	worker.SetDefaultDriver("porter.deploy")
 
-	deplNamespace := os.Getenv("PORTER_NAMESPACE")
+	if hasDeploymentHookEnvVars() {
+		deplNamespace := os.Getenv("PORTER_NAMESPACE")
 
-	if deplNamespace == "" {
-		return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
-	}
+		if deplNamespace == "" {
+			return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
+		}
 
-	deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
+		deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
 
-	if err != nil {
-		return err
+		if err != nil {
+			return err
+		}
+
+		worker.RegisterHook("deployment", deploymentHook)
 	}
 
-	worker.RegisterHook("deployment", deploymentHook)
+	cloneEnvGroupHook := NewCloneEnvGroupHook(client, resGroup)
+	worker.RegisterHook("cloneenvgroup", cloneEnvGroupHook)
 
 	return worker.Apply(resGroup, &switchboardTypes.ApplyOpts{
 		BasePath: basePath,
 	})
 }
 
+func hasDeploymentHookEnvVars() bool {
+	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr == "" {
+		return false
+	}
+
+	if prIDStr := os.Getenv("PORTER_PULL_REQUEST_ID"); prIDStr == "" {
+		return false
+	}
+
+	if branchName := os.Getenv("PORTER_BRANCH_NAME"); branchName == "" {
+		return false
+	}
+
+	if actionIDStr := os.Getenv("PORTER_ACTION_ID"); actionIDStr == "" {
+		return false
+	}
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName == "" {
+		return false
+	}
+
+	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner == "" {
+		return false
+	}
+
+	if prName := os.Getenv("PORTER_PR_NAME"); prName == "" {
+		return false
+	}
+
+	return true
+}
+
 type Source struct {
 	Name          string
 	Repo          string
@@ -135,6 +172,8 @@ type ApplicationConfig struct {
 	WaitForJob bool
 
 	Build struct {
+		ForceBuild bool
+		ForcePush  bool
 		Method     string
 		Context    string
 		Dockerfile string
@@ -143,6 +182,8 @@ type ApplicationConfig struct {
 		Buildpacks []string
 	}
 
+	EnvGroups []types.EnvGroupMeta
+
 	Values map[string]interface{}
 }
 
@@ -161,18 +202,24 @@ func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts)
 		output:      make(map[string]interface{}),
 	}
 
-	err := driver.getSource(resource.Source)
+	source := &Source{}
 
+	err := getSource(resource.Source, source)
 	if err != nil {
 		return nil, err
 	}
 
-	err = driver.getTarget(resource.Target)
+	driver.source = source
+
+	target := &Target{}
 
+	err = getTarget(resource.Target, target)
 	if err != nil {
 		return nil, err
 	}
 
+	driver.target = target
+
 	return driver, nil
 }
 
@@ -310,6 +357,7 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 		LocalDockerfile: appConfig.Build.Dockerfile,
 		OverrideTag:     tag,
 		Method:          deploy.DeployBuildType(method),
+		EnvGroups:       appConfig.EnvGroups,
 	}
 
 	if shouldCreate {
@@ -405,7 +453,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 	if appConf.Build.Method == "registry" {
 		subdomain, err = createAgent.CreateFromRegistry(appConf.Build.Image, appConf.Values)
 	} else {
-		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig)
+		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig, appConf.Build.ForceBuild)
 	}
 
 	if err != nil {
@@ -453,13 +501,13 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 			}
 		}
 
-		err = updateAgent.Build(buildConfig)
+		err = updateAgent.Build(buildConfig, appConf.Build.ForceBuild)
 
 		if err != nil {
 			return nil, err
 		}
 
-		err = updateAgent.Push()
+		err = updateAgent.Push(appConf.Build.ForcePush)
 
 		if err != nil {
 			return nil, err
@@ -497,94 +545,90 @@ func (d *Driver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *Driver) getSource(genericSource map[string]interface{}) error {
-	d.source = &Source{}
-
+func getSource(input map[string]interface{}, output *Source) error {
 	// first read from env vars
-	d.source.Name = os.Getenv("PORTER_SOURCE_NAME")
-	d.source.Repo = os.Getenv("PORTER_SOURCE_REPO")
-	d.source.Version = os.Getenv("PORTER_SOURCE_VERSION")
+	output.Name = os.Getenv("PORTER_SOURCE_NAME")
+	output.Repo = os.Getenv("PORTER_SOURCE_REPO")
+	output.Version = os.Getenv("PORTER_SOURCE_VERSION")
 
 	// next, check for values in the YAML file
-	if d.source.Name == "" {
-		if name, ok := genericSource["name"]; ok {
+	if output.Name == "" {
+		if name, ok := input["name"]; ok {
 			nameVal, ok := name.(string)
 			if !ok {
 				return fmt.Errorf("invalid name provided")
 			}
-			d.source.Name = nameVal
+			output.Name = nameVal
 		}
 	}
 
-	if d.source.Name == "" {
+	if output.Name == "" {
 		return fmt.Errorf("source name required")
 	}
 
-	if d.source.Repo == "" {
-		if repo, ok := genericSource["repo"]; ok {
+	if output.Repo == "" {
+		if repo, ok := input["repo"]; ok {
 			repoVal, ok := repo.(string)
 			if !ok {
 				return fmt.Errorf("invalid repo provided")
 			}
-			d.source.Repo = repoVal
+			output.Repo = repoVal
 		}
 	}
 
-	if d.source.Version == "" {
-		if version, ok := genericSource["version"]; ok {
+	if output.Version == "" {
+		if version, ok := input["version"]; ok {
 			versionVal, ok := version.(string)
 			if !ok {
 				return fmt.Errorf("invalid version provided")
 			}
-			d.source.Version = versionVal
+			output.Version = versionVal
 		}
 	}
 
 	// lastly, just put in the defaults
-	if d.source.Version == "" {
-		d.source.Version = "latest"
+	if output.Version == "" {
+		output.Version = "latest"
 	}
 
-	d.source.IsApplication = d.source.Repo == "https://charts.getporter.dev"
+	output.IsApplication = output.Repo == "https://charts.getporter.dev"
 
-	if d.source.Repo == "" {
-		d.source.Repo = "https://charts.getporter.dev"
+	if output.Repo == "" {
+		output.Repo = "https://charts.getporter.dev"
 
-		values, err := existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
+		values, err := existsInRepo(output.Name, output.Version, output.Repo)
 
 		if err == nil {
 			// found in "https://charts.getporter.dev"
-			d.source.SourceValues = values
-			d.source.IsApplication = true
+			output.SourceValues = values
+			output.IsApplication = true
 			return nil
 		}
 
-		d.source.Repo = "https://chart-addons.getporter.dev"
+		output.Repo = "https://chart-addons.getporter.dev"
 
-		values, err = existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
+		values, err = existsInRepo(output.Name, output.Version, output.Repo)
 
 		if err == nil {
 			// found in https://chart-addons.getporter.dev
-			d.source.SourceValues = values
+			output.SourceValues = values
 			return nil
 		}
 
 		return fmt.Errorf("source does not exist in any repo")
 	}
 
-	return fmt.Errorf("source '%s' does not exist in repo '%s'", d.source.Name, d.source.Repo)
+	return fmt.Errorf("source '%s' does not exist in repo '%s'", output.Name, output.Repo)
 }
 
-func (d *Driver) getTarget(genericTarget map[string]interface{}) error {
-	d.target = &Target{}
-
+func getTarget(input map[string]interface{}, output *Target) error {
 	// first read from env vars
 	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {
 		project, err := strconv.Atoi(projectEnv)
 		if err != nil {
 			return err
 		}
-		d.target.Project = uint(project)
+		output.Project = uint(project)
 	}
 
 	if clusterEnv := os.Getenv("PORTER_CLUSTER"); clusterEnv != "" {
@@ -592,51 +636,51 @@ func (d *Driver) getTarget(genericTarget map[string]interface{}) error {
 		if err != nil {
 			return err
 		}
-		d.target.Cluster = uint(cluster)
+		output.Cluster = uint(cluster)
 	}
 
-	d.target.Namespace = os.Getenv("PORTER_NAMESPACE")
+	output.Namespace = os.Getenv("PORTER_NAMESPACE")
 
 	// next, check for values in the YAML file
-	if d.target.Project == 0 {
-		if project, ok := genericTarget["project"]; ok {
+	if output.Project == 0 {
+		if project, ok := input["project"]; ok {
 			projectVal, ok := project.(uint)
 			if !ok {
 				return fmt.Errorf("project value must be an integer")
 			}
-			d.target.Project = projectVal
+			output.Project = projectVal
 		}
 	}
 
-	if d.target.Cluster == 0 {
-		if cluster, ok := genericTarget["cluster"]; ok {
+	if output.Cluster == 0 {
+		if cluster, ok := input["cluster"]; ok {
 			clusterVal, ok := cluster.(uint)
 			if !ok {
 				return fmt.Errorf("cluster value must be an integer")
 			}
-			d.target.Cluster = clusterVal
+			output.Cluster = clusterVal
 		}
 	}
 
-	if d.target.Namespace == "" {
-		if namespace, ok := genericTarget["namespace"]; ok {
+	if output.Namespace == "" {
+		if namespace, ok := input["namespace"]; ok {
 			namespaceVal, ok := namespace.(string)
 			if !ok {
 				return fmt.Errorf("invalid namespace provided")
 			}
-			d.target.Namespace = namespaceVal
+			output.Namespace = namespaceVal
 		}
 	}
 
 	// lastly, just put in the defaults
-	if d.target.Project == 0 {
-		d.target.Project = config.Project
+	if output.Project == 0 {
+		output.Project = config.Project
 	}
-	if d.target.Cluster == 0 {
-		d.target.Cluster = config.Cluster
+	if output.Cluster == 0 {
+		output.Cluster = config.Cluster
 	}
-	if d.target.Namespace == "" {
-		d.target.Namespace = "default"
+	if output.Namespace == "" {
+		output.Namespace = "default"
 	}
 
 	return nil
@@ -699,30 +743,24 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 		namespace:     namespace,
 	}
 
-	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr != "" {
-		ghID, err := strconv.Atoi(ghIDStr)
+	ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID")
+	ghID, err := strconv.Atoi(ghIDStr)
 
-		if err != nil {
-			return nil, err
-		}
-
-		res.gitInstallationID = uint(ghID)
-	} else if ghIDStr == "" {
-		return nil, fmt.Errorf("Git installation ID must be defined, set by PORTER_GIT_INSTALLATION_ID")
+	if err != nil {
+		return nil, err
 	}
 
-	if prIDStr := os.Getenv("PORTER_PULL_REQUEST_ID"); prIDStr != "" {
-		prID, err := strconv.Atoi(prIDStr)
+	res.gitInstallationID = uint(ghID)
 
-		if err != nil {
-			return nil, err
-		}
+	prIDStr := os.Getenv("PORTER_PULL_REQUEST_ID")
+	prID, err := strconv.Atoi(prIDStr)
 
-		res.prID = uint(prID)
-	} else if prIDStr == "" {
-		return nil, fmt.Errorf("Pull request ID must be defined, set by PORTER_PULL_REQUEST_ID")
+	if err != nil {
+		return nil, err
 	}
 
+	res.prID = uint(prID)
+
 	res.projectID = config.Project
 
 	if res.projectID == 0 {
@@ -735,41 +773,26 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 		return nil, fmt.Errorf("cluster id must be set")
 	}
 
-	if branchName := os.Getenv("PORTER_BRANCH_NAME"); branchName != "" {
-		res.branch = branchName
-	} else if branchName == "" {
-		return nil, fmt.Errorf("Branch name must be defined, set by PORTER_BRANCH_NAME")
-	}
+	branchName := os.Getenv("PORTER_BRANCH_NAME")
+	res.branch = branchName
 
-	if actionIDStr := os.Getenv("PORTER_ACTION_ID"); actionIDStr != "" {
-		actionID, err := strconv.Atoi(actionIDStr)
+	actionIDStr := os.Getenv("PORTER_ACTION_ID")
+	actionID, err := strconv.Atoi(actionIDStr)
 
-		if err != nil {
-			return nil, err
-		}
-
-		res.actionID = uint(actionID)
-	} else if actionIDStr == "" {
-		return nil, fmt.Errorf("Action Run ID must be defined, set by PORTER_ACTION_ID")
+	if err != nil {
+		return nil, err
 	}
 
-	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
-		res.repoName = repoName
-	} else if repoName == "" {
-		return nil, fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
-	}
+	res.actionID = uint(actionID)
 
-	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-		res.repoOwner = repoOwner
-	} else if repoOwner == "" {
-		return nil, fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
-	}
+	repoName := os.Getenv("PORTER_REPO_NAME")
+	res.repoName = repoName
 
-	if prName := os.Getenv("PORTER_PR_NAME"); prName != "" {
-		res.prName = prName
-	} else if prName == "" {
-		return nil, fmt.Errorf("PR Name must be supplied, set by PORTER_PR_NAME")
-	}
+	repoOwner := os.Getenv("PORTER_REPO_OWNER")
+	res.repoOwner = repoOwner
+
+	prName := os.Getenv("PORTER_PR_NAME")
+	res.prName = prName
 
 	commit, err := git.LastCommit()
 
@@ -914,3 +937,91 @@ func (t *DeploymentHook) OnError(err error) {
 		)
 	}
 }
+
+type CloneEnvGroupHook struct {
+	client   *api.Client
+	resGroup *switchboardTypes.ResourceGroup
+}
+
+func NewCloneEnvGroupHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup) *CloneEnvGroupHook {
+	return &CloneEnvGroupHook{
+		client:   client,
+		resGroup: resourceGroup,
+	}
+}
+
+func (t *CloneEnvGroupHook) PreApply() error {
+	for _, res := range t.resGroup.Resources {
+		config := &ApplicationConfig{}
+
+		err := mapstructure.Decode(res.Config, &config)
+		if err != nil {
+			continue
+		}
+
+		if config != nil && len(config.EnvGroups) > 0 {
+			target := &Target{}
+
+			err = getTarget(res.Target, target)
+
+			if err != nil {
+				return err
+			}
+
+			for _, group := range config.EnvGroups {
+				if group.Name == "" {
+					return fmt.Errorf("env group name cannot be empty")
+				}
+
+				_, err := t.client.GetEnvGroup(
+					context.Background(),
+					target.Project,
+					target.Cluster,
+					target.Namespace,
+					&types.GetEnvGroupRequest{
+						Name:    group.Name,
+						Version: group.Version,
+					},
+				)
+
+				if err != nil && err.Error() == "env group not found" {
+					if group.Namespace == "" {
+						return fmt.Errorf("env group namespace cannot be empty")
+					}
+
+					color.New(color.FgBlue, color.Bold).
+						Printf("Env group '%s' does not exist in the target namespace '%s'\n", group.Name, target.Namespace)
+					color.New(color.FgBlue, color.Bold).
+						Printf("Cloning env group '%s' from namespace '%s' to target namespace '%s'\n",
+							group.Name, group.Namespace, target.Namespace)
+
+					_, err = t.client.CloneEnvGroup(
+						context.Background(), target.Project, target.Cluster, group.Namespace,
+						&types.CloneEnvGroupRequest{
+							Name:      group.Name,
+							Namespace: target.Namespace,
+						},
+					)
+
+					if err != nil {
+						return err
+					}
+				} else if err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func (t *CloneEnvGroupHook) DataQueries() map[string]interface{} {
+	return nil
+}
+
+func (t *CloneEnvGroupHook) PostApply(populatedData map[string]interface{}) error {
+	return nil
+}
+
+func (t *CloneEnvGroupHook) OnError(err error) {}

+ 9 - 1
cli/cmd/create.go

@@ -77,6 +77,7 @@ var values string
 var source string
 var image string
 var registryURL string
+var forceBuild bool
 
 func init() {
 	rootCmd.AddCommand(createCmd)
@@ -155,6 +156,13 @@ func init() {
 		"",
 		"the registry URL to use (must exist in \"porter registries list\")",
 	)
+
+	createCmd.PersistentFlags().BoolVar(
+		&forceBuild,
+		"force-build",
+		false,
+		"set this to force build an image",
+	)
 }
 
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
@@ -217,7 +225,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 	}
 
 	if source == "local" {
-		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil)
+		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil, forceBuild)
 
 		return handleSubdomainCreate(subdomain, err)
 	} else if source == "github" {

+ 17 - 2
cli/cmd/deploy.go

@@ -206,6 +206,7 @@ var dockerfile string
 var method string
 var stream bool
 var buildFlagsEnv []string
+var forcePush bool
 
 func init() {
 	buildFlagsEnv = []string{}
@@ -288,6 +289,20 @@ func init() {
 		"stream update logs to porter dashboard",
 	)
 
+	updateCmd.PersistentFlags().BoolVar(
+		&forceBuild,
+		"force-build",
+		false,
+		"set this to force build an image (images tagged with \"latest\" have this set by default)",
+	)
+
+	updateCmd.PersistentFlags().BoolVar(
+		&forcePush,
+		"force-push",
+		false,
+		"set this to force push an image (images tagged with \"latest\" have this set by default)",
+	)
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -476,7 +491,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 	}
 
-	if err := updateAgent.Build(nil); err != nil {
+	if err := updateAgent.Build(nil, forceBuild); err != nil {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "build",
@@ -516,7 +531,7 @@ func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 	}
 
-	if err := updateAgent.Push(); err != nil {
+	if err := updateAgent.Push(forcePush); err != nil {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "push",

+ 55 - 41
cli/cmd/deploy/create.go

@@ -219,6 +219,7 @@ func (c *CreateAgent) CreateFromDocker(
 	overrideValues map[string]interface{},
 	imageTag string,
 	extraBuildConfig *types.BuildConfig,
+	forceBuild bool,
 ) (string, error) {
 	opts := c.CreateOpts
 
@@ -272,59 +273,65 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 	}
 
-	env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
+	imageExists := agent.CheckIfImageExists(fmt.Sprintf("%s:%s", imageURL, imageTag))
 
-	if err != nil {
-		env = map[string]string{}
-	}
+	if imageExists && imageTag != "latest" && !forceBuild {
+		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, imageTag)
+	} else { // image does not exist or has tag "latest" so we (re)build one
+		env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
 
-	// add additional env based on options
-	for key, val := range opts.SharedOpts.AdditionalEnv {
-		env[key] = val
-	}
+		if err != nil {
+			env = map[string]string{}
+		}
 
-	buildAgent := &BuildAgent{
-		SharedOpts:  opts.SharedOpts,
-		client:      c.Client,
-		imageRepo:   imageURL,
-		env:         env,
-		imageExists: false,
-	}
+		// add additional env based on options
+		for key, val := range opts.SharedOpts.AdditionalEnv {
+			env[key] = val
+		}
+
+		buildAgent := &BuildAgent{
+			SharedOpts:  opts.SharedOpts,
+			client:      c.Client,
+			imageRepo:   imageURL,
+			env:         env,
+			imageExists: false,
+		}
 
-	if opts.Method == DeployBuildTypeDocker {
-		basePath, err := filepath.Abs(".")
+		if opts.Method == DeployBuildTypeDocker {
+			basePath, err := filepath.Abs(".")
+
+			if err != nil {
+				return "", err
+			}
+
+			err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
+		} else {
+			err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
+		}
 
 		if err != nil {
 			return "", err
 		}
 
-		err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
-	} else {
-		err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
-	}
-
-	if err != nil {
-		return "", err
-	}
-
-	// create repository
-	err = c.Client.CreateRepository(
-		context.Background(),
-		opts.ProjectID,
-		regID,
-		&types.CreateRegistryRepositoryRequest{
-			ImageRepoURI: imageURL,
-		},
-	)
+		// create repository
+		err = c.Client.CreateRepository(
+			context.Background(),
+			opts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
 
-	if err != nil {
-		return "", err
-	}
+		if err != nil {
+			return "", err
+		}
 
-	err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
+		err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
 
-	if err != nil {
-		return "", err
+		if err != nil {
+			return "", err
+		}
 	}
 
 	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
@@ -478,6 +485,13 @@ func (c *CreateAgent) getMergedValues(overrideValues map[string]interface{}) (st
 		return "", nil, err
 	}
 
+	err = coalesceEnvGroups(c.Client, c.CreateOpts.ProjectID, c.CreateOpts.ClusterID,
+		c.CreateOpts.Namespace, c.CreateOpts.EnvGroups, values)
+
+	if err != nil {
+		return "", nil, err
+	}
+
 	// merge existing values with overriding values
 	mergedValues := utils.CoalesceValues(values, overrideValues)
 

+ 30 - 17
cli/cmd/deploy/deploy.go

@@ -137,7 +137,12 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 
 	deployAgent.tag = opts.OverrideTag
 
-	return deployAgent, nil
+	err = coalesceEnvGroups(deployAgent.client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
+		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
+
+	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(fmt.Sprintf("%s:%s", deployAgent.imageRepo, deployAgent.tag))
+
+	return deployAgent, err
 }
 
 type GetBuildEnvOpts struct {
@@ -216,12 +221,28 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 
 // Build uses the deploy agent options to build a new container image from either
 // buildpack or docker.
-func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig) error {
+func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild bool) error {
+	// retrieve current image to use for cache
+	currImageSection := d.release.Config["image"].(map[string]interface{})
+	currentTag := currImageSection["tag"].(string)
+
+	if d.tag == "" {
+		d.tag = currentTag
+	}
+
+	// we do not want to re-build an image
+	// FIXME: what if overrideBuildConfig == nil but the image stays the same?
+	if overrideBuildConfig == nil && d.imageExists && d.tag != "latest" && !forceBuild {
+		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", d.imageRepo, d.tag)
+		return nil
+	}
+
 	// if build is not local, fetch remote source
 	var basePath string
-	buildCtx := d.opts.LocalPath
 	var err error
 
+	buildCtx := d.opts.LocalPath
+
 	if !d.opts.Local {
 		repoSplit := strings.Split(d.release.GitActionConfig.GitRepo, "/")
 
@@ -262,24 +283,11 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig) error {
 		}
 	}
 
-	// retrieve current image to use for cache
-	currImageSection := d.release.Config["image"].(map[string]interface{})
-	currentTag := currImageSection["tag"].(string)
-
-	if d.tag == "" {
-		d.tag = currentTag
-	}
-
 	currTag, err := d.pullCurrentReleaseImage()
 
 	// if image is not found, don't return an error
 	if err != nil && err != docker.PullImageErrNotFound {
 		return err
-	} else if err != nil && err == docker.PullImageErrNotFound {
-		fmt.Println("could not find image, moving to build step")
-		d.imageExists = false
-	} else if err == nil {
-		d.imageExists = true
 	}
 
 	buildAgent := &BuildAgent{
@@ -311,7 +319,12 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig) error {
 }
 
 // Push pushes a local image to the remote repository linked in the release
-func (d *DeployAgent) Push() error {
+func (d *DeployAgent) Push(forcePush bool) error {
+	if d.imageExists && !forcePush && d.tag != "latest" {
+		fmt.Printf("%s:%s has been pushed already, so skipping push\n", d.imageRepo, d.tag)
+		return nil
+	}
+
 	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 }
 

+ 55 - 0
cli/cmd/deploy/shared.go

@@ -1,5 +1,13 @@
 package deploy
 
+import (
+	"context"
+	"fmt"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+)
+
 // SharedOpts are common options for build, create, and deploy agents
 type SharedOpts struct {
 	ProjectID       uint
@@ -10,4 +18,51 @@ type SharedOpts struct {
 	OverrideTag     string
 	Method          DeployBuildType
 	AdditionalEnv   map[string]string
+	EnvGroups       []types.EnvGroupMeta
+}
+
+func coalesceEnvGroups(
+	client *api.Client,
+	projectID, clusterID uint,
+	namespace string,
+	envGroups []types.EnvGroupMeta,
+	config map[string]interface{},
+) error {
+	for _, group := range envGroups {
+		if group.Name == "" {
+			return fmt.Errorf("env group name cannot be empty")
+		}
+
+		envGroup, err := client.GetEnvGroup(
+			context.Background(),
+			projectID,
+			clusterID,
+			namespace,
+			&types.GetEnvGroupRequest{
+				Name:    group.Name,
+				Version: group.Version,
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+
+		envConfig, err := getNestedMap(config, "container", "env", "normal")
+
+		if err != nil || envConfig == nil {
+			envConfig = make(map[string]interface{})
+		}
+
+		for k, v := range envGroup.Variables {
+			envConfig[k] = v
+		}
+
+		containerMap, _ := config["container"].(map[string]interface{})
+		envMap, _ := containerMap["env"].(map[string]interface{})
+
+		envMap["normal"] = envConfig
+	}
+
+	return nil
 }

+ 167 - 7
cli/cmd/docker/agent.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net/http"
 	"os"
 	"strings"
 	"time"
@@ -158,6 +159,140 @@ var PullImageErrNotFound = fmt.Errorf("Requested image not found")
 
 var PullImageErrUnauthorized = fmt.Errorf("Could not pull image: unauthorized")
 
+func getRegistryRepositoryPair(image, prefix string) []string {
+	if !strings.HasSuffix(prefix, "/") {
+		prefix = prefix + "/"
+	}
+
+	return strings.Split(strings.TrimPrefix(strings.Split(image, ":")[0], prefix), "/")
+}
+
+// CheckIfImageExists checks if the image exists in the registry
+func (a *Agent) CheckIfImageExists(image string) bool {
+	registryToken, err := a.getContainerRegistryToken(image)
+
+	if err != nil {
+		return false
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+
+	if strings.Contains(image, "gcr.io") {
+		gcrRegRepo := getRegistryRepositoryPair(image, "gcr.io")
+
+		if len(gcrRegRepo) != 2 {
+			return false
+		}
+
+		req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(
+			"https://gcr.io/v2/%s/%s/tags/list", gcrRegRepo[0], gcrRegRepo[1],
+		), nil)
+
+		if err != nil {
+			return false
+		}
+
+		req.Header.Add("Content-Type", "application/json")
+		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", registryToken))
+
+		resp, err := http.DefaultClient.Do(req)
+
+		if err != nil {
+			return false
+		}
+
+		defer resp.Body.Close()
+
+		var tags struct {
+			Tags []string `json:"tags,omitempty"`
+		}
+
+		err = json.NewDecoder(resp.Body).Decode(&tags)
+
+		if err != nil {
+			return false
+		}
+
+		reqTag := strings.Split(image, ":")[1]
+
+		for _, tag := range tags.Tags {
+			if tag == reqTag {
+				return true
+			}
+		}
+
+		return false
+	} else if strings.Contains(image, "registry.digitalocean.com") {
+		doRegRepo := getRegistryRepositoryPair(image, "registry.digitalocean.com")
+
+		if len(doRegRepo) != 2 {
+			return false
+		}
+
+		req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(
+			"https://api.digitalocean.com/v2/registry/%s/repositories/%s/digests", doRegRepo[0], doRegRepo[1],
+		), nil)
+
+		if err != nil {
+			return false
+		}
+
+		req.Header.Add("Content-Type", "application/json")
+		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", registryToken))
+
+		resp, err := http.DefaultClient.Do(req)
+
+		if err != nil {
+			return false
+		}
+
+		defer resp.Body.Close()
+
+		// refer https://github.com/digitalocean/godo/blob/main/registry.go#L106
+		var digest struct {
+			Manifests []struct {
+				Tags []string `json:"tags,omitempty"`
+			} `json:"manifests,omitempty"`
+		}
+
+		err = json.NewDecoder(resp.Body).Decode(&digest)
+
+		if err != nil {
+			return false
+		}
+
+		reqTag := strings.Split(image, ":")[1]
+
+		for _, manifest := range digest.Manifests {
+			for _, tag := range manifest.Tags {
+				if tag == reqTag {
+					return true
+				}
+			}
+		}
+
+		return false
+	}
+
+	encodedRegistryAuth, err := a.getEncodedRegistryAuth(image)
+
+	if err != nil {
+		return false
+	}
+
+	_, err = a.client.DistributionInspect(context.Background(), image, encodedRegistryAuth)
+
+	if err == nil {
+		return true
+	} else if strings.Contains(err.Error(), "image not found") ||
+		strings.Contains(err.Error(), "does not exist in the registry") {
+		return false
+	}
+
+	return false
+}
+
 // PullImage pulls an image specified by the image string
 func (a *Agent) PullImage(image string) error {
 	opts, err := a.getPullOptions(image)
@@ -219,17 +354,46 @@ func (a *Agent) getPullOptions(image string) (types.ImagePullOptions, error) {
 		return types.ImagePullOptions{}, nil
 	}
 
+	authConfigEncoded, err := a.getEncodedRegistryAuth(image)
+
+	if err != nil {
+		return types.ImagePullOptions{}, err
+	}
+
+	return types.ImagePullOptions{
+		RegistryAuth: authConfigEncoded,
+		Platform:     "linux/amd64",
+	}, nil
+}
+
+func (a *Agent) getContainerRegistryToken(image string) (string, error) {
+	serverURL, err := GetServerURLFromTag(image)
+
+	if err != nil {
+		return "", err
+	}
+
+	_, secret, err := a.authGetter.GetCredentials(serverURL)
+
+	if err != nil {
+		return "", err
+	}
+
+	return secret, nil
+}
+
+func (a *Agent) getEncodedRegistryAuth(image string) (string, error) {
 	// get using server url
 	serverURL, err := GetServerURLFromTag(image)
 
 	if err != nil {
-		return types.ImagePullOptions{}, err
+		return "", err
 	}
 
 	user, secret, err := a.authGetter.GetCredentials(serverURL)
 
 	if err != nil {
-		return types.ImagePullOptions{}, err
+		return "", err
 	}
 
 	var authConfig = types.AuthConfig{
@@ -239,12 +403,8 @@ func (a *Agent) getPullOptions(image string) (types.ImagePullOptions, error) {
 	}
 
 	authConfigBytes, _ := json.Marshal(authConfig)
-	authConfigEncoded := base64.URLEncoding.EncodeToString(authConfigBytes)
 
-	return types.ImagePullOptions{
-		RegistryAuth: authConfigEncoded,
-		Platform:     "linux/amd64",
-	}, nil
+	return base64.URLEncoding.EncodeToString(authConfigBytes), nil
 }
 
 func (a *Agent) getPushOptions(image string) (types.ImagePushOptions, error) {

+ 96 - 0
cli/cmd/get.go

@@ -0,0 +1,96 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v2"
+	"helm.sh/helm/v3/pkg/time"
+)
+
+// getCmd represents the "porter get" base command when called
+// without any subcommands
+var getCmd = &cobra.Command{
+	Use:   "get [release]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Fetches a release.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, get)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var output string
+
+func init() {
+	rootCmd.AddCommand(getCmd)
+
+	getCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"the namespace of the release",
+	)
+
+	getCmd.PersistentFlags().StringVar(
+		&output,
+		"output",
+		"",
+		"the output format to use (\"yaml\" or \"json\")",
+	)
+}
+
+type getReleaseInfo struct {
+	Name         string
+	Namespace    string
+	LastDeployed time.Time `json:"last_deployed" yaml:"last_deployed"`
+	ReleaseType  string    `json:"release_type" yaml:"release_type"`
+}
+
+func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	rel, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, args[0])
+
+	if err != nil {
+		return err
+	}
+
+	relInfo := &getReleaseInfo{
+		Name:         rel.Name,
+		Namespace:    rel.Namespace,
+		LastDeployed: rel.Info.LastDeployed,
+		ReleaseType:  rel.Chart.Metadata.Name,
+	}
+
+	if output == "yaml" {
+		bytes, err := yaml.Marshal(relInfo)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(string(bytes))
+	} else if output == "json" {
+		bytes, err := json.Marshal(relInfo)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(string(bytes))
+	} else {
+		fmt.Printf("Name:          %s\n", relInfo.Name)
+		fmt.Printf("Namespace:     %s\n", relInfo.Namespace)
+		fmt.Printf("Last deployed: %s\n", relInfo.LastDeployed)
+		fmt.Printf("Release type:  %s\n", relInfo.ReleaseType)
+	}
+
+	return nil
+}

+ 104 - 28
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -236,43 +236,114 @@ export const ExpandedEnvGroupFC = ({
         setTimeout(() => setButtonStatus(""), 1000);
       }
     } else {
-      const configMapSecretVariables = fillWithDeletedVariables(
-        originalEnvVars.filter((variable) => {
-          return variable.value.includes("PORTERSECRET");
-        }),
-        variables.filter((variable) => {
-          return variable.value.includes("PORTERSECRET") || variable.hidden;
-        })
-      ).reduce(
-        (acc, variable) => ({
-          ...acc,
-          [variable.key]: variable.value,
-        }),
-        {}
+      // SEPARATE THE TWO KINDS OF VARIABLES
+      let secret = variables.filter(
+        (variable) =>
+          variable.hidden && !variable.value.includes("PORTERSECRET")
       );
 
-      const configMapVariables = fillWithDeletedVariables(
-        originalEnvVars,
-        variables.filter(
-          (variable) =>
-            !variable.hidden || !variable.value?.includes("PORTERSECRET")
-        )
-      ).reduce(
-        (acc, variable) => ({
-          ...acc,
-          [variable.key]: variable.value,
-        }),
-        {}
+      let normal = variables.filter(
+        (variable) =>
+          !variable.hidden && !variable.value.includes("PORTERSECRET")
       );
 
+      // Filter variables that weren't updated
+      normal = normal.reduce((acc, variable) => {
+        const originalVar = originalEnvVars.find(
+          (orgVar) => orgVar.key === variable.key
+        );
+
+        // Remove variables that weren't updated
+        if (variable.value === originalVar?.value) {
+          return acc;
+        }
+
+        // add the variable that's going to be updated
+        return [...acc, variable];
+      }, []);
+
+      secret = secret.reduce((acc, variable) => {
+        const originalVar = originalEnvVars.find(
+          (orgVar) => orgVar.key === variable.key
+        );
+
+        // Remove variables that weren't updated
+        if (variable.value === originalVar?.value) {
+          return acc;
+        }
+
+        // add the variable that's going to be updated
+        return [...acc, variable];
+      }, []);
+
+      // Check through the original env vars to see if there's a missing variable, if it is, then means it was removed
+      const removedNormal = originalEnvVars.reduce((acc, orgVar) => {
+        if (orgVar.value.includes("PORTERSECRET")) {
+          return acc;
+        }
+
+        const variableFound = variables.find(
+          (variable) => orgVar.key === variable.key
+        );
+        if (variableFound) {
+          return acc;
+        }
+        return [
+          ...acc,
+          {
+            key: orgVar.key,
+            value: null,
+          },
+        ];
+      }, []);
+
+      const removedSecret = originalEnvVars.reduce((acc, orgVar) => {
+        if (!orgVar.value.includes("PORTERSECRET")) {
+          return acc;
+        }
+
+        const variableFound = variables.find(
+          (variable) => orgVar.key === variable.key
+        );
+        if (variableFound) {
+          return acc;
+        }
+        return [
+          ...acc,
+          {
+            key: orgVar.key,
+            value: null,
+          },
+        ];
+      }, []);
+
+      normal = [...normal, ...removedNormal];
+      secret = [...secret, ...removedSecret];
+
+      const normalObject = normal.reduce((acc, val) => {
+        return {
+          ...acc,
+          [val.key]: val.value,
+        };
+      }, {});
+
+      const secretObject = secret.reduce((acc, val) => {
+        return {
+          ...acc,
+          [val.key]: val.value,
+        };
+      }, {});
+
+      console.log({ normalObject, secretObject });
+
       try {
         const updatedEnvGroup = await api
           .updateConfigMap(
             "<token>",
             {
               name,
-              variables: configMapVariables,
-              secret_variables: configMapSecretVariables,
+              variables: normalObject,
+              secret_variables: secretObject,
             },
             {
               id: currentProject.id,
@@ -430,7 +501,12 @@ const EnvGroupSettings = ({
   const [isAuthorized] = useAuth();
 
   const canDelete = useMemo(() => {
-    return envGroup?.applications.length === 0;
+    // add a case for when applications is null - in this case this is a deprecated env group version
+    if (!envGroup?.applications) {
+      return true;
+    }
+
+    return envGroup?.applications?.length === 0;
   }, [envGroup]);
 
   return (

+ 78 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -25,6 +25,7 @@ import { pushFiltered } from "../../../../shared/routing";
 import { RouteComponentProps, withRouter } from "react-router";
 import Banner from "components/Banner";
 import KeyValueArray from "components/form-components/KeyValueArray";
+import { onlyInLeft } from "shared/array_utils";
 import { readableDate } from "shared/string_utils";
 
 type PropsType = WithAuthProps &
@@ -323,14 +324,15 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     return ws;
   };
 
-  handleSaveValues = (config?: any, runJob?: boolean) => {
+  handleSaveValues = async (config?: any, runJob?: boolean) => {
     let { currentCluster, setCurrentError, currentProject } = this.context;
     this.setState({ saveValuesStatus: "loading" });
 
     let conf: string;
+    let values = {} as any;
 
     if (!config) {
-      let values = {};
+      values = {};
       let imageUrl = this.state.newestImage;
       let tag = null;
 
@@ -353,7 +355,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       });
     } else {
       // Convert dotted keys to nested objects
-      let values = {};
+      values = {};
 
       for (let key in config) {
         _.set(values, key, config[key]);
@@ -391,6 +393,79 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
       );
     }
 
+    const oldSyncedEnvGroups =
+      this.props.currentChart.config?.container?.env?.synced || [];
+    const newSyncedEnvGroups = values?.container?.env?.synced || [];
+
+    const deletedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      oldSyncedEnvGroups,
+      newSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      newSyncedEnvGroups,
+      oldSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addApplicationToEnvGroupPromises = addedEnvGroups.map(
+      (envGroup: any) => {
+        return api.addApplicationToEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: this.state.currentChart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: this.state.currentChart.namespace,
+          }
+        );
+      }
+    );
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't sync the env group to the application, please try again."
+      );
+    }
+
+    const removeApplicationToEnvGroupPromises = deletedEnvGroups.map(
+      (envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: this.state.currentChart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: this.state.currentChart.namespace,
+          }
+        );
+      }
+    );
+    try {
+      await Promise.all(removeApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't remove the synced env group from the application, please try again."
+      );
+    }
+
     api
       .upgradeChartValues(
         "<token>",

+ 1 - 3
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -301,9 +301,7 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
       hosting: "aws",
     });
 
-    window.open(
-      "https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-    );
+    window.open("https://docs.porter.run/getting-started/provisioning-on-aws");
   };
 
   return (

+ 1 - 1
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -284,7 +284,7 @@ const GCPFormSectionFC: React.FC<PropsType> = (props) => {
       hosting: "gcp",
     });
 
-    window.open("https://docs.getporter.dev/docs/getting-started-on-gcp");
+    window.open("https://docs.porter.run/getting-started/provisioning-on-gcp");
   };
 
   return (

+ 13 - 21
dashboard/src/shared/api.tsx

@@ -422,11 +422,9 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -442,11 +440,9 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -462,11 +458,9 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -1145,7 +1139,7 @@ const listEnvGroups = baseApi<
     cluster_id: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroups/list`;
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroup/list`;
 });
 
 const listConfigMaps = baseApi<
@@ -1169,11 +1163,9 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${
-    pathParams.cluster_id
-  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
-    pathParams.version ? "&version=" + pathParams.version : ""
-  }`;
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
+    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
+    }`;
 });
 
 const getConfigMap = baseApi<

+ 1 - 1
go.mod

@@ -39,7 +39,7 @@ require (
 	github.com/moby/term v0.0.0-20210610120745-9d4ed1856297
 	github.com/opencontainers/image-spec v1.0.2
 	github.com/pkg/errors v0.9.1
-	github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034
+	github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb
 	github.com/rs/zerolog v1.26.0
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
 	github.com/spf13/cobra v1.3.0

+ 2 - 0
go.sum

@@ -1217,6 +1217,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034 h1:qzxRAL/HPfadofm5CX3zG3aPXOH77W3KwiW/zctUF7c=
 github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
+github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb h1:aNRIZcKkDkFhyROzmc5FCHgK6+ZbmzfTGudioPdtgmU=
+github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=

+ 55 - 1
internal/helm/agent.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/pkg/errors"
 	"github.com/porter-dev/porter/internal/helm/loader"
@@ -12,6 +13,7 @@ import (
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage/driver"
 	corev1 "k8s.io/api/core/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/helm/pkg/chartutil"
@@ -207,7 +209,59 @@ func (a *Agent) UpgradeReleaseByValues(
 	res, err := cmd.Run(conf.Name, ch, conf.Values)
 
 	if err != nil {
-		return nil, fmt.Errorf("Upgrade failed: %v", err)
+		// refer: https://github.com/helm/helm/blob/release-3.8/pkg/action/action.go#L62
+		// issue tracker: https://github.com/helm/helm/issues/4558
+		if err.Error() == "another operation (install/upgrade/rollback) is in progress" {
+			secretList, err := a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace).List(
+				context.Background(),
+				v1.ListOptions{
+					LabelSelector: fmt.Sprintf("owner=helm,status in (pending-install, pending-upgrade, pending-rollback),name=%s", rel.Name),
+				},
+			)
+
+			if err != nil {
+				return nil, fmt.Errorf("Upgrade failed: %w", err)
+			}
+
+			if len(secretList.Items) > 0 {
+				mostRecentSecret := secretList.Items[0]
+
+				for i := 1; i < len(secretList.Items); i += 1 {
+					oldVersion, _ := strconv.Atoi(mostRecentSecret.Labels["version"])
+					newVersion, _ := strconv.Atoi(secretList.Items[i].Labels["version"])
+
+					if oldVersion < newVersion {
+						mostRecentSecret = secretList.Items[i]
+					}
+				}
+
+				if time.Since(mostRecentSecret.CreationTimestamp.Time) >= time.Minute {
+					helmSecrets := driver.NewSecrets(a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace))
+
+					rel.Info.Status = release.StatusFailed
+
+					err = helmSecrets.Update(mostRecentSecret.GetName(), rel)
+
+					if err != nil {
+						return nil, fmt.Errorf("Upgrade failed: %w", err)
+					}
+
+					// retry upgrade
+					res, err = cmd.Run(conf.Name, ch, conf.Values)
+
+					if err != nil {
+						return nil, fmt.Errorf("Upgrade failed: %w", err)
+					}
+
+					return res, nil
+				} else {
+					// ask the user to wait for about a minute before retrying for the above fix to kick in
+					return nil, fmt.Errorf("another operation (install/upgrade/rollback) is in progress. If this error persists, please wait for 60 seconds to force an upgrade")
+				}
+			}
+		}
+
+		return nil, fmt.Errorf("Upgrade failed: %w", err)
 	}
 
 	return res, nil

+ 2 - 5
internal/kubernetes/envgroup/create.go

@@ -52,11 +52,8 @@ func ConvertV1ToV2EnvGroup(agent *kubernetes.Agent, name, namespace string) (*v1
 		return nil, err
 	}
 
-	// delete the old configmap and secret
-	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
-		return nil, err
-	}
-
+	// delete the old configmap
+	// note: we keep the old secret to ensure existing secret references are kept intact
 	if err := agent.DeleteConfigMap(name, namespace); err != nil {
 		return nil, err
 	}