Просмотр исходного кода

Merge branch 'master' into nafees/rbac-improvements

Mohammed Nafees 3 лет назад
Родитель
Сommit
e9f0d323ce
40 измененных файлов с 16657 добавлено и 119 удалено
  1. 54 12
      api/server/handlers/infra/forms.go
  2. 12 17
      api/server/handlers/namespace/stream_pod_logs.go
  3. 3 11
      api/server/handlers/stack/create.go
  4. 76 0
      api/server/handlers/v1/env_group/add_release.go
  5. 399 0
      api/server/handlers/v1/env_group/create.go
  6. 91 0
      api/server/handlers/v1/env_group/delete.go
  7. 88 0
      api/server/handlers/v1/env_group/get.go
  8. 95 0
      api/server/handlers/v1/env_group/get_all_versions.go
  9. 104 0
      api/server/handlers/v1/env_group/list.go
  10. 79 0
      api/server/handlers/v1/env_group/remove_release.go
  11. 6 1
      api/server/router/router.go
  12. 506 0
      api/server/router/v1/env_group.go
  13. 1 1
      api/server/router/v1/namespace.go
  14. 67 5
      api/types/namespace.go
  15. 7 6
      api/types/stacks.go
  16. 66 3
      cli/cmd/deploy.go
  17. 14 3
      cli/cmd/run.go
  18. 28 0
      cmd/migrate/main.go
  19. 365 0
      cmd/migrate/populate_source_config_display_name/helpers_test.go
  20. 40 0
      cmd/migrate/populate_source_config_display_name/populate.go
  21. 76 0
      cmd/migrate/populate_source_config_display_name/populate_test.go
  22. 14275 1
      dashboard/package-lock.json
  23. 8 0
      dashboard/src/components/porter-form/FormDebugger.tsx
  24. 4 0
      dashboard/src/components/porter-form/PorterForm.tsx
  25. 94 0
      dashboard/src/components/porter-form/field-components/UrlLink.tsx
  26. 11 2
      dashboard/src/components/porter-form/types.ts
  27. 1 0
      dashboard/src/components/repo-selector/RepoList.tsx
  28. 3 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  29. 3 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  30. 15 13
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  31. 2 2
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  32. 10 5
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  33. 2 2
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  34. 2 2
      dashboard/src/main/home/integrations/create-integration/GARForm.tsx
  35. 16 0
      dashboard/src/main/home/onboarding/constants.ts
  36. 17 17
      dashboard/src/shared/api.tsx
  37. 3 1
      internal/kubernetes/prometheus/metrics.go
  38. 11 13
      internal/models/stack.go
  39. 1 1
      internal/repository/stack.go
  40. 2 1
      internal/stacks/helpers.go

+ 54 - 12
api/server/handlers/infra/forms.go

@@ -396,6 +396,10 @@ tabs:
           value: t3.xlarge
           value: t3.xlarge
         - label: t3.2xlarge
         - label: t3.2xlarge
           value: t3.2xlarge
           value: t3.2xlarge
+        - label: c6i.large
+          value: c6i.large
+        - label: c6i.xlarge
+          value: c6i.xlarge
         - label: c6i.2xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
           value: c6i.2xlarge
     - type: string-input
     - type: string-input
@@ -656,12 +660,18 @@ tabs:
           value: asia-northeast3
           value: asia-northeast3
         - label: asia-south1
         - label: asia-south1
           value: asia-south1
           value: asia-south1
+        - label: asia-south2
+          value: asia-south2
         - label: asia-southeast1
         - label: asia-southeast1
           value: asia-southeast1
           value: asia-southeast1
         - label: asia-southeast2
         - label: asia-southeast2
           value: asia-southeast2
           value: asia-southeast2
         - label: australia-southeast1
         - label: australia-southeast1
           value: australia-southeast1
           value: australia-southeast1
+        - label: australia-southeast2
+          value: australia-southeast2
+        - label: europe-central2
+          value: europe-central2
         - label: europe-north1
         - label: europe-north1
           value: europe-north1
           value: europe-north1
         - label: europe-west1
         - label: europe-west1
@@ -674,23 +684,33 @@ tabs:
           value: europe-west4
           value: europe-west4
         - label: europe-west6
         - label: europe-west6
           value: europe-west6
           value: europe-west6
+        - label: europe-west8
+          value: europe-west8
+        - label: europe-west9
+          value: europe-west9
+        - label: europe-southwest1
+          value: europe-southwest1
         - label: northamerica-northeast1
         - label: northamerica-northeast1
           value: northamerica-northeast1
           value: northamerica-northeast1
+        - label: northamerica-northeast2
+          value: northamerica-northeast2
         - label: southamerica-east1
         - label: southamerica-east1
           value: southamerica-east1
           value: southamerica-east1
+        - label: southamerica-west1
+          value: southamerica-west1
         - label: us-central1
         - label: us-central1
           value: us-central1
           value: us-central1
         - label: us-east1
         - label: us-east1
           value: us-east1
           value: us-east1
         - label: us-east4
         - label: us-east4
           value: us-east4
           value: us-east4
-        - label: us-east1
-          value: us-east1
-        - label: us-east1
-          value: us-east1
+        - label: us-east5
+          value: us-east5
+        - label: us-south1
+          value: us-south1
         - label: us-west1
         - label: us-west1
           value: us-west1
           value: us-west1
-        - label: us-east1
+        - label: us-west2
           value: us-west2
           value: us-west2
         - label: us-west3
         - label: us-west3
           value: us-west3
           value: us-west3
@@ -741,8 +761,6 @@ tabs:
           value: europe-central2
           value: europe-central2
         - label: europe-north1
         - label: europe-north1
           value: europe-north1
           value: europe-north1
-        - label: europe-southwest1
-          value: europe-southwest1
         - label: europe-west1
         - label: europe-west1
           value: europe-west1
           value: europe-west1
         - label: europe-west2
         - label: europe-west2
@@ -757,6 +775,8 @@ tabs:
           value: europe-west8
           value: europe-west8
         - label: europe-west9
         - label: europe-west9
           value: europe-west9
           value: europe-west9
+        - label: europe-southwest1
+          value: europe-southwest1
         - label: northamerica-northeast1
         - label: northamerica-northeast1
           value: northamerica-northeast1
           value: northamerica-northeast1
         - label: northamerica-northeast2
         - label: northamerica-northeast2
@@ -783,6 +803,12 @@ tabs:
           value: us-west3
           value: us-west3
         - label: us-west4
         - label: us-west4
           value: us-west4
           value: us-west4
+        - label: us (multi-region)
+          value: us
+        - label: europe (multi-region)
+          value: europe
+        - label: asia (multi-region)
+          value: asia
 `
 `
 
 
 const gkeForm = `name: GKE
 const gkeForm = `name: GKE
@@ -814,12 +840,18 @@ tabs:
           value: asia-northeast3
           value: asia-northeast3
         - label: asia-south1
         - label: asia-south1
           value: asia-south1
           value: asia-south1
+        - label: asia-south2
+          value: asia-south2
         - label: asia-southeast1
         - label: asia-southeast1
           value: asia-southeast1
           value: asia-southeast1
         - label: asia-southeast2
         - label: asia-southeast2
           value: asia-southeast2
           value: asia-southeast2
         - label: australia-southeast1
         - label: australia-southeast1
           value: australia-southeast1
           value: australia-southeast1
+        - label: australia-southeast2
+          value: australia-southeast2
+        - label: europe-central2
+          value: europe-central2
         - label: europe-north1
         - label: europe-north1
           value: europe-north1
           value: europe-north1
         - label: europe-west1
         - label: europe-west1
@@ -832,23 +864,33 @@ tabs:
           value: europe-west4
           value: europe-west4
         - label: europe-west6
         - label: europe-west6
           value: europe-west6
           value: europe-west6
+        - label: europe-west8
+          value: europe-west8
+        - label: europe-west9
+          value: europe-west9
+        - label: europe-southwest1
+          value: europe-southwest1
         - label: northamerica-northeast1
         - label: northamerica-northeast1
           value: northamerica-northeast1
           value: northamerica-northeast1
+        - label: northamerica-northeast2
+          value: northamerica-northeast2
         - label: southamerica-east1
         - label: southamerica-east1
           value: southamerica-east1
           value: southamerica-east1
+        - label: southamerica-west1
+          value: southamerica-west1
         - label: us-central1
         - label: us-central1
           value: us-central1
           value: us-central1
         - label: us-east1
         - label: us-east1
           value: us-east1
           value: us-east1
         - label: us-east4
         - label: us-east4
           value: us-east4
           value: us-east4
-        - label: us-east1
-          value: us-east1
-        - label: us-east1
-          value: us-east1
+        - label: us-east5
+          value: us-east5
+        - label: us-south1
+          value: us-south1
         - label: us-west1
         - label: us-west1
           value: us-west1
           value: us-west1
-        - label: us-east1
+        - label: us-west2
           value: us-west2
           value: us-west2
         - label: us-west3
         - label: us-west3
           value: us-west3
           value: us-west3

+ 12 - 17
api/server/handlers/namespace/stream_pod_logs.go

@@ -55,22 +55,17 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 
 	err = agent.GetPodLogs(namespace, name, request.Container, safeRW)
 	err = agent.GetPodLogs(namespace, name, request.Container, safeRW)
 
 
-	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("pod %s/%s was not found", namespace, name),
-			http.StatusNotFound,
-		))
-
-		return
-	} else if brErr := (kubernetes.BadRequestError{}); errors.As(err, &targetErr) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			&brErr,
-			http.StatusBadRequest,
-		))
-
-		return
-	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+	if err != nil {
+		if errors.Is(err, kubernetes.IsNotFoundError) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("pod %s/%s was not found", namespace, name),
+				http.StatusNotFound))
+			return
+		} else if _, ok := err.(*kubernetes.BadRequestError); ok {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		} else {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 	}
 }
 }

+ 3 - 11
api/server/handlers/stack/create.go

@@ -261,21 +261,13 @@ func getSourceConfigModels(sourceConfigs []*types.CreateStackSourceConfigRequest
 				return nil, err
 				return nil, err
 			}
 			}
 
 
-			newSourceConfig := &models.StackSourceConfig{
+			res = append(res, models.StackSourceConfig{
 				UID:          uid,
 				UID:          uid,
+				DisplayName:  sourceConfig.DisplayName,
 				Name:         sourceConfig.Name,
 				Name:         sourceConfig.Name,
 				ImageRepoURI: sourceConfig.ImageRepoURI,
 				ImageRepoURI: sourceConfig.ImageRepoURI,
 				ImageTag:     sourceConfig.ImageTag,
 				ImageTag:     sourceConfig.ImageTag,
-			}
-
-			// If the source config had a source config ID then we need to copy it over
-			if sourceConfig.StableSourceConfigID != "" {
-				newSourceConfig.StableSourceConfigID = sourceConfig.StableSourceConfigID
-			} else {
-				newSourceConfig.StableSourceConfigID = string(uid)
-			}
-
-			res = append(res, *newSourceConfig)
+			})
 		}
 		}
 	}
 	}
 
 

+ 76 - 0
api/server/handlers/v1/env_group/add_release.go

@@ -0,0 +1,76 @@
+package env_group
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type AddReleaseToEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewAddReleaseToEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AddReleaseToEnvGroupHandler {
+	return &AddReleaseToEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *AddReleaseToEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.V1EnvGroupReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the attached configmap
+	cm, _, err := agent.GetLatestVersionedConfigMap(name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.AddApplicationToVersionedConfigMap(cm, request.ReleaseName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 399 - 0
api/server/handlers/v1/env_group/create.go

@@ -0,0 +1,399 @@
+package env_group
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"sync"
+
+	"sigs.k8s.io/yaml"
+
+	"helm.sh/helm/v3/pkg/release"
+	v1 "k8s.io/api/core/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/helm"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type CreateEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateEnvGroupHandler {
+	return &CreateEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.CreateEnvGroupRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, 0)
+
+	// if the environment group exists and has MetaVersion=1, throw an error
+	if envGroup != nil && envGroup.MetaVersion == 1 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group with that name already exists"),
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
+		Name:            request.Name,
+		Namespace:       namespace,
+		Variables:       request.Variables,
+		SecretVariables: request.SecretVariables,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err = envgroup.ToEnvGroup(configMap)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	releases, err := envgroup.GetSyncedReleases(helmAgent, configMap)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.V1EnvGroupResponse{
+		CreatedAt: envGroup.CreatedAt,
+		Version:   envGroup.Version,
+		Name:      envGroup.Name,
+		Releases:  envGroup.Applications,
+		Variables: envGroup.Variables,
+	}
+
+	stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err == nil && len(stackId) > 0 {
+		res.StackID = stackId
+	}
+
+	c.WriteResult(w, r, res)
+
+	// trigger rollout of new applications after writing the result
+	errors := rolloutApplications(c.Config(), cluster, helmAgent, envGroup, configMap, releases)
+
+	if len(errors) > 0 {
+		errStrArr := make([]string, 0)
+
+		for _, err := range errors {
+			errStrArr = append(errStrArr, err.Error())
+		}
+
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf(strings.Join(errStrArr, ","))))
+		return
+	}
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+func rolloutApplications(
+	config *config.Config,
+	cluster *models.Cluster,
+	helmAgent *helm.Agent,
+	envGroup *types.EnvGroup,
+	configMap *v1.ConfigMap,
+	releases []*release.Release,
+) []error {
+	registries, err := config.Repo.Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		return []error{err}
+	}
+
+	// construct the synced env section that should be written
+	newSection := &SyncedEnvSection{
+		Name:    envGroup.Name,
+		Version: envGroup.Version,
+	}
+
+	newSectionKeys := make([]SyncedEnvSectionKey, 0)
+
+	for key, val := range configMap.Data {
+		newSectionKeys = append(newSectionKeys, SyncedEnvSectionKey{
+			Name:   key,
+			Secret: strings.Contains(val, "PORTERSECRET"),
+		})
+	}
+
+	newSection.Keys = newSectionKeys
+
+	// asynchronously update releases with that image repo uri
+	var wg sync.WaitGroup
+	mu := &sync.Mutex{}
+	errors := make([]error, 0)
+
+	for i, rel := range releases {
+		index := i
+		release := rel
+		wg.Add(1)
+
+		go func() {
+			defer wg.Done()
+			// read release via agent
+			newConfig, err := getNewConfig(release.Config, newSection)
+
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err)
+				mu.Unlock()
+				return
+			}
+
+			// if this is a job chart, update the config and set correct paused param to true
+			if release.Chart.Name() == "job" {
+				newConfig["paused"] = true
+			}
+
+			conf := &helm.UpgradeReleaseConfig{
+				Name:       releases[index].Name,
+				Cluster:    cluster,
+				Repo:       config.Repo,
+				Registries: registries,
+				Values:     newConfig,
+			}
+
+			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf)
+
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err)
+				mu.Unlock()
+				return
+			}
+		}()
+	}
+
+	wg.Wait()
+
+	return errors
+}
+
+type SyncedEnvSection struct {
+	Name    string                `json:"name" yaml:"name"`
+	Version uint                  `json:"version" yaml:"version"`
+	Keys    []SyncedEnvSectionKey `json:"keys" yaml:"keys"`
+}
+
+type SyncedEnvSectionKey struct {
+	Name   string `json:"name" yaml:"name"`
+	Secret bool   `json:"secret" yaml:"secret"`
+}
+
+func getNewConfig(curr map[string]interface{}, syncedEnvSection *SyncedEnvSection) (map[string]interface{}, error) {
+	// look for container.env.synced
+	envConf, err := getNestedMap(curr, "container", "env")
+
+	if err != nil {
+		return nil, err
+	}
+
+	syncedEnvInter, syncedEnvExists := envConf["synced"]
+
+	if !syncedEnvExists {
+		return curr, nil
+	} else {
+		syncedArr := make([]*SyncedEnvSection, 0)
+		syncedArrInter, ok := syncedEnvInter.([]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("could not convert to synced env section: not an array")
+		}
+
+		for _, syncedArrInterObj := range syncedArrInter {
+			syncedArrObj := &SyncedEnvSection{}
+			syncedArrInterObjMap, ok := syncedArrInterObj.(map[string]interface{})
+
+			if !ok {
+				continue
+			}
+
+			if nameField, nameFieldExists := syncedArrInterObjMap["name"]; nameFieldExists {
+				syncedArrObj.Name, ok = nameField.(string)
+
+				if !ok {
+					continue
+				}
+			}
+
+			if versionField, versionFieldExists := syncedArrInterObjMap["version"]; versionFieldExists {
+				versionFloat, ok := versionField.(float64)
+
+				if !ok {
+					continue
+				}
+
+				syncedArrObj.Version = uint(versionFloat)
+			}
+
+			if keyField, keyFieldExists := syncedArrInterObjMap["keys"]; keyFieldExists {
+				keyFieldInterArr, ok := keyField.([]interface{})
+
+				if !ok {
+					continue
+				}
+
+				keyFieldMapArr := make([]map[string]interface{}, 0)
+
+				for _, keyFieldInter := range keyFieldInterArr {
+					mapConv, ok := keyFieldInter.(map[string]interface{})
+
+					if !ok {
+						continue
+					}
+
+					keyFieldMapArr = append(keyFieldMapArr, mapConv)
+				}
+
+				keyFieldRes := make([]SyncedEnvSectionKey, 0)
+
+				for _, keyFieldMap := range keyFieldMapArr {
+					toAdd := SyncedEnvSectionKey{}
+
+					if nameField, nameFieldExists := keyFieldMap["name"]; nameFieldExists {
+						toAdd.Name, ok = nameField.(string)
+
+						if !ok {
+							continue
+						}
+					}
+
+					if secretField, secretFieldExists := keyFieldMap["secret"]; secretFieldExists {
+						toAdd.Secret, ok = secretField.(bool)
+
+						if !ok {
+							continue
+						}
+					}
+
+					keyFieldRes = append(keyFieldRes, toAdd)
+				}
+
+				syncedArrObj.Keys = keyFieldRes
+			}
+
+			syncedArr = append(syncedArr, syncedArrObj)
+		}
+
+		resArr := make([]SyncedEnvSection, 0)
+		foundMatch := false
+
+		for _, candidate := range syncedArr {
+			if candidate.Name == syncedEnvSection.Name {
+				resArr = append(resArr, *syncedEnvSection)
+				foundMatch = true
+			} else {
+				resArr = append(resArr, *candidate)
+			}
+		}
+
+		if !foundMatch {
+			return curr, nil
+		}
+
+		envConf["synced"] = resArr
+	}
+
+	// to remove all types that Helm may not be able to work with, we marshal to and from
+	// yaml for good measure. Otherwise we get silly error messages like:
+	// Upgrade failed: template: web/templates/deployment.yaml:138:40: executing \"web/templates/deployment.yaml\"
+	// at <$syncedEnv.keys>: can't evaluate field keys in type namespace.SyncedEnvSection
+	currYAML, err := yaml.Marshal(curr)
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make(map[string]interface{})
+
+	err = yaml.Unmarshal([]byte(currYAML), &res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+
+	for _, field := range fields {
+		objField, ok := curr[field]
+
+		if !ok {
+			return nil, fmt.Errorf("%s not found", field)
+		}
+
+		res, ok = objField.(map[string]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	return res, nil
+}
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, envGroup *types.EnvGroup) error {
+	// update the relevant env group version number if tied to a stack resource
+	return stacks.UpdateEnvGroupVersion(config, projectID, clusterID, envGroup)
+}

+ 91 - 0
api/server/handlers/v1/env_group/delete.go

@@ -0,0 +1,91 @@
+package env_group
+
+import (
+	"fmt"
+	"net/http"
+
+	"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/server/shared/requestutils"
+	"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"
+)
+
+type DeleteEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteEnvGroupHandler {
+	return &DeleteEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeleteEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get the env group: if it's MetaVersion=2, return an error
+	envGroup, err := envgroup.GetEnvGroup(agent, name, namespace, 0)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if envGroup != nil && envGroup.MetaVersion == 1 {
+		if err := deleteV1ConfigMap(agent, name, namespace); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if envGroup != nil && envGroup.MetaVersion == 2 {
+		if len(envGroup.Applications) != 0 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("env group must not have any connected releases"),
+				http.StatusPreconditionFailed,
+			))
+
+			return
+		} else if err = envgroup.DeleteEnvGroup(agent, name, namespace); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}
+
+func deleteV1ConfigMap(agent *kubernetes.Agent, name, namespace string) error {
+	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
+		return err
+	}
+
+	if err := agent.DeleteConfigMap(name, namespace); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 88 - 0
api/server/handlers/v1/env_group/get.go

@@ -0,0 +1,88 @@
+package env_group
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type GetEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetEnvGroupHandler {
+	return &GetEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	version, reqErr := requestutils.GetURLParamUint(r, types.URLParamEnvGroupVersion)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err := envgroup.GetEnvGroup(agent, name, namespace, version)
+
+	if err != nil {
+		if strings.Contains(err.Error(), "not found") {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.V1EnvGroupResponse{
+		CreatedAt: envGroup.CreatedAt,
+		Version:   envGroup.Version,
+		Name:      envGroup.Name,
+		Releases:  envGroup.Applications,
+		Variables: envGroup.Variables,
+	}
+
+	stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err == nil && len(stackId) > 0 {
+		res.StackID = stackId
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 95 - 0
api/server/handlers/v1/env_group/get_all_versions.go

@@ -0,0 +1,95 @@
+package env_group
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/server/shared/requestutils"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type GetEnvGroupAllVersionsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetEnvGroupAllVersionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetEnvGroupAllVersionsHandler {
+	return &GetEnvGroupAllVersionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetEnvGroupAllVersionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMaps, err := agent.ListVersionedConfigMaps(name, namespace)
+
+	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 {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.V1EnvGroupsAllVersionsResponse
+
+	for _, cm := range configMaps {
+		eg, err := envgroup.ToEnvGroup(&cm)
+
+		if err != nil {
+			continue
+		}
+
+		elem := &types.V1EnvGroupResponse{
+			CreatedAt: eg.CreatedAt,
+			Version:   eg.Version,
+			Name:      eg.Name,
+			Releases:  eg.Applications,
+			Variables: eg.Variables,
+		}
+
+		stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, eg)
+
+		if err == nil && len(stackId) > 0 {
+			elem.StackID = stackId
+		}
+
+		res = append(res, elem)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 104 - 0
api/server/handlers/v1/env_group/list.go

@@ -0,0 +1,104 @@
+package env_group
+
+import (
+	"net/http"
+
+	"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/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type ListEnvGroupsHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListEnvGroupsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListEnvGroupsHandler {
+	return &ListEnvGroupsHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListEnvGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get all versioned config maps
+	configMaps, err := agent.ListAllVersionedConfigMaps(namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.V1ListAllEnvGroupsResponse
+
+	for _, cm := range configMaps {
+		eg, err := envgroup.ToEnvGroup(&cm)
+
+		if err != nil {
+			continue
+		}
+
+		elem := &types.V1EnvGroupMeta{
+			CreatedAt: eg.CreatedAt,
+			Name:      eg.Name,
+		}
+
+		stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, eg)
+
+		if err == nil && len(stackId) > 0 {
+			elem.StackID = stackId
+		}
+
+		res = append(res, elem)
+	}
+
+	// get all meta-version 1 configmaps
+	configMapList, err := agent.ListConfigMaps(namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, v1CM := range configMapList.Items {
+		eg, err := envgroup.ToEnvGroup(&v1CM)
+
+		if err != nil {
+			continue
+		}
+
+		elem := &types.V1EnvGroupMeta{
+			CreatedAt: eg.CreatedAt,
+			Name:      eg.Name,
+		}
+
+		stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, eg)
+
+		if err == nil && len(stackId) > 0 {
+			elem.StackID = stackId
+		}
+
+		res = append(res, elem)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 79 - 0
api/server/handlers/v1/env_group/remove_release.go

@@ -0,0 +1,79 @@
+package env_group
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RemoveReleaseFromEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRemoveReleaseFromEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RemoveReleaseFromEnvGroupHandler {
+	return &RemoveReleaseFromEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RemoveReleaseFromEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.V1EnvGroupReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the attached configmap
+	cm, _, err := agent.GetLatestVersionedConfigMap(name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.RemoveApplicationFromVersionedConfigMap(cm, request.ReleaseName)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 6 - 1
api/server/router/router.go

@@ -120,7 +120,12 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
 		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
 		v1StackRegisterer := v1.NewV1StackScopedRegisterer()
 		v1StackRegisterer := v1.NewV1StackScopedRegisterer()
-		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer, v1StackRegisterer)
+		v1EnvGroupRegisterer := v1.NewV1EnvGroupScopedRegisterer()
+		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(
+			v1ReleaseRegisterer,
+			v1StackRegisterer,
+			v1EnvGroupRegisterer,
+		)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 			v1ClusterRegisterer,
 			v1ClusterRegisterer,

+ 506 - 0
api/server/router/v1/env_group.go

@@ -0,0 +1,506 @@
+package v1
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi"
+	v1EnvGroup "github.com/porter-dev/porter/api/server/handlers/v1/env_group"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// swagger:parameters getEnvGroup
+type envGroupVersionPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The env group name
+	// in: path
+	// required: true
+	Name string `json:"name"`
+
+	// The version of the env group. 0 means latest version.
+	// in: path
+	// required: true
+	// minimum: 0
+	Version uint `json:"version"`
+}
+
+// swagger:parameters getEnvGroupAllVersions deleteEnvGroup addReleaseToEnvGroup removeReleaseFromEnvGroup
+type envGroupPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The env group name
+	// in: path
+	// required: true
+	Name string `json:"name"`
+}
+
+func NewV1EnvGroupScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1EnvGroupScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1EnvGroupScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1EnvGroupRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1EnvGroupRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/env_groups"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups -> namespace.NewCreateEnvGroupHandler
+	// swagger:operation PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups createOrUpdateEnvGroup
+	//
+	// Creates a new env group or updates an existing one in the namespace denoted by `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id`. The cluster should belong to the project denoted by `project_id`.
+	//
+	// **Note:** If updating an existing env group, the linked releases with the env group will all be updated as well.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create or update an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateEnvGroupRequest
+	//     description: The env group to create or update
+	//     schema:
+	//       $ref: '#/definitions/CreateEnvGroupRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully created a new namespace
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupResponse'
+	//   '403':
+	//     description: Forbidden
+	createOrUpdateEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPut,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createOrUpdateEnvGroupHandler := v1EnvGroup.NewCreateEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createOrUpdateEnvGroupEndpoint,
+		Handler:  createOrUpdateEnvGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/versions/{version} -> env_group.NewGetEnvGroupHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/versions/{version} getEnvGroup
+	//
+	// Gets an env group denoted by `name` and `version` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`. **Note:** To get the latest version of an env group, set `version` to `0`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '200':
+	//     description: Successfully fetched the env group
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	getEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/versions/{%s}", relPath, types.URLParamEnvGroupName,
+					types.URLParamEnvGroupVersion),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getEnvGroupHandler := v1EnvGroup.NewGetEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEnvGroupEndpoint,
+		Handler:  getEnvGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} -> env_group.NewGetEnvGroupAllVersionsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} getEnvGroupAllVersions
+	//
+	// Gets all versions of an env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get all versions of an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	// responses:
+	//   '200':
+	//     description: Successfully fetched the env group
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupsAllVersionsResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	getEnvGroupAllVersionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getEnvGroupAllVersionsHandler := v1EnvGroup.NewGetEnvGroupAllVersionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEnvGroupAllVersionsEndpoint,
+		Handler:  getEnvGroupAllVersionsHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups -> namespace.NewListEnvGroupsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups listAllEnvGroups
+	//
+	// Gets all env groups in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get all env groups
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully fetched the env group
+	//     schema:
+	//       $ref: '#/definitions/V1ListAllEnvGroupsResponse'
+	//   '403':
+	//     description: Forbidden
+	listEnvGroupsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	listEnvGroupsHandler := v1EnvGroup.NewListEnvGroupsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvGroupsEndpoint,
+		Handler:  listEnvGroupsHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} -> env_group.NewDeleteEnvGroupHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} deleteEnvGroup
+	//
+	// Deletes the env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the env group
+	//   '403':
+	//     description: Forbidden
+	//   '412':
+	//     description: Env group is linked to one or more releases
+	deleteEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	deleteEnvGroupHandler := v1EnvGroup.NewDeleteEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEnvGroupEndpoint,
+		Handler:  deleteEnvGroupHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/add_release -> env_group.NewAddReleaseToEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/add_release addReleaseToEnvGroup
+	//
+	// Adds a release to the env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add a release to an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - in: body
+	//     name: V1EnvGroupReleaseRequest
+	//     description: The name of the release to add
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupReleaseRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the release
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	addReleaseToEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/add_release", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	addReleaseToEnvGroupHandler := v1EnvGroup.NewAddReleaseToEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addReleaseToEnvGroupEndpoint,
+		Handler:  addReleaseToEnvGroupHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/remove_release -> env_group.NewRemoveReleaseFromEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/remove_release removeReleaseFromEnvGroup
+	//
+	// Removes a release from the env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove a release from an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - in: body
+	//     name: V1EnvGroupReleaseRequest
+	//     description: The name of the release to remove
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupReleaseRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully removed the release
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	removeReleaseFromEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/remove_release", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	removeReleaseFromEnvGroupHandler := v1EnvGroup.NewRemoveReleaseFromEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeReleaseFromEnvGroupEndpoint,
+		Handler:  removeReleaseFromEnvGroupHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 1 - 1
api/server/router/v1/namespace.go

@@ -8,7 +8,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
-// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks
+// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks createOrUpdateEnvGroup listAllEnvGroups
 type namespacePathParams struct {
 type namespacePathParams struct {
 	// The project id
 	// The project id
 	// in: path
 	// in: path

+ 67 - 5
api/types/namespace.go

@@ -9,8 +9,10 @@ import (
 )
 )
 
 
 const (
 const (
-	URLParamPodName     URLParam = "name"
-	URLParamIngressName URLParam = "name"
+	URLParamPodName         URLParam = "name"
+	URLParamIngressName     URLParam = "name"
+	URLParamEnvGroupName    URLParam = "name"
+	URLParamEnvGroupVersion URLParam = "version"
 )
 )
 
 
 // ReleaseListFilter is a struct that represents the various filter options used for
 // ReleaseListFilter is a struct that represents the various filter options used for
@@ -153,10 +155,19 @@ type AddEnvGroupApplicationRequest struct {
 
 
 type ListEnvGroupsResponse []*EnvGroupMeta
 type ListEnvGroupsResponse []*EnvGroupMeta
 
 
+// CreateEnvGroupRequest represents the request body to create or update an env group
+//
+// swagger:model
 type CreateEnvGroupRequest struct {
 type CreateEnvGroupRequest struct {
-	Name            string            `json:"name,required"`
-	Variables       map[string]string `json:"variables,required"`
-	SecretVariables map[string]string `json:"secret_variables,required"`
+	// the name of the env group to create or update
+	// example: prod-env-group
+	Name string `json:"name" form:"required"`
+
+	// the variables to include in the env group
+	Variables map[string]string `json:"variables" form:"required"`
+
+	// the secret variables to include in the env group
+	SecretVariables map[string]string `json:"secret_variables"`
 }
 }
 
 
 type CreateConfigMapResponse struct {
 type CreateConfigMapResponse struct {
@@ -215,3 +226,54 @@ type GetEnvGroupResponse struct {
 	*EnvGroup
 	*EnvGroup
 	StackID string `json:"stack_id,omitempty"`
 	StackID string `json:"stack_id,omitempty"`
 }
 }
+
+// V1EnvGroupReleaseRequest represents the request body to add or remove a release in an env group
+//
+// swagger:model
+type V1EnvGroupReleaseRequest struct {
+	ReleaseName string `json:"release_name" form:"required"`
+}
+
+// V1EnvGroupResponse defines an env group
+//
+// swagger:model
+type V1EnvGroupResponse struct {
+	// the UTC timestamp in RFC 3339 format indicating the creation time of the env group
+	CreatedAt time.Time `json:"created_at"`
+
+	// the version of the env group
+	Version uint `json:"version"`
+
+	// the name of the env group
+	Name string `json:"name"`
+
+	// the list of releases linked to this env group
+	Releases []string `json:"releases"`
+
+	// the variables contained in this env group
+	Variables map[string]string `json:"variables"`
+
+	// the ID of the stack containing this env group (if any)
+	StackID string `json:"stack_id,omitempty"`
+}
+
+// V1EnvGroupsAllVersionsResponse represents the response body containing all versions of an env group
+//
+// swagger:model
+type V1EnvGroupsAllVersionsResponse []*V1EnvGroupResponse
+
+type V1EnvGroupMeta struct {
+	// the UTC timestamp in RFC 3339 format indicating the creation time of the env group
+	CreatedAt time.Time `json:"created_at"`
+
+	// the name of the env group
+	Name string `json:"name"`
+
+	// the ID of the stack containing this env group (if any)
+	StackID string `json:"stack_id,omitempty"`
+}
+
+// V1ListAllEnvGroupsResponse represents the response body containing the list of env groups
+//
+// swagger:model
+type V1ListAllEnvGroupsResponse []*V1EnvGroupMeta

+ 7 - 6
api/types/stacks.go

@@ -212,9 +212,12 @@ type StackSourceConfig struct {
 	// The numerical revision id that this source config belongs to
 	// The numerical revision id that this source config belongs to
 	StackRevisionID uint `json:"stack_revision_id"`
 	StackRevisionID uint `json:"stack_revision_id"`
 
 
-	// The display name of the stack source
+	// Unique name for the source config
 	Name string `json:"name"`
 	Name string `json:"name"`
 
 
+	// Display name for the stack source
+	DisplayName string `json:"display_name"`
+
 	// The unique id of the stack source config
 	// The unique id of the stack source config
 	ID string `json:"id"`
 	ID string `json:"id"`
 
 
@@ -226,9 +229,6 @@ type StackSourceConfig struct {
 
 
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
-
-	// Unique ID to identify between revisions
-	StableSourceConfigID string `json:"stable_source_config_id"`
 }
 }
 
 
 // swagger:model
 // swagger:model
@@ -253,6 +253,9 @@ type CreateStackEnvGroupRequest struct {
 
 
 // swagger:model
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 type CreateStackSourceConfigRequest struct {
+	// required: true
+	DisplayName string `json:"display_name" form:"required"`
+
 	// required: true
 	// required: true
 	Name string `json:"name" form:"required"`
 	Name string `json:"name" form:"required"`
 
 
@@ -262,8 +265,6 @@ type CreateStackSourceConfigRequest struct {
 	// required: true
 	// required: true
 	ImageTag string `json:"image_tag" form:"required"`
 	ImageTag string `json:"image_tag" form:"required"`
 
 
-	StableSourceConfigID string `json:"source_config_id,omitempty"`
-
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 }
 }

+ 66 - 3
cli/cmd/deploy.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -148,10 +149,19 @@ for the application:
 
 
 var updatePushCmd = &cobra.Command{
 var updatePushCmd = &cobra.Command{
 	Use:   "push",
 	Use:   "push",
-	Short: "Pushes a new image for an application specified by the --app flag.",
+	Short: "Pushes an image to a Docker registry linked to your Porter project.",
+	Args:  cobra.MaximumNArgs(1),
 	Long: fmt.Sprintf(`
 	Long: fmt.Sprintf(`
 %s
 %s
 
 
+Pushes a local Docker image to a registry linked to your Porter project. This command
+requires the project ID to be set either by using the %s command
+or the --project flag. For example, to push a local nginx image:
+
+  %s
+
+%s
+
 Pushes a new image for an application specified by the --app flag. This command uses
 Pushes a new image for an application specified by the --app flag. This command uses
 the image repository saved in the application config by default. For example, if an
 the image repository saved in the application config by default. For example, if an
 application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx",
 application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx",
@@ -164,6 +174,9 @@ are using an image registry that was created outside of Porter, make sure that y
 linked it via "porter connect".
 linked it via "porter connect".
 `,
 `,
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
+		color.New(color.FgBlue).Sprintf("porter config set-project"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update push gcr.io/snowflake-123456/nginx:1234567"),
+		color.New(color.Bold).Sprintf("LEGACY USAGE:"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 	),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
@@ -369,8 +382,6 @@ func init() {
 
 
 	updateBuildCmd.MarkPersistentFlagRequired("app")
 	updateBuildCmd.MarkPersistentFlagRequired("app")
 
 
-	updatePushCmd.MarkPersistentFlagRequired("app")
-
 	updateConfigCmd.MarkPersistentFlagRequired("app")
 	updateConfigCmd.MarkPersistentFlagRequired("app")
 
 
 	updateEnvGroupCmd.PersistentFlags().StringVar(
 	updateEnvGroupCmd.PersistentFlags().StringVar(
@@ -490,6 +501,58 @@ func updateBuild(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 }
 }
 
 
 func updatePush(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func updatePush(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	if app == "" {
+		if len(args) == 0 {
+			return fmt.Errorf("please provide the docker image name")
+		}
+
+		image := args[0]
+
+		registries, err := client.ListRegistries(context.Background(), cliConf.Project)
+
+		if err != nil {
+			return err
+		}
+
+		regs := *registries
+		regID := uint(0)
+
+		for _, reg := range regs {
+			if strings.Contains(image, reg.URL) {
+				regID = reg.ID
+				break
+			}
+		}
+
+		if regID == 0 {
+			return fmt.Errorf("could not find registry for image: %s", image)
+		}
+
+		err = client.CreateRepository(context.Background(), cliConf.Project, regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: strings.Split(image, ":")[0],
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+
+		agent, err := docker.NewAgentWithAuthGetter(client, cliConf.Project)
+
+		if err != nil {
+			return err
+		}
+
+		err = agent.PushImage(image)
+
+		if err != nil {
+			return err
+		}
+
+		return nil
+	}
+
 	updateAgent, err := updateGetAgent(client)
 	updateAgent, err := updateGetAgent(client)
 
 
 	if err != nil {
 	if err != nil {

+ 14 - 3
cli/cmd/run.go

@@ -33,6 +33,8 @@ import (
 
 
 var namespace string
 var namespace string
 var verbose bool
 var verbose bool
+var existingPod bool
+var nonInteractive bool
 
 
 // runCmd represents the "porter run" base command when called
 // runCmd represents the "porter run" base command when called
 // without any subcommands
 // without any subcommands
@@ -63,8 +65,6 @@ var cleanupCmd = &cobra.Command{
 	},
 	},
 }
 }
 
 
-var existingPod bool
-
 func init() {
 func init() {
 	rootCmd.AddCommand(runCmd)
 	rootCmd.AddCommand(runCmd)
 
 
@@ -91,12 +91,23 @@ func init() {
 		"whether to print verbose output",
 		"whether to print verbose output",
 	)
 	)
 
 
+	runCmd.PersistentFlags().BoolVar(
+		&nonInteractive,
+		"non-interactive",
+		false,
+		"whether to run in non-interactive mode",
+	)
+
 	runCmd.AddCommand(cleanupCmd)
 	runCmd.AddCommand(cleanupCmd)
 }
 }
 
 
 func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
 
 
+	if nonInteractive {
+		color.New(color.FgBlue).Println("Using non-interactive mode. The first available pod will be used to run the command.")
+	}
+
 	podsSimple, err := getPods(client, namespace, args[0])
 	podsSimple, err := getPods(client, namespace, args[0])
 
 
 	if err != nil {
 	if err != nil {
@@ -108,7 +119,7 @@ func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 
 
 	if len(podsSimple) == 0 {
 	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
 		return fmt.Errorf("At least one pod must exist in this deployment.")
-	} else if len(podsSimple) == 1 || !existingPod {
+	} else if nonInteractive || len(podsSimple) == 1 || !existingPod {
 		selectedPod = podsSimple[0]
 		selectedPod = podsSimple[0]
 	} else {
 	} else {
 		podNames := make([]string, 0)
 		podNames := make([]string, 0)

+ 28 - 0
cmd/migrate/main.go

@@ -5,6 +5,7 @@ import (
 
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
+	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
 
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
@@ -61,6 +62,14 @@ func main() {
 		}
 		}
 	}
 	}
 
 
+	if shouldPopulateSourceConfigDisplayName() {
+		err := populate_source_config_display_name.PopulateSourceConfigDisplayName(db, logger)
+
+		if err != nil {
+			logger.Fatal().Err(err).Msg("failed to populate source config display name")
+		}
+	}
+
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 		logger.Fatal().Err(err).Msg("vault migration failed")
 		logger.Fatal().Err(err).Msg("vault migration failed")
 	}
 	}
@@ -83,3 +92,22 @@ func shouldKeyRotate() (bool, string, string) {
 
 
 	return c.OldEncryptionKey != "" && c.NewEncryptionKey != "", c.OldEncryptionKey, c.NewEncryptionKey
 	return c.OldEncryptionKey != "" && c.NewEncryptionKey != "", c.OldEncryptionKey, c.NewEncryptionKey
 }
 }
+
+type PopulateSourceConfigDisplayNameConf struct {
+	// we add a dummy field to avoid empty struct issue with envdecode
+	DummyField string `env:"ASDF,default=asdf"`
+
+	// if true, will populate the display name for all source configs
+	PopulateSourceConfigDisplayName bool `env:"POPULATE_SOURCE_CONFIG_DISPLAY_NAME"`
+}
+
+func shouldPopulateSourceConfigDisplayName() bool {
+	var c PopulateSourceConfigDisplayNameConf
+
+	if err := envdecode.StrictDecode(&c); err != nil {
+		log.Fatalf("Failed to decode migration conf: %s", err)
+		return false
+	}
+
+	return c.PopulateSourceConfigDisplayName
+}

+ 365 - 0
cmd/migrate/populate_source_config_display_name/helpers_test.go

@@ -0,0 +1,365 @@
+package populate_source_config_display_name_test
+
+import (
+	"fmt"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+	_gorm "gorm.io/gorm"
+)
+
+type tester struct {
+	Key *[32]byte
+	DB  *_gorm.DB
+
+	repo         repository.Repository
+	dbFileName   string
+	key          *[32]byte
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initClusters []*models.Cluster
+	initKIs      []*ints.KubeIntegration
+	initStacks   []*models.Stack
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&env.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.User{},
+		&models.Cluster{},
+		&models.Stack{},
+		&models.StackEnvGroup{},
+		&models.StackSourceConfig{},
+		&models.StackRevision{},
+		&models.StackResource{},
+		&ints.KubeIntegration{},
+		&ints.ClusterTokenCache{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+	tester.Key = &key
+	tester.DB = db
+
+	tester.repo = gorm.NewRepository(db, &key, nil)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User().CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initCluster(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initKIs) == 0 {
+		initKubeIntegration(tester, t)
+	}
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+		TokenCache: ints.ClusterTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
+		},
+	}
+
+	cluster, err := tester.repo.Cluster().CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initClusters = append(tester.initClusters, cluster)
+}
+
+func initProject(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project().CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initKubeIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	ki := &ints.KubeIntegration{
+		Mechanism:             ints.KubeLocal,
+		ProjectID:             tester.initProjects[0].ID,
+		UserID:                tester.initUsers[0].ID,
+		Kubeconfig:            []byte("current-context: testing\n"),
+		ClientCertificateData: []byte("clientcertdata"),
+		ClientKeyData:         []byte("clientkeydata"),
+		Token:                 []byte("token"),
+		Username:              []byte("username"),
+		Password:              []byte("password"),
+	}
+
+	ki, err := tester.repo.KubeIntegration().CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initKIs = append(tester.initKIs, ki)
+}
+
+func initEmptyStack(tester *tester, t *testing.T, stackName string) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	if len(tester.initClusters) == 0 {
+		initCluster(tester, t)
+	}
+
+	uid, _ := encryption.GenerateRandomBytes(16)
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: tester.initProjects[0].ID,
+		ClusterID: tester.initClusters[0].ID,
+		Namespace: "test-namespace",
+		Name:      stackName,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  []models.StackSourceConfig{},
+			},
+		},
+	}
+
+	newStack, err := tester.repo.Stack().CreateStack(stack)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initStacks = append(tester.initStacks, newStack)
+}
+
+func initStack(tester *tester, t *testing.T, stackName string) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	if len(tester.initClusters) == 0 {
+		initCluster(tester, t)
+	}
+
+	uid, _ := encryption.GenerateRandomBytes(16)
+
+	sourceConfigs := []models.StackSourceConfig{
+		{
+			Name:         "source-config-1",
+			ImageRepoURI: "some-repo",
+			ImageTag:     "some-tag",
+			UID:          uid,
+		},
+	}
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: tester.initProjects[0].ID,
+		ClusterID: tester.initClusters[0].ID,
+		Namespace: "test-namespace",
+		Name:      stackName,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  sourceConfigs,
+			},
+		},
+	}
+
+	newStack, err := tester.repo.Stack().CreateStack(stack)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initStacks = append(tester.initStacks, newStack)
+}
+
+func createNewStackRevision(tester *tester, t *testing.T, stackName string) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+	if len(tester.initClusters) == 0 {
+		initCluster(tester, t)
+	}
+	if len(tester.initStacks) == 0 {
+		initStack(tester, t, stackName)
+	}
+
+	stack := tester.initStacks[0]
+
+	for _, s := range tester.initStacks {
+		if s.Name == stackName {
+			stack = s
+			break
+		}
+	}
+
+	prevRevision := findLatestRevisionByRevisionNumber(t, stack.Revisions)
+
+	oldSourceConfig := prevRevision.SourceConfigs[0]
+
+	newUid, _ := encryption.GenerateRandomBytes(16)
+	sourceConfigs := []models.StackSourceConfig{
+		{
+			Name:         oldSourceConfig.Name,
+			ImageRepoURI: "some-repo-" + fmt.Sprint(prevRevision.RevisionNumber+1),
+			ImageTag:     "some-tag-" + fmt.Sprint(prevRevision.RevisionNumber+1),
+			UID:          newUid,
+		},
+	}
+
+	newRevision := models.StackRevision{
+		RevisionNumber: prevRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  sourceConfigs,
+		StackID:        stack.ID,
+	}
+
+	tester.repo.Stack().AppendNewRevision(&newRevision)
+}
+
+func findLatestRevisionByRevisionNumber(t *testing.T, revisions []models.StackRevision) *models.StackRevision {
+	t.Helper()
+
+	latestRevision := revisions[0]
+	for _, revision := range revisions {
+		if revision.RevisionNumber > latestRevision.RevisionNumber {
+			latestRevision = revision
+		}
+	}
+
+	return &latestRevision
+}
+
+func appendNewSourceConfig(t *testing.T, tester *tester, stack *models.Stack, sourceConfig models.StackSourceConfig) {
+	t.Helper()
+
+	prevRevision := findLatestRevisionByRevisionNumber(t, stack.Revisions)
+
+	previousSourceConfigs := []models.StackSourceConfig{}
+
+	for _, sourceConfig := range prevRevision.SourceConfigs {
+		newUid, _ := encryption.GenerateRandomBytes(16)
+
+		sc := models.StackSourceConfig{
+			Name:         sourceConfig.Name,
+			ImageRepoURI: sourceConfig.ImageRepoURI,
+			ImageTag:     sourceConfig.ImageTag,
+			UID:          newUid,
+		}
+		previousSourceConfigs = append(previousSourceConfigs, sc)
+	}
+
+	newRevision := models.StackRevision{
+		RevisionNumber: prevRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  append(prevRevision.SourceConfigs, sourceConfig),
+		StackID:        stack.ID,
+	}
+
+	tester.repo.Stack().AppendNewRevision(&newRevision)
+}

+ 40 - 0
cmd/migrate/populate_source_config_display_name/populate.go

@@ -0,0 +1,40 @@
+package populate_source_config_display_name
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	lr "github.com/porter-dev/porter/pkg/logger"
+	_gorm "gorm.io/gorm"
+)
+
+func PopulateSourceConfigDisplayName(db *_gorm.DB, logger *lr.Logger) error {
+	logger.Info().Msg("Initiated source config display name population")
+	// get all source configs
+	sourceConfigs := make([]*models.StackSourceConfig, 0)
+
+	if err := db.Find(&sourceConfigs).Error; err != nil {
+		logger.Error().Msgf("failed to get source configs %v", err)
+		return err
+	}
+
+	if len(sourceConfigs) == 0 {
+		logger.Info().Msg("no source configs to populate")
+		return nil
+	}
+
+	updatedCount := 0
+	// copy name to display name if display name is empty
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.DisplayName == "" {
+			sourceConfig.DisplayName = sourceConfig.Name
+			updatedCount++
+		}
+	}
+	// update source configs
+	if err := db.Save(&sourceConfigs).Error; err != nil {
+		logger.Error().Msgf("failed to update source configs %v", err)
+		return err
+	}
+
+	logger.Info().Msgf("source config display name population completed, %d source configs updated", updatedCount)
+	return nil
+}

+ 76 - 0
cmd/migrate/populate_source_config_display_name/populate_test.go

@@ -0,0 +1,76 @@
+package populate_source_config_display_name_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
+
+	"github.com/porter-dev/porter/internal/models"
+	lr "github.com/porter-dev/porter/pkg/logger"
+)
+
+func TestAllSourceConfigsArePopulated(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_stable_source_config_id_population.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	defer cleanup(tester, t)
+
+	stackName := "first-stack"
+
+	initStack(tester, t, stackName)
+
+	createNewStackRevision(tester, t, stackName)
+
+	createNewStackRevision(tester, t, stackName)
+
+	createNewStackRevision(tester, t, stackName)
+
+	err := populate_source_config_display_name.PopulateSourceConfigDisplayName(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("%\n", err)
+		return
+	}
+
+	sourceConfigs := []*models.StackSourceConfig{}
+
+	if err := tester.DB.Find(&sourceConfigs).Error; err != nil {
+		t.Fatalf("failed to find source configs: %s", err)
+	}
+
+	if len(sourceConfigs) != 4 {
+		t.Fatalf("expected 4 source configs, got %d", len(sourceConfigs))
+	}
+
+	for _, sc := range sourceConfigs {
+		if sc.DisplayName == "" {
+			t.Fatalf("expected display name to be populated, got empty string")
+		}
+	}
+}
+
+func TestPopulateOnEmptyStack(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_stable_source_config_id_population.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	initEmptyStack(tester, t, "empty-stack")
+
+	defer cleanup(tester, t)
+
+	err := populate_source_config_display_name.PopulateSourceConfigDisplayName(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("expected no error, got %s", err)
+		return
+	}
+}

Разница между файлами не показана из-за своего большого размера
+ 14275 - 1
dashboard/package-lock.json


+ 8 - 0
dashboard/src/components/porter-form/FormDebugger.tsx

@@ -11,6 +11,7 @@ import "ace-builds/src-noconflict/mode-text";
 
 
 import Heading from "../form-components/Heading";
 import Heading from "../form-components/Heading";
 import Helper from "../form-components/Helper";
 import Helper from "../form-components/Helper";
+import { ChartType } from "shared/types";
 
 
 type PropsType = {
 type PropsType = {
   goBack: () => void;
   goBack: () => void;
@@ -170,6 +171,13 @@ export default class FormDebugger extends Component<PropsType, StateType> {
           rightTabOptions={this.state.showBonusTabs ? tabOptions : []}
           rightTabOptions={this.state.showBonusTabs ? tabOptions : []}
           renderTabContents={this.renderTabContents}
           renderTabContents={this.renderTabContents}
           saveButtonText={"Test Submit"}
           saveButtonText={"Test Submit"}
+          injectedProps={{
+            "url-link": {
+              chart: {
+                name: "something",
+              } as ChartType,
+            },
+          }}
         />
         />
       </StyledFormDebugger>
       </StyledFormDebugger>
     );
     );

+ 4 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -12,6 +12,7 @@ import {
   SelectField,
   SelectField,
   ServiceIPListField,
   ServiceIPListField,
   TextAreaField,
   TextAreaField,
+  UrlLinkField,
 } from "./types";
 } from "./types";
 import TabRegion, { TabOption } from "../TabRegion";
 import TabRegion, { TabOption } from "../TabRegion";
 import Heading from "../form-components/Heading";
 import Heading from "../form-components/Heading";
@@ -29,6 +30,7 @@ import ResourceList from "./field-components/ResourceList";
 import VeleroForm from "./field-components/VeleroForm";
 import VeleroForm from "./field-components/VeleroForm";
 import CronInput from "./field-components/CronInput";
 import CronInput from "./field-components/CronInput";
 import TextAreaInput from "./field-components/TextAreaInput";
 import TextAreaInput from "./field-components/TextAreaInput";
+import UrlLink from "./field-components/UrlLink";
 
 
 interface Props {
 interface Props {
   leftTabOptions?: TabOption[];
   leftTabOptions?: TabOption[];
@@ -98,6 +100,8 @@ const PorterForm: React.FC<Props> = (props) => {
         return <CronInput {...(bundledProps as CronField)} />;
         return <CronInput {...(bundledProps as CronField)} />;
       case "text-area":
       case "text-area":
         return <TextAreaInput {...(bundledProps as TextAreaField)} />;
         return <TextAreaInput {...(bundledProps as TextAreaField)} />;
+      case "url-link":
+        return <UrlLink {...(bundledProps as UrlLinkField)} />;
     }
     }
     return <p>Not Implemented: {(field as any).type}</p>;
     return <p>Not Implemented: {(field as any).type}</p>;
   };
   };

+ 94 - 0
dashboard/src/components/porter-form/field-components/UrlLink.tsx

@@ -0,0 +1,94 @@
+import { get } from "lodash";
+import React from "react";
+import styled from "styled-components";
+import { UrlLinkField } from "../types";
+import { hasSetValue } from "../utils";
+
+const populate = (str: string, obj: unknown) => {
+  return str.replace(/{[^{}]*}+/g, (match) => {
+    const key = match.replace("{", "").replace("}", "");
+    let value;
+    if (key[0] === ".") {
+      value = get(obj, key.substring(1));
+    } else {
+      value = get(obj, key);
+    }
+
+    if (typeof value !== "string") {
+      return "Couldn't find value " + key;
+    }
+
+    return value;
+  });
+};
+
+const UrlLink = (props: UrlLinkField) => {
+  const { value, label, injectedProps } = props;
+
+  if (!hasSetValue(props)) {
+    return null;
+  }
+
+  let val = value;
+
+  if (Array.isArray(value)) {
+    val = value[0];
+  }
+
+  if (typeof val !== "string") {
+    return null;
+  }
+
+  if (!injectedProps?.chart) {
+    return null;
+  }
+
+  const populatedUrl = populate(val, injectedProps.chart);
+
+  return (
+    <>
+      <Label>{label}</Label>
+      <StyledServiceRow>
+        <a href={populatedUrl} target="_blank">
+          <i className="material-icons-outlined">link</i>
+          {populatedUrl}
+        </a>
+      </StyledServiceRow>
+    </>
+  );
+};
+
+export default UrlLink;
+
+const StyledServiceRow = styled.div`
+  width: 100%;
+  height: 40px;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  border-radius: 5px;
+  padding: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > a {
+    margin-left: 2px;
+    font-size: 13px;
+    user-select: text;
+    display: flex;
+    -webkit-box-align: center;
+    align-items: center;
+    > i {
+      font-size: 15px;
+      margin-right: 10px;
+    }
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;

+ 11 - 2
dashboard/src/components/porter-form/types.ts

@@ -5,7 +5,7 @@
 
 
 // YAML Field interfaces
 // YAML Field interfaces
 
 
-import { ContextProps } from "../../shared/types";
+import { ChartType, ContextProps } from "../../shared/types";
 
 
 export interface GenericField {
 export interface GenericField {
   id: string;
   id: string;
@@ -146,6 +146,14 @@ export interface TextAreaField extends GenericInputField {
   };
   };
 }
 }
 
 
+export interface UrlLinkField extends GenericInputField {
+  type: "url-link";
+  label: string;
+  injectedProps: {
+    chart: ChartType;
+  };
+}
+
 export type FormField =
 export type FormField =
   | HeadingField
   | HeadingField
   | SubtitleField
   | SubtitleField
@@ -159,7 +167,8 @@ export type FormField =
   | VeleroBackupField
   | VeleroBackupField
   | VariableField
   | VariableField
   | CronField
   | CronField
-  | TextAreaField;
+  | TextAreaField
+  | UrlLinkField;
 
 
 export interface ShowIfAnd {
 export interface ShowIfAnd {
   and: ShowIf[];
   and: ShowIf[];

+ 1 - 0
dashboard/src/components/repo-selector/RepoList.tsx

@@ -442,6 +442,7 @@ const ProviderSelectorStyles = {
     overflow-y: auto;
     overflow-y: auto;
     width: calc(100% - 4px);
     width: calc(100% - 4px);
     box-shadow: 0 8px 20px 0px #00000088;
     box-shadow: 0 8px 20px 0px #00000088;
+    z-index: 999;
   `,
   `,
   Option: styled.div`
   Option: styled.div`
     display: flex;
     display: flex;

+ 3 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -870,6 +870,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
                                 ? stackEnvGroups
                                 ? stackEnvGroups
                                 : undefined,
                                 : undefined,
                           },
                           },
+                          "url-link": {
+                            chart: currentChart,
+                          },
                         }}
                         }}
                       />
                       />
                     </BodyWrapper>
                     </BodyWrapper>

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

@@ -395,6 +395,9 @@ export const ExpandedJobChartFC: React.FC<{
                   availableSyncEnvGroups:
                   availableSyncEnvGroups:
                     isStack && !disableForm ? stackEnvGroups : undefined,
                     isStack && !disableForm ? stackEnvGroups : undefined,
                 },
                 },
+                "url-link": {
+                  chart: chart,
+                },
               }}
               }}
             />
             />
           )}
           )}

+ 15 - 13
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -30,7 +30,12 @@ const _SourceConfig = ({
     const index = newSourceConfigArray.findIndex(
     const index = newSourceConfigArray.findIndex(
       (sc) => sc.id === sourceConfig.id
       (sc) => sc.id === sourceConfig.id
     );
     );
-    newSourceConfigArray[index] = sourceConfig;
+
+    newSourceConfigArray[index] = {
+      ...sourceConfig,
+      display_name: sourceConfig.display_name || sourceConfig.name,
+    };
+
     setSourceConfigArrayCopy(newSourceConfigArray);
     setSourceConfigArrayCopy(newSourceConfigArray);
   };
   };
 
 
@@ -136,17 +141,19 @@ const SourceConfigItem = ({
   disabled: boolean;
   disabled: boolean;
 }) => {
 }) => {
   const [editNameMode, toggleEditNameMode] = useReducer((prev) => !prev, false);
   const [editNameMode, toggleEditNameMode] = useReducer((prev) => !prev, false);
-  const prevName = useRef(sourceConfig.name);
-  const [name, setName] = useState(sourceConfig.name);
+  const prevName = useRef(sourceConfig.display_name || sourceConfig.name);
+  const [name, setName] = useState(
+    sourceConfig.display_name || sourceConfig.name
+  );
 
 
   const handleNameChange = (newName: string) => {
   const handleNameChange = (newName: string) => {
     setName(newName);
     setName(newName);
-    handleChange({ ...sourceConfig, name: newName });
+    handleChange({ ...sourceConfig, display_name: newName });
   };
   };
 
 
   const handleNameChangeCancel = () => {
   const handleNameChangeCancel = () => {
     setName(prevName.current);
     setName(prevName.current);
-    handleChange({ ...sourceConfig, name: prevName.current });
+    handleChange({ ...sourceConfig, display_name: prevName.current });
     toggleEditNameMode();
     toggleEditNameMode();
   };
   };
 
 
@@ -170,14 +177,9 @@ const SourceConfigItem = ({
         <SourceConfigStyles.ItemTitle>
         <SourceConfigStyles.ItemTitle>
           <span>{name}</span>
           <span>{name}</span>
 
 
-          {sourceConfig.stable_source_config_id && (
-            <EditButton
-              onClick={toggleEditNameMode}
-              disabled={!sourceConfig.stable_source_config_id}
-            >
-              <i className="material-icons-outlined">edit</i>
-            </EditButton>
-          )}
+          <EditButton onClick={toggleEditNameMode}>
+            <i className="material-icons-outlined">edit</i>
+          </EditButton>
         </SourceConfigStyles.ItemTitle>
         </SourceConfigStyles.ItemTitle>
       )}
       )}
 
 

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx

@@ -22,8 +22,8 @@ const SelectSource = () => {
       return;
       return;
     }
     }
 
 
-    const newSource: CreateStackBody["source_configs"][0] = {
-      name: sourceName,
+    const newSource: Omit<CreateStackBody["source_configs"][0], "name"> = {
+      display_name: sourceName,
       image_repo_uri: imageUrl,
       image_repo_uri: imageUrl,
       image_tag: imageTag,
       image_tag: imageTag,
     };
     };

+ 10 - 5
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -11,7 +11,9 @@ export type StacksLaunchContextType = {
   setStackName: (name: string) => void;
   setStackName: (name: string) => void;
   setStackNamespace: (namespace: string) => void;
   setStackNamespace: (namespace: string) => void;
 
 
-  addSourceConfig: (sourceConfig: CreateStackBody["source_configs"][0]) => void;
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => void;
 
 
   addAppResource: (
   addAppResource: (
     appResource: CreateStackBody["app_resources"][0],
     appResource: CreateStackBody["app_resources"][0],
@@ -40,7 +42,9 @@ const defaultValues: StacksLaunchContextType = {
   setStackName: (name: string) => {},
   setStackName: (name: string) => {},
   setStackNamespace: (namespace: string) => {},
   setStackNamespace: (namespace: string) => {},
 
 
-  addSourceConfig: (sourceConfig: CreateStackBody["source_configs"][0]) => {},
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => {},
 
 
   addAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
   addAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
 
 
@@ -92,10 +96,11 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
       source_configs: [
       source_configs: [
         ...prev.source_configs,
         ...prev.source_configs,
         {
         {
-          name:
-            sourceConfig.name ||
-            newSourceConfigName(prev.source_configs.length),
           ...sourceConfig,
           ...sourceConfig,
+          display_name:
+            sourceConfig.display_name ||
+            newSourceConfigName(prev.source_configs.length),
+          name: newSourceConfigName(prev.source_configs.length),
         },
         },
       ],
       ],
     }));
     }));

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -9,6 +9,7 @@ export type CreateStackBody = {
     values: unknown;
     values: unknown;
   }[];
   }[];
   source_configs: {
   source_configs: {
+    display_name: string;
     name: string;
     name: string;
     image_repo_uri: string;
     image_repo_uri: string;
     image_tag: string;
     image_tag: string;
@@ -80,6 +81,7 @@ export type StackRevision = {
 
 
 export type SourceConfig = {
 export type SourceConfig = {
   id: string;
   id: string;
+  display_name: string;
   name: string;
   name: string;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
@@ -90,8 +92,6 @@ export type SourceConfig = {
   stack_id: string;
   stack_id: string;
   stack_revision_id: number;
   stack_revision_id: number;
 
 
-  stable_source_config_id: string;
-
   build?: {
   build?: {
     method: "pack" | "docker";
     method: "pack" | "docker";
     folder_path: string;
     folder_path: string;

+ 2 - 2
dashboard/src/main/home/integrations/create-integration/GARForm.tsx

@@ -4,7 +4,7 @@ import InputRow from "components/form-components/InputRow";
 import SelectRow from "components/form-components/SelectRow";
 import SelectRow from "components/form-components/SelectRow";
 import UploadArea from "components/form-components/UploadArea";
 import UploadArea from "components/form-components/UploadArea";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
-import { GCP_REGION_OPTIONS } from "main/home/onboarding/constants";
+import { GAR_REGION_OPTIONS } from "main/home/onboarding/constants";
 import React, { useContext, useState } from "react";
 import React, { useContext, useState } from "react";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -109,7 +109,7 @@ const GARForm = (props: { closeForm: () => void }) => {
         />
         />
         <Helper>GAR Region</Helper>
         <Helper>GAR Region</Helper>
         <SelectRow
         <SelectRow
-          options={GCP_REGION_OPTIONS}
+          options={GAR_REGION_OPTIONS}
           width="100%"
           width="100%"
           value={region}
           value={region}
           scrollBuffer={true}
           scrollBuffer={true}

+ 16 - 0
dashboard/src/main/home/onboarding/constants.ts

@@ -5,22 +5,38 @@ export const GCP_REGION_OPTIONS = [
   { value: "asia-northeast2", label: "asia-northeast2" },
   { value: "asia-northeast2", label: "asia-northeast2" },
   { value: "asia-northeast3", label: "asia-northeast3" },
   { value: "asia-northeast3", label: "asia-northeast3" },
   { value: "asia-south1", label: "asia-south1" },
   { value: "asia-south1", label: "asia-south1" },
+  { value: "asia-south2", label: "asia-south2" },
   { value: "asia-southeast1", label: "asia-southeast1" },
   { value: "asia-southeast1", label: "asia-southeast1" },
   { value: "asia-southeast2", label: "asia-southeast2" },
   { value: "asia-southeast2", label: "asia-southeast2" },
   { value: "australia-southeast1", label: "australia-southeast1" },
   { value: "australia-southeast1", label: "australia-southeast1" },
+  { value: "australia-southeast2", label: "australia-southeast2" },
+  { value: "europe-central2", label: "europe-central2" },
   { value: "europe-north1", label: "europe-north1" },
   { value: "europe-north1", label: "europe-north1" },
   { value: "europe-west1", label: "europe-west1" },
   { value: "europe-west1", label: "europe-west1" },
   { value: "europe-west2", label: "europe-west2" },
   { value: "europe-west2", label: "europe-west2" },
   { value: "europe-west3", label: "europe-west3" },
   { value: "europe-west3", label: "europe-west3" },
   { value: "europe-west4", label: "europe-west4" },
   { value: "europe-west4", label: "europe-west4" },
   { value: "europe-west6", label: "europe-west6" },
   { value: "europe-west6", label: "europe-west6" },
+  { value: "europe-west8", label: "europe-west8" },
+  { value: "europe-west9", label: "europe-west9" },
+  { value: "europe-southwest1", label: "europe-southwest1" },
   { value: "northamerica-northeast1", label: "northamerica-northeast1" },
   { value: "northamerica-northeast1", label: "northamerica-northeast1" },
+  { value: "northamerica-northeast2", label: "northamerica-northeast2" },
   { value: "southamerica-east1", label: "southamerica-east1" },
   { value: "southamerica-east1", label: "southamerica-east1" },
+  { value: "southamerica-west1", label: "southamerica-west1" },
   { value: "us-central1", label: "us-central1" },
   { value: "us-central1", label: "us-central1" },
   { value: "us-east1", label: "us-east1" },
   { value: "us-east1", label: "us-east1" },
   { value: "us-east4", label: "us-east4" },
   { value: "us-east4", label: "us-east4" },
+  { value: "us-east5", label: "us-east5" },
   { value: "us-west1", label: "us-west1" },
   { value: "us-west1", label: "us-west1" },
   { value: "us-west2", label: "us-west2" },
   { value: "us-west2", label: "us-west2" },
   { value: "us-west3", label: "us-west3" },
   { value: "us-west3", label: "us-west3" },
   { value: "us-west4", label: "us-west4" },
   { value: "us-west4", label: "us-west4" },
+  { value: "us-south1", label: "us-south1" },
 ];
 ];
+
+export const GAR_REGION_OPTIONS = GCP_REGION_OPTIONS.concat([
+  { value: "us", label: "us (multi-region)" },
+  { value: "europe", label: "europe (multi-region)" },
+  { value: "asia", label: "asia (multi-region)" },
+]);

+ 17 - 17
dashboard/src/shared/api.tsx

@@ -2003,6 +2003,22 @@ const createStack = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
 );
 );
 
 
+const updateStack = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
 const listStacks = baseApi<
 const listStacks = baseApi<
   {},
   {},
   { project_id: number; cluster_id: number; namespace: string }
   { project_id: number; cluster_id: number; namespace: string }
@@ -2145,22 +2161,6 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 );
 
 
-const updateStack = baseApi<
-  {
-    name: string;
-  },
-  {
-    project_id: number;
-    cluster_id: number;
-    namespace: string;
-    stack_id: string;
-  }
->(
-  "PATCH",
-  ({ project_id, cluster_id, namespace, stack_id }) =>
-    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
-);
-
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -2355,6 +2355,7 @@ export default {
   getStack,
   getStack,
   getStackRevision,
   getStackRevision,
   createStack,
   createStack,
+  updateStack,
   rollbackStack,
   rollbackStack,
   deleteStack,
   deleteStack,
   updateStackSourceConfig,
   updateStackSourceConfig,
@@ -2362,7 +2363,6 @@ export default {
   removeStackAppResource,
   removeStackAppResource,
   addStackEnvGroup,
   addStackEnvGroup,
   removeStackEnvGroup,
   removeStackEnvGroup,
-  updateStack,
 
 
   // STATUS
   // STATUS
   getGithubStatus,
   getGithubStatus,

+ 3 - 1
internal/kubernetes/prometheus/metrics.go

@@ -301,7 +301,7 @@ func getSelectionRegex(kind, name string) (string, error) {
 
 
 	switch strings.ToLower(kind) {
 	switch strings.ToLower(kind) {
 	case "deployment":
 	case "deployment":
-		suffix = "[a-z0-9]+-[a-z0-9]+"
+		suffix = "[a-z0-9]+"
 	case "statefulset":
 	case "statefulset":
 		suffix = "[0-9]+"
 		suffix = "[0-9]+"
 	case "job":
 	case "job":
@@ -310,6 +310,8 @@ func getSelectionRegex(kind, name string) (string, error) {
 		suffix = "[a-z0-9]+-[a-z0-9]+"
 		suffix = "[a-z0-9]+-[a-z0-9]+"
 	case "ingress":
 	case "ingress":
 		return name, nil
 		return name, nil
+	case "daemonset":
+		suffix = "[a-z0-9]+"
 	default:
 	default:
 		return "", fmt.Errorf("not a supported controller to query for metrics")
 		return "", fmt.Errorf("not a supported controller to query for metrics")
 	}
 	}

+ 11 - 13
internal/models/stack.go

@@ -160,14 +160,12 @@ func (s StackResource) ToStackResource(stackID string, stackRevisionID uint, sou
 type StackSourceConfig struct {
 type StackSourceConfig struct {
 	gorm.Model
 	gorm.Model
 
 
-	// A unique identifier for this source config, this will allow us identify a same source config
-	// across multiple revisions and updates. This is not the same as the UID or ID which are updated over revisions.
-	StableSourceConfigID string
-
 	StackRevisionID uint
 	StackRevisionID uint
 
 
 	Name string
 	Name string
 
 
+	DisplayName string
+
 	UID string
 	UID string
 
 
 	ImageRepoURI string
 	ImageRepoURI string
@@ -179,15 +177,15 @@ type StackSourceConfig struct {
 
 
 func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevisionID uint) *types.StackSourceConfig {
 func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevisionID uint) *types.StackSourceConfig {
 	return &types.StackSourceConfig{
 	return &types.StackSourceConfig{
-		CreatedAt:            s.CreatedAt,
-		UpdatedAt:            s.UpdatedAt,
-		StackID:              stackID,
-		StackRevisionID:      stackRevisionID,
-		Name:                 s.Name,
-		ID:                   s.UID,
-		ImageRepoURI:         s.ImageRepoURI,
-		ImageTag:             s.ImageTag,
-		StableSourceConfigID: s.StableSourceConfigID,
+		CreatedAt:       s.CreatedAt,
+		UpdatedAt:       s.UpdatedAt,
+		StackID:         stackID,
+		StackRevisionID: stackRevisionID,
+		Name:            s.Name,
+		ID:              s.UID,
+		ImageRepoURI:    s.ImageRepoURI,
+		ImageTag:        s.ImageTag,
+		DisplayName:     s.DisplayName,
 	}
 	}
 }
 }
 
 

+ 1 - 1
internal/repository/stack.go

@@ -9,8 +9,8 @@ type StackRepository interface {
 	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
 	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
 	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)
-
 	UpdateStack(stack *models.Stack) (*models.Stack, error)
 	UpdateStack(stack *models.Stack) (*models.Stack, error)
+
 	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
 	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
 	ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error)
 	ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error)
 	ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error)
 	ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error)

+ 2 - 1
internal/stacks/helpers.go

@@ -22,6 +22,7 @@ func CloneSourceConfigs(sourceConfigs []models.StackSourceConfig) ([]models.Stac
 		res = append(res, models.StackSourceConfig{
 		res = append(res, models.StackSourceConfig{
 			UID:          uid,
 			UID:          uid,
 			Name:         sourceConfig.Name,
 			Name:         sourceConfig.Name,
+			DisplayName:  sourceConfig.DisplayName,
 			ImageRepoURI: sourceConfig.ImageRepoURI,
 			ImageRepoURI: sourceConfig.ImageRepoURI,
 			ImageTag:     sourceConfig.ImageTag,
 			ImageTag:     sourceConfig.ImageTag,
 		})
 		})
@@ -52,7 +53,7 @@ func CloneAppResources(
 			if prevSourceConfig.UID == appResource.StackSourceConfigUID {
 			if prevSourceConfig.UID == appResource.StackSourceConfigUID {
 				// find the corresponding new source config
 				// find the corresponding new source config
 				for _, newSourceConfig := range newSourceConfigs {
 				for _, newSourceConfig := range newSourceConfigs {
-					if newSourceConfig.StableSourceConfigID == prevSourceConfig.StableSourceConfigID {
+					if newSourceConfig.Name == prevSourceConfig.Name {
 						linkedSourceConfigUID = newSourceConfig.UID
 						linkedSourceConfigUID = newSourceConfig.UID
 					}
 					}
 				}
 				}

Некоторые файлы не были показаны из-за большого количества измененных файлов