Pārlūkot izejas kodu

Merge pull request #2234 from porter-dev/nico/por-559-support-environment-group-creation-from

Support env groups for stacks feature
abelanger5 3 gadi atpakaļ
vecāks
revīzija
282c84b7e6
52 mainītis faili ar 2019 papildinājumiem un 512 dzēšanām
  1. 14 0
      api/server/handlers/namespace/create_env_group.go
  2. 22 1
      api/server/handlers/namespace/get_env_group.go
  3. 25 0
      api/server/handlers/release/get.go
  4. 121 43
      api/server/handlers/stack/create.go
  5. 13 0
      api/server/handlers/stack/delete.go
  6. 36 0
      api/server/handlers/stack/list_revisions.go
  7. 12 1
      api/server/handlers/stack/rollback.go
  8. 4 1
      api/server/handlers/stack/update_source_put.go
  9. 58 6
      api/server/router/v1/stack.go
  10. 5 0
      api/types/namespace.go
  11. 1 1
      api/types/release.go
  12. 54 1
      api/types/stacks.go
  13. 6 0
      dashboard/src/components/porter-form/PorterForm.tsx
  14. 4 1
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  15. 24 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  16. 11 0
      dashboard/src/components/porter-form/types.ts
  17. 1 0
      dashboard/src/main/home/Home.tsx
  18. 13 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  19. 83 38
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  20. 42 15
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  21. 62 85
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  22. 14 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  23. 3 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  24. 22 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  25. 74 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/useStackEnvGroups.ts
  26. 61 5
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  27. 150 63
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  28. 8 2
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  29. 3 2
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  30. 112 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  31. 5 10
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx
  32. 79 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx
  33. 37 6
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  34. 84 73
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  35. 156 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx
  36. 91 7
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  37. 3 11
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  38. 103 9
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  39. 0 54
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx
  40. 130 45
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  41. 29 14
      dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx
  42. 30 1
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  43. 8 0
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  44. 1 1
      dashboard/src/shared/types.tsx
  45. 0 1
      internal/models/release.go
  46. 39 0
      internal/models/stack.go
  47. 1 0
      internal/repository/gorm/migrate.go
  48. 15 3
      internal/repository/gorm/stack.go
  49. 2 0
      internal/repository/stack.go
  50. 4 0
      internal/repository/test/stack.go
  51. 23 0
      internal/stacks/helpers.go
  52. 121 0
      internal/stacks/hooks.go

+ 14 - 0
api/server/handlers/namespace/create_env_group.go

@@ -20,6 +20,7 @@ import (
 	"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 {
@@ -115,6 +116,13 @@ func (c *CreateEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		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(
@@ -367,3 +375,9 @@ func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]inte
 
 	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)
+}

+ 22 - 1
api/server/handlers/namespace/get_env_group.go

@@ -1,6 +1,7 @@
 package namespace
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -13,6 +14,8 @@ import (
 	"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"
+	"gorm.io/gorm"
 )
 
 type GetEnvGroupHandler struct {
@@ -62,5 +65,23 @@ func (c *GetEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	c.WriteResult(w, r, envGroup)
+	stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.WriteResult(w, r, &types.GetEnvGroupResponse{EnvGroup: envGroup})
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.GetEnvGroupResponse{
+		EnvGroup: envGroup,
+		StackID:  stackId,
+	}
+
+	c.WriteResult(w, r, res)
 }

+ 25 - 0
api/server/handlers/release/get.go

@@ -63,6 +63,31 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 			res.BuildConfig = bc.ToBuildConfigType()
 		}
+
+		if release.StackResourceID != 0 {
+			stackResource, err := c.Repo().Stack().ReadStackResource(release.StackResourceID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			stackRevision, err := c.Repo().Stack().ReadStackRevision(stackResource.StackRevisionID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			stack, err := c.Repo().Stack().ReadStackByID(cluster.ProjectID, stackRevision.StackID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			res.StackID = stack.UID
+		}
 	} else if err != gorm.ErrRecordNotFound {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return

+ 121 - 43
api/server/handlers/stack/create.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 
 	helmrelease "helm.sh/helm/v3/pkg/release"
@@ -73,6 +74,13 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	envGroups, err := getEnvGroupModels(req.EnvGroups, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// write stack to the database with creating status
 	stack := &models.Stack{
 		ProjectID: proj.ID,
@@ -86,6 +94,7 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				Status:         string(types.StackRevisionStatusDeploying),
 				SourceConfigs:  sourceConfigs,
 				Resources:      resources,
+				EnvGroups:      envGroups,
 			},
 		},
 	}
@@ -97,76 +106,98 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// apply all app resources
-	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	// apply all env groups
+	k8sAgent, err := p.GetAgent(r, cluster, "")
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+	envGroupDeployErrors := make([]string, 0)
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+	for _, envGroup := range req.EnvGroups {
+		cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+			Name:            envGroup.Name,
+			Namespace:       namespace,
+			Variables:       envGroup.Variables,
+			SecretVariables: envGroup.SecretVariables,
+		})
 
-	helmReleaseMap := make(map[string]*helmrelease.Release)
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", envGroup.Name))
+		}
 
-	deployErrs := make([]string, 0)
+		// add each of the linked applications to the env group
+		for _, appName := range envGroup.LinkedApplications {
 
-	for _, appResource := range req.AppResources {
-		rel, err := applyAppResource(&applyAppResourceOpts{
-			config:     p.Config(),
-			projectID:  proj.ID,
-			namespace:  namespace,
-			cluster:    cluster,
-			registries: registries,
-			helmAgent:  helmAgent,
-			request:    appResource,
-		})
+			cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
 
-		if err != nil {
-			deployErrs = append(deployErrs, err.Error())
-		} else {
-			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+			if err != nil {
+				envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", envGroup.Name))
+			}
 		}
 	}
 
-	// update stack revision status
 	revision := &stack.Revisions[0]
 
-	if len(deployErrs) > 0 {
+	if len(envGroupDeployErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
-		revision.Reason = "DeployError"
-		revision.Message = strings.Join(deployErrs, " , ")
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+
+		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	} else {
-		revision.Status = string(types.StackRevisionStatusDeployed)
-	}
+		// apply all app resources
+		registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 
-	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+		helmAgent, err := p.GetHelmAgent(r, cluster, "")
 
-	saveErrs := make([]string, 0)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		helmReleaseMap := make(map[string]*helmrelease.Release)
 
-	for _, resource := range revision.Resources {
-		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
-			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+		deployErrs := make([]string, 0)
+
+		for _, appResource := range req.AppResources {
+			rel, err := applyAppResource(&applyAppResourceOpts{
+				config:     p.Config(),
+				projectID:  proj.ID,
+				namespace:  namespace,
+				cluster:    cluster,
+				registries: registries,
+				helmAgent:  helmAgent,
+				request:    appResource,
+			})
 
 			if err != nil {
-				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+				deployErrs = append(deployErrs, err.Error())
+			} else {
+				helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
 			}
 		}
-	}
 
-	if len(saveErrs) > 0 {
-		revision.Reason = "SaveError"
-		revision.Message = strings.Join(saveErrs, " , ")
+		// update stack revision status
+		if len(deployErrs) > 0 {
+			revision.Status = string(types.StackRevisionStatusFailed)
+			revision.Reason = "DeployError"
+			revision.Message = strings.Join(deployErrs, " , ")
+		} else {
+			revision.Status = string(types.StackRevisionStatusDeployed)
+		}
 
 		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
 
@@ -174,6 +205,30 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
+
+		saveErrs := make([]string, 0)
+
+		for _, resource := range revision.Resources {
+			if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+				_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+				if err != nil {
+					saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+				}
+			}
+		}
+
+		if len(saveErrs) > 0 {
+			revision.Reason = "SaveError"
+			revision.Message = strings.Join(saveErrs, " , ")
+
+			revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+			if err != nil {
+				p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
 	}
 
 	// read the stack again to get the latest revision info
@@ -248,3 +303,26 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 
 	return res, nil
 }
+
+func getEnvGroupModels(envGroups []*types.CreateStackEnvGroupRequest, projID, clusterID uint, namespace string) ([]models.StackEnvGroup, error) {
+	res := make([]models.StackEnvGroup, 0)
+
+	for _, envGroup := range envGroups {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackEnvGroup{
+			Name:            envGroup.Name,
+			UID:             uid,
+			EnvGroupVersion: 1,
+			ProjectID:       projID,
+			ClusterID:       clusterID,
+			Namespace:       namespace,
+		})
+	}
+
+	return res, nil
+}

+ 13 - 0
api/server/handlers/stack/delete.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -40,6 +41,13 @@ func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
+		k8sAgent, err := p.GetAgent(r, cluster, "")
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
 		helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
 
 		if err != nil {
@@ -54,6 +62,11 @@ func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				name:      appResource.Name,
 			})
 		}
+
+		// delete all env groups in stack
+		for _, envGroup := range revision.EnvGroups {
+			envgroup.DeleteEnvGroup(k8sAgent, envGroup.Name, envGroup.Namespace)
+		}
 	}
 
 	stack, err := p.Repo().Stack().DeleteStack(stack)

+ 36 - 0
api/server/handlers/stack/list_revisions.go

@@ -0,0 +1,36 @@
+package stack
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackListRevisionsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackListRevisionsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackListRevisionsHandler {
+	return &StackListRevisionsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackListRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	res := make([]types.StackRevision, 0)
+
+	for _, stackRev := range stack.Revisions {
+		res = append(res, *stackRev.ToStackRevisionType(stack.UID))
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 12 - 1
api/server/handlers/stack/rollback.go

@@ -1,6 +1,7 @@
 package stack
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -86,8 +87,16 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	revision.SourceConfigs = newSourceConfigs
 	revision.Resources = appResources
+	revision.EnvGroups = envGroups
 
 	revision, err = p.Repo().Stack().AppendNewRevision(revision)
 
@@ -114,9 +123,11 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	if len(rollbackErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "RollbackError"
-		revision.Message = strings.Join(rollbackErrors, " , ")
+		revision.Message = fmt.Sprintf("Error while rolling back to version %d: %s", req.TargetRevision, strings.Join(rollbackErrors, " , "))
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "Rollback"
+		revision.Message = fmt.Sprintf("The stack was rolled back to version %d", req.TargetRevision)
 	}
 
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

+ 4 - 1
api/server/handlers/stack/update_source_put.go

@@ -1,6 +1,7 @@
 package stack
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -130,9 +131,11 @@ func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	if len(deployErrs) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "DeployError"
-		revision.Message = strings.Join(deployErrs, " , ")
+		revision.Message = fmt.Sprintf("Error while updating source configuration: %s", strings.Join(deployErrs, " , "))
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "SourceConfigUpdate"
+		revision.Message = fmt.Sprintf("The source configuration was updated")
 	}
 
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

+ 58 - 6
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -58,11 +58,11 @@ type stackRevisionPathParams struct {
 	// required: true
 	StackID string `json:"stack_id"`
 
-	// The stack revision number
+	// The stack revision id
 	// in: path
 	// required: true
 	// minimum: 1
-	StackRevisionNumber string `json:"stack_revision_number"`
+	RevisionID string `json:"revision_id"`
 }
 
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
@@ -267,8 +267,60 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
-	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} -> stack.NewStackGetRevisionHandler
-	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} getStackRevision
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/revisions -> stack.NewStackListRevisionsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/revisions listStackRevisions
+	//
+	// Lists revisions in a stack. A max of 100 revisions will be returned, sorted from most recent to least recent.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List stack revisions
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed stack revisions
+	//     schema:
+	//       $ref: '#/definitions/ListStackRevisionsResponse'
+	//   '403':
+	//     description: Forbidden
+	listRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/revisions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	listRevisionsHandler := stack.NewStackListRevisionsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listRevisionsEndpoint,
+		Handler:  listRevisionsHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{revision_id} -> stack.NewStackGetRevisionHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{revision_id} getStackRevision
 	//
 	// Gets a stack revision
 	//
@@ -283,7 +335,7 @@ func getV1StackRoutes(
 	//   - name: cluster_id
 	//   - name: namespace
 	//   - name: stack_id
-	//   - name: stack_revision_number
+	//   - name: revision_id
 	// responses:
 	//   '200':
 	//     description: Successfully got the stack revision

+ 5 - 0
api/types/namespace.go

@@ -210,3 +210,8 @@ type GetJobRunsRequest struct {
 type StreamJobRunsRequest struct {
 	Name string `schema:"name"`
 }
+
+type GetEnvGroupResponse struct {
+	*EnvGroup
+	StackID string `json:"stack_id,omitempty"`
+}

+ 1 - 1
api/types/release.go

@@ -37,7 +37,7 @@ type PorterRelease struct {
 	Tags []string `json:"tags,omitempty"`
 
 	// Whether this release is tied to a stack or not
-	IsStack bool `json:"is_stack"`
+	StackID string `json:"stack_id"`
 }
 
 // swagger:model

+ 54 - 1
api/types/stacks.go

@@ -17,6 +17,9 @@ type CreateStackRequest struct {
 	// registry or linked to a remote Git repository.
 	// required: true
 	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+
+	// A list of env groups which can be synced to an application
+	EnvGroups []*CreateStackEnvGroupRequest `json:"env_groups,omitempty" form:"required,dive,required"`
 }
 
 // swagger:model
@@ -84,6 +87,9 @@ type Stack struct {
 	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
 }
 
+// swagger:model
+type ListStackRevisionsResponse []StackRevision
+
 // swagger:model
 type StackListResponse []Stack
 
@@ -109,7 +115,7 @@ type StackResource struct {
 	// If this is an app resource, app-specific information for the resource
 	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
 
-	// The source configuration for this stack
+	// The source configuration that this resource uses, if this is an application resource
 	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
 }
 
@@ -158,7 +164,34 @@ type StackRevision struct {
 	// The list of resources deployed in this revision
 	Resources []StackResource `json:"resources"`
 
+	// The list of source configs deployed in this revision
 	SourceConfigs []StackSourceConfig `json:"source_configs"`
+
+	// The list of env groups scoped to this stack
+	EnvGroups []StackEnvGroup `json:"env_groups"`
+}
+
+type StackEnvGroup struct {
+	// The time that this resource was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that this resource was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this resource belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this resource belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The name of the resource
+	Name string `json:"name"`
+
+	// The id for this resource
+	ID string `json:"id"`
+
+	// The version of the env group which is being used
+	EnvGroupVersion uint `json:"env_group_version"`
 }
 
 type StackSourceConfig struct {
@@ -190,6 +223,26 @@ type StackSourceConfig struct {
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 }
 
+// swagger:model
+type CreateStackEnvGroupRequest struct {
+	// The name of the env group
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// The non-secret variables to set in the env group
+	// required: true
+	Variables map[string]string `json:"variables,required" form:"required"`
+
+	// The secret variables to set in the env group
+	// required: true
+	SecretVariables map[string]string `json:"secret_variables,required" form:"required"`
+
+	// The list of applications that this env group should be synced to. These applications **must** be present
+	// in the stack - if an env group is created from a stack, syncing to applications which are not in the stack
+	// is not supported
+	LinkedApplications []string `json:"linked_applications"`
+}
+
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 	// required: true

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

@@ -4,6 +4,7 @@ import {
   CheckboxField,
   CronField,
   FormField,
+  InjectedProps,
   InputField,
   KeyValueArrayField,
   ResourceListField,
@@ -49,6 +50,7 @@ interface Props {
   hideSpacer?: boolean;
   // The tab to redirect to after saving the form
   redirectTabAfterSave?: string;
+  injectedProps?: InjectedProps;
 }
 
 const PorterForm: React.FC<Props> = (props) => {
@@ -63,10 +65,14 @@ const PorterForm: React.FC<Props> = (props) => {
   const { currentTab, setCurrentTab } = props;
 
   const renderSectionField = (field: FormField): JSX.Element => {
+    const injected = props.injectedProps?.[field.type];
+
     const bundledProps = {
       ...field,
       isReadOnly,
+      injectedProps: injected ?? {},
     };
+
     switch (field.type) {
       case "heading":
         return <Heading>{field.label}</Heading>;

+ 4 - 1
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -1,7 +1,7 @@
 import React, { useState } from "react";
 
 import PorterForm from "./PorterForm";
-import { PorterFormData } from "./types";
+import { InjectedProps, PorterFormData } from "./types";
 import { PorterFormContextProvider } from "./PorterFormContextProvider";
 
 type PropsType = {
@@ -23,6 +23,7 @@ type PropsType = {
   hideBottomSpacer?: boolean;
   redirectTabAfterSave?: string;
   includeMetadata?: boolean;
+  injectedProps?: InjectedProps;
 };
 
 const PorterFormWrapper: React.FC<PropsType> = ({
@@ -44,6 +45,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
   hideBottomSpacer,
   redirectTabAfterSave,
   includeMetadata,
+  injectedProps,
 }) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
@@ -99,6 +101,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}
           redirectTabAfterSave={redirectTabAfterSave}
+          injectedProps={injectedProps}
         />
       </PorterFormContextProvider>
     </React.Fragment>

+ 24 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -4,6 +4,7 @@ import {
   GetMetadataFunction,
   KeyValueArrayField,
   KeyValueArrayFieldState,
+  PartialEnvGroup,
   PopulatedEnvGroup,
 } from "../types";
 import sliders from "../../../assets/sliders.svg";
@@ -63,7 +64,27 @@ const KeyValueArray: React.FC<Props> = (props) => {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
       const values = props.value[0];
       // console.log(values);
-      const envGroups = values?.synced || [];
+      const envGroups: PartialEnvGroup[] = values?.synced || [];
+
+      if (Array.isArray(props.injectedProps?.availableSyncEnvGroups)) {
+        const availableEnvGroups = props.injectedProps.availableSyncEnvGroups;
+
+        const populatedEnvGroups = envGroups
+          .map((envGroup) => {
+            return availableEnvGroups.find(
+              (availableEnvGroup) => availableEnvGroup.name === envGroup.name
+            );
+          })
+          .filter(Boolean);
+
+        setState(() => ({
+          synced_env_groups: Array.isArray(populatedEnvGroups)
+            ? populatedEnvGroups
+            : [],
+        }));
+        return;
+      }
+
       const promises = Promise.all(
         envGroups.map(async (envGroup: any) => {
           const res = await api.getEnvGroup(
@@ -90,6 +111,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
       });
     }
   }, [
+    props.injectedProps,
     props.value[0],
     variables?.clusterId,
     variables?.namespace,
@@ -180,6 +202,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
             existingValues={getProcessedValues(state.values)}
             enableSyncedEnvGroups={enableSyncedEnvGroups}
             syncedEnvGroups={state.synced_env_groups}
+            availableEnvGroups={props.injectedProps?.availableSyncEnvGroups}
             namespace={variables.namespace}
             clusterId={variables.clusterId}
             closeModal={() =>

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

@@ -9,6 +9,7 @@ import { ContextProps } from "../../shared/types";
 
 export interface GenericField {
   id: string;
+  injectedProps: unknown;
 }
 
 export interface GenericInputField extends GenericField {
@@ -88,6 +89,9 @@ export interface KeyValueArrayField extends GenericInputField {
     };
     type: "env" | "normal";
   };
+  injectedProps: {
+    availableSyncEnvGroups: PopulatedEnvGroup[];
+  };
 }
 
 export interface ArrayInputField extends GenericInputField {
@@ -218,6 +222,7 @@ export type PopulatedEnvGroup = {
   };
   applications: any[];
   meta_version: number;
+  stack_id?: string;
 };
 export interface KeyValueArrayFieldState {
   values: {
@@ -308,3 +313,9 @@ export type GetMetadataFunction<T = unknown> = (
   state: PorterFormFieldFieldState,
   context: Partial<ContextProps>
 ) => T;
+
+export type InjectedProps = Partial<
+  {
+    [K in FormField["type"]]: Extract<FormField, { type: K }>["injectedProps"];
+  }
+>;

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -541,6 +541,7 @@ const ViewWrapper = styled.div`
 const DashboardWrapper = styled.div`
   width: calc(85%);
   min-width: 300px;
+  height: fit-content;
 `;
 
 const StyledHome = styled.div`

+ 13 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -13,7 +13,7 @@ import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
-import { pushQueryParams } from "shared/routing";
+import { getQueryParam, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = RouteComponentProps &
@@ -79,6 +79,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
                 namespace={this.state.namespace}
               />
               <SortSelector
+                currentView="env-groups"
                 setSortType={(sortType) => this.setState({ sortType })}
                 sortType={this.state.sortType}
               />
@@ -98,6 +99,16 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
     }
   };
 
+  closeExpanded = () => {
+    pushQueryParams(this.props, {}, ["selected_env_group"]);
+    const redirectUrlOnClose = getQueryParam(this.props, "redirect_url");
+    if (redirectUrlOnClose) {
+      this.props.history.push(redirectUrlOnClose);
+      return;
+    }
+    this.setState({ expandedEnvGroup: null });
+  };
+
   renderContents = () => {
     if (this.state.expandedEnvGroup) {
       return (
@@ -108,7 +119,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
           }
           currentCluster={this.props.currentCluster}
           envGroup={this.state.expandedEnvGroup}
-          closeExpanded={() => this.setState({ expandedEnvGroup: null })}
+          closeExpanded={() => this.closeExpanded()}
         />
       );
     } else {

+ 83 - 38
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -7,8 +7,10 @@ import { ClusterType } from "shared/types";
 
 import EnvGroup from "./EnvGroup";
 import Loading from "components/Loading";
+import { getQueryParam, pushQueryParams } from "shared/routing";
+import { RouteComponentProps, withRouter } from "react-router";
 
-type PropsType = {
+type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   namespace: string;
   sortType: string;
@@ -27,51 +29,68 @@ const dummyEnvGroups = [
   { name: "backend-production", last_updated: "7", namespace: "default" },
 ];
 
-export default class EnvGroupList extends Component<PropsType, StateType> {
+class EnvGroupList extends Component<PropsType, StateType> {
   state = {
     envGroups: [] as any[],
     loading: false,
     error: false,
   };
 
-  updateEnvGroups = () => {
-    api
-      .listEnvGroups(
-        "<token>",
-        {},
-        {
-          id: this.context.currentProject.id,
-          namespace: this.props.namespace,
-          cluster_id: this.props.currentCluster.id,
-        }
-      )
-      .then((res) => {
-        let sortedGroups = res?.data;
-        switch (this.props.sortType) {
-          case "Oldest":
-            sortedGroups.sort((a: any, b: any) =>
-              Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
-            );
-            break;
-          case "Alphabetical":
-            sortedGroups.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-            break;
-          default:
-            sortedGroups.sort((a: any, b: any) =>
-              Date.parse(a.created_at) > Date.parse(b.created_at) ? -1 : 1
-            );
-        }
-        this.setState({ envGroups: sortedGroups, loading: false });
-      })
-      .catch((err) => {
-        console.log(err);
-        this.setState({ loading: false, error: true });
-      });
+  updateEnvGroups = async () => {
+    const { currentCluster, namespace, sortType } = this.props;
+    try {
+      const envGroups = await api
+        .listEnvGroups(
+          "<token>",
+          {},
+          {
+            id: this.context.currentProject.id,
+            namespace: this.props.namespace,
+            cluster_id: this.props.currentCluster.id,
+          }
+        )
+        .then((res) => res.data);
+
+      let sortedGroups = envGroups;
+      switch (this.props.sortType) {
+        case "Oldest":
+          sortedGroups.sort((a: any, b: any) =>
+            Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
+          );
+          break;
+        case "Alphabetical":
+          sortedGroups.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+          break;
+        default:
+          sortedGroups.sort((a: any, b: any) =>
+            Date.parse(a.created_at) > Date.parse(b.created_at) ? -1 : 1
+          );
+      }
+
+      return sortedGroups;
+    } catch (error) {
+      console.log(error);
+      this.setState({ loading: false, error: true });
+    }
   };
 
   componentDidMount() {
     this.setState({ loading: true });
-    this.updateEnvGroups();
+    this.updateEnvGroups().then((envGroups) => {
+      const selectedEnvGroup = getQueryParam(this.props, "selected_env_group");
+
+      if (selectedEnvGroup) {
+        // find env group by selectedEnvGroup
+        const envGroup = envGroups.find(
+          (envGroup: any) => envGroup.name === selectedEnvGroup
+        );
+        if (envGroup) {
+          this.props.setExpandedEnvGroup(envGroup);
+          return;
+        }
+      }
+      this.setState({ envGroups, loading: false });
+    });
   }
 
   componentDidUpdate(prevProps: PropsType) {
@@ -82,10 +101,34 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
       prevProps.sortType !== this.props.sortType
     ) {
       (this.props.namespace || this.props.namespace === "") &&
-        this.updateEnvGroups();
+        this.updateEnvGroups().then((envGroups) => {
+          const selectedEnvGroup = getQueryParam(
+            this.props,
+            "selected_env_group"
+          );
+
+          this.setState({ envGroups, loading: false });
+
+          if (selectedEnvGroup) {
+            // find env group by selectedEnvGroup
+            const envGroup = envGroups.find(
+              (envGroup: any) => envGroup.name === selectedEnvGroup
+            );
+            if (envGroup) {
+              this.props.setExpandedEnvGroup(envGroup);
+            } else {
+              pushQueryParams(this.props, {}, ["selected_env_group"]);
+            }
+          }
+        });
     }
   }
 
+  handleExpand = (envGroup: any) => {
+    pushQueryParams(this.props, { selected_env_group: envGroup.name }, []);
+    this.props.setExpandedEnvGroup(envGroup);
+  };
+
   renderEnvGroupList = () => {
     let { loading, error, envGroups } = this.state;
 
@@ -115,7 +158,7 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
         <EnvGroup
           key={i}
           envGroup={envGroup}
-          setExpanded={() => this.props.setExpandedEnvGroup(envGroup)}
+          setExpanded={() => this.handleExpand(envGroup)}
         />
       );
     });
@@ -128,6 +171,8 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
 
 EnvGroupList.contextType = Context;
 
+export default withRouter(EnvGroupList);
+
 const Placeholder = styled.div`
   width: 100%;
   display: flex;

+ 42 - 15
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -546,23 +546,39 @@ const EnvGroupSettings = ({
           </Helper>
           {!canDelete && (
             <Helper color="#f5cb42">
-              Looks like you still have applications syncedto this env group.
-              Please remove this env group from those applications to delete
+              Applications are still synced to this env group. Navigate to
+              "Linked Applications" and remove this env group from all
+              applications to delete.
             </Helper>
           )}
-          <Button
-            color="#b91133"
-            onClick={() => {
-              setCurrentOverlay({
-                message: `Are you sure you want to delete ${name}?`,
-                onYes: handleDeleteEnvGroup,
-                onNo: () => setCurrentOverlay(null),
-              });
-            }}
-            disabled={!canDelete}
-          >
-            Delete {envGroup.name}
-          </Button>
+          {envGroup.stack_id?.length ? (
+            <>
+              <Helper color="#f5cb42">
+                You have to delete the stack to remove this env group.
+              </Helper>
+              <CloneButton
+                as={DynamicLink}
+                color="#5561C0"
+                to={`/stacks/${envGroup.namespace}/${envGroup.stack_id}`}
+              >
+                Go to the stack
+              </CloneButton>
+            </>
+          ) : (
+            <Button
+              color="#b91133"
+              onClick={() => {
+                setCurrentOverlay({
+                  message: `Are you sure you want to delete ${envGroup.name}?`,
+                  onYes: handleDeleteEnvGroup,
+                  onNo: () => setCurrentOverlay(null),
+                });
+              }}
+              disabled={!canDelete}
+            >
+              Delete {envGroup.name}
+            </Button>
+          )}
         </InnerWrapper>
       )}
     </TabWrapper>
@@ -705,6 +721,17 @@ const Button = styled.button`
   }
 `;
 
+const CloneButton = styled(Button)`
+  display: flex;
+  width: fit-content;
+  align-items: center;
+  justify-content: center;
+  background-color: #ffffff11;
+  :hover {
+    background-color: #ffffff18;
+  }
+`;
+
 const InnerWrapper = styled.div<{ full?: boolean }>`
   width: 100%;
   height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")};

+ 62 - 85
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -25,6 +25,7 @@ import DeploymentType from "./DeploymentType";
 import IncidentsTab from "./incidents/IncidentsTab";
 import BuildSettingsTab from "./BuildSettingsTab";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
+import { useStackEnvGroups } from "./useStackEnvGroups";
 
 type Props = {
   namespace: string;
@@ -74,6 +75,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [isAuthorized] = useAuth();
   const [fullScreenLogs, setFullScreenLogs] = useState<boolean>(false);
 
+  const {
+    isStack,
+    stackEnvGroups,
+    isLoadingStackEnvGroups,
+  } = useStackEnvGroups(currentChart);
+
   const {
     newWebsocket,
     openWebsocket,
@@ -528,7 +535,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    if (currentChart?.git_action_config?.git_repo && !currentChart.is_stack) {
+    if (currentChart?.git_action_config?.git_repo && !isStack) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",
@@ -565,59 +572,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
     setDevOpsMode(!devOpsMode);
   };
 
-  const renderIcon = () => {
-    if (
-      currentChart.chart.metadata.icon &&
-      currentChart.chart.metadata.icon !== ""
-    ) {
-      return <Icon src={currentChart.chart.metadata.icon} />;
-    } else {
-      return <i className="material-icons">tonality</i>;
-    }
-  };
-
-  // const chartStatus = useMemo(() => {
-  //   const getAvailability = (kind: string, c: any) => {
-  //     switch (kind?.toLowerCase()) {
-  //       case "deployment":
-  //       case "replicaset":
-  //         return c.status.availableReplicas == c.status.replicas;
-  //       case "statefulset":
-  //         return c.status.readyReplicas == c.status.replicas;
-  //       case "daemonset":
-  //         return c.status.numberAvailable == c.status.desiredNumberScheduled;
-  //     }
-  //   };
-
-  //   const chartStatus = currentChart.info.status;
-
-  //   if (chartStatus === "deployed") {
-  //     for (var uid in controllers) {
-  //       let value = controllers[uid];
-  //       let available = getAvailability(value.metadata.kind, value);
-  //       let progressing = true;
-
-  //       controllers[uid]?.status?.conditions?.forEach((condition: any) => {
-  //         if (
-  //           condition.type == "Progressing" &&
-  //           condition.status == "False" &&
-  //           condition.reason == "ProgressDeadlineExceeded"
-  //         ) {
-  //           progressing = false;
-  //         }
-  //       });
-
-  //       if (!available && progressing) {
-  //         return "loading";
-  //       } else if (!available && !progressing) {
-  //         return "failed";
-  //       }
-  //     }
-  //     return "deployed";
-  //   }
-  //   return chartStatus;
-  // }, [currentChart, controllers]);
-
   const renderUrl = () => {
     if (url) {
       return (
@@ -840,37 +794,60 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 latestVersion={currentChart.latest_version}
                 upgradeVersion={handleUpgradeVersion}
               />
-              {(isPreview || leftTabOptions.length > 0) && (
-                <BodyWrapper>
-                  <PorterFormWrapper
-                    formData={cloneDeep(currentChart.form)}
-                    valuesToOverride={{
-                      namespace: props.namespace,
-                      clusterId: currentCluster.id,
-                    }}
-                    renderTabContents={renderTabContents}
-                    isReadOnly={
-                      isPreview ||
-                      imageIsPlaceholder ||
-                      !isAuthorized("application", "", ["get", "update"])
-                    }
-                    onSubmit={onSubmit}
-                    includeMetadata
-                    rightTabOptions={rightTabOptions}
-                    leftTabOptions={leftTabOptions}
-                    color={isPreview ? "#f5cb42" : null}
-                    addendum={
-                      <TabButton
-                        onClick={toggleDevOpsMode}
-                        devOpsMode={devOpsMode}
-                      >
-                        <i className="material-icons">offline_bolt</i> DevOps
-                        Mode
-                      </TabButton>
-                    }
-                    saveValuesStatus={saveValuesStatus}
-                  />
-                </BodyWrapper>
+              {isStack && isLoadingStackEnvGroups ? (
+                <>
+                  <LineBreak />
+                  <Placeholder>
+                    <TextWrap>
+                      <Header>
+                        <Spinner src={loadingSrc} />
+                      </Header>
+                    </TextWrap>
+                  </Placeholder>
+                </>
+              ) : (
+                <>
+                  {(isPreview || leftTabOptions.length > 0) && (
+                    <BodyWrapper>
+                      <PorterFormWrapper
+                        formData={cloneDeep(currentChart.form)}
+                        valuesToOverride={{
+                          namespace: props.namespace,
+                          clusterId: currentCluster.id,
+                        }}
+                        renderTabContents={renderTabContents}
+                        isReadOnly={
+                          isPreview ||
+                          imageIsPlaceholder ||
+                          !isAuthorized("application", "", ["get", "update"])
+                        }
+                        onSubmit={onSubmit}
+                        includeMetadata
+                        rightTabOptions={rightTabOptions}
+                        leftTabOptions={leftTabOptions}
+                        color={isPreview ? "#f5cb42" : null}
+                        addendum={
+                          <TabButton
+                            onClick={toggleDevOpsMode}
+                            devOpsMode={devOpsMode}
+                          >
+                            <i className="material-icons">offline_bolt</i>{" "}
+                            DevOps Mode
+                          </TabButton>
+                        }
+                        saveValuesStatus={saveValuesStatus}
+                        injectedProps={{
+                          "key-value-array": {
+                            availableSyncEnvGroups:
+                              isStack && !isPreview
+                                ? stackEnvGroups
+                                : undefined,
+                          },
+                        }}
+                      />
+                    </BodyWrapper>
+                  )}
+                </>
               )}
             </>
           )}

+ 14 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -28,6 +28,7 @@ import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
 import BuildSettingsTab from "./BuildSettingsTab";
+import { useStackEnvGroups } from "./useStackEnvGroups";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -69,6 +70,12 @@ export const ExpandedJobChartFC: React.FC<{
     setSelectedJob,
   } = useJobs(chart);
 
+  const {
+    isStack,
+    stackEnvGroups,
+    isLoadingStackEnvGroups,
+  } = useStackEnvGroups(chart);
+
   const [devOpsMode, setDevOpsMode] = useState(
     () => localStorage.getItem("devOpsMode") === "true"
   );
@@ -283,7 +290,7 @@ export const ExpandedJobChartFC: React.FC<{
 
   const formData = useMemo(() => cloneDeep(chart?.form || {}), [chart]);
 
-  if (status === "loading") {
+  if (status === "loading" || isLoadingStackEnvGroups) {
     return <Loading />;
   }
 
@@ -378,6 +385,12 @@ export const ExpandedJobChartFC: React.FC<{
                   <i className="material-icons">offline_bolt</i> DevOps Mode
                 </TabButton>
               }
+              injectedProps={{
+                "key-value-array": {
+                  availableSyncEnvGroups:
+                    isStack && !disableForm ? stackEnvGroups : undefined,
+                },
+              }}
             />
           )}
         </BodyWrapper>

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -202,6 +202,8 @@ class RevisionSection extends Component<PropsType, StateType> {
         ? String(imageTag).slice(0, 7)
         : imageTag;
 
+      const isStack = !!this.props.chart.stack_id;
+
       return (
         <Tr
           key={i}
@@ -236,7 +238,7 @@ class RevisionSection extends Component<PropsType, StateType> {
                   "get",
                   "update",
                 ]) ||
-                this.props.chart.is_stack
+                isStack
               }
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })

+ 22 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -19,6 +19,7 @@ import { Link } from "react-router-dom";
 import { isDeployedFromGithub } from "shared/release/utils";
 import TagSelector from "./TagSelector";
 import { PORTER_IMAGE_TEMPLATES } from "shared/common";
+import DynamicLink from "components/DynamicLink";
 
 type PropsType = {
   currentChart: ChartType;
@@ -206,10 +207,10 @@ const SettingsSection: React.FC<PropsType> = ({
     if (!isAuthorizedToCreateWebhook) {
       buttonStatus = "Unauthorized to create webhook token";
     }
-    console.log(PORTER_IMAGE_TEMPLATES.includes(selectedImageUrl));
+
     return (
       <>
-        {!currentChart.is_stack &&
+        {!currentChart.stack_id?.length &&
         !PORTER_IMAGE_TEMPLATES.includes(selectedImageUrl) ? (
           <>
             <Heading>Source Settings</Heading>
@@ -316,10 +317,24 @@ const SettingsSection: React.FC<PropsType> = ({
           )}
 
           <Heading>Additional Settings</Heading>
-
-          <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
-            Delete {currentChart.name}
-          </Button>
+          {currentChart.stack_id?.length ? (
+            <>
+              <Helper>
+                You have to delete the stack to remove this application.
+              </Helper>
+              <CloneButton
+                as={DynamicLink}
+                color="#5561C0"
+                to={`/stacks/${currentChart.namespace}/${currentChart.stack_id}`}
+              >
+                Go to the stack
+              </CloneButton>
+            </>
+          ) : (
+            <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
+              Delete {currentChart.name}
+            </Button>
+          )}
         </StyledSettingsSection>
       ) : (
         <Loading />
@@ -368,7 +383,7 @@ const Button = styled.button`
 
 const CloneButton = styled(Button)`
   display: flex;
-  width: min-content;
+  width: fit-content;
   align-items: center;
   justify-content: center;
   background-color: #ffffff11;

+ 74 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/useStackEnvGroups.ts

@@ -0,0 +1,74 @@
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import { Stack } from "../stacks/types";
+
+export const useStackEnvGroups = (chart: ChartType) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [stackEnvGroups, setStackEnvGroups] = useState([]);
+  const [loading, setLoading] = useState(true);
+
+  const getEnvGroups = async (stack: Stack) => {
+    const envGroups = stack.latest_revision.env_groups;
+
+    const envGroupsWithValues = envGroups.map((envGroup) => {
+      return api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: chart.namespace,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            version: envGroup.env_group_version,
+          }
+        )
+        .then((res) => res.data);
+    });
+
+    return Promise.all(envGroupsWithValues);
+  };
+
+  const getStack = (stack_id: string) =>
+    api
+      .getStack<Stack>(
+        "token",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          stack_id,
+          namespace: chart.namespace,
+        }
+      )
+      .then((res) => res.data);
+
+  useEffect(() => {
+    const stack_id = chart.stack_id;
+    if (!stack_id) {
+      return;
+    }
+    setLoading(true);
+    getStack(stack_id)
+      .then((stack) => getEnvGroups(stack))
+      .then((populatedEnvGroups) => {
+        setStackEnvGroups(populatedEnvGroups);
+
+        setLoading(false);
+      })
+      .catch((error) => {
+        setCurrentError(error);
+      });
+  }, [chart.stack_id]);
+
+  return {
+    isStack: chart.stack_id?.length ? true : false,
+    stackEnvGroups,
+    isLoadingStackEnvGroups: loading,
+  };
+};

+ 61 - 5
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -1,13 +1,18 @@
 import DynamicLink from "components/DynamicLink";
+import Selector from "components/Selector";
 import React, { useEffect, useState } from "react";
 import { useHistory, useLocation } from "react-router";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
 import DashboardHeader from "../DashboardHeader";
 import NamespaceSelector from "../NamespaceSelector";
+import SortSelector from "../SortSelector";
 import StackList from "./_StackList";
 const Dashboard = () => {
   const [currentNamespace, setCurrentNamespace] = useState("default");
+  const [currentSort, setCurrentSort] = useState<
+    "created_at" | "updated_at" | "alphabetical"
+  >("created_at");
 
   const location = useLocation();
   const history = useHistory();
@@ -38,18 +43,65 @@ const Dashboard = () => {
           <i className="material-icons">add</i>
           Create Stack
         </Button>
-        <NamespaceSelector
-          namespace={currentNamespace}
-          setNamespace={handleNamespaceChange}
-        />
+        <FilterWrapper>
+          <StyledSortSelector>
+            <Label>
+              <i className="material-icons">sort</i> Sort
+            </Label>
+            <Selector
+              activeValue={currentSort}
+              setActiveValue={(sortType) => setCurrentSort(sortType as any)}
+              options={[
+                {
+                  value: "created_at",
+                  label: "Created At",
+                },
+                {
+                  value: "updated_at",
+                  label: "Last Updated",
+                },
+                {
+                  value: "alphabetical",
+                  label: "Alphabetical",
+                },
+              ]}
+              dropdownLabel="Sort By"
+              width="150px"
+              dropdownWidth="230px"
+              closeOverlay={true}
+            />
+          </StyledSortSelector>
+          <NamespaceSelector
+            namespace={currentNamespace}
+            setNamespace={handleNamespaceChange}
+          />
+        </FilterWrapper>
       </ActionRow>
-      <StackList namespace={currentNamespace} />
+      <StackList namespace={currentNamespace} sortBy={currentSort} />
     </>
   );
 };
 
 export default Dashboard;
 
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledSortSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  margin-right: 30px;
+`;
+
 const Button = styled(DynamicLink)`
   display: flex;
   flex-direction: row;
@@ -101,3 +153,7 @@ const ActionRow = styled.div`
   align-items: center;
   margin-bottom: 35px;
 `;
+
+const FilterWrapper = styled.div`
+  display: flex;
+`;

+ 150 - 63
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -3,6 +3,7 @@ import Placeholder from "components/Placeholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
 import React, { useContext, useEffect, useState } from "react";
+import backArrow from "assets/back_arrow.png";
 import { useParams } from "react-router";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -23,8 +24,11 @@ import {
 } from "../components/styles";
 import { getStackStatus, getStackStatusMessage } from "../shared";
 import { FullStackRevision, Stack, StackRevision } from "../types";
+import EnvGroups from "./components/EnvGroups";
 import RevisionList from "./_RevisionList";
 import SourceConfig from "./_SourceConfig";
+import { NavLink } from "react-router-dom";
+import Settings from "./components/Settings";
 
 const ExpandedStack = () => {
   const { namespace, stack_id } = useParams<{
@@ -39,8 +43,8 @@ const ExpandedStack = () => {
   );
 
   const [stack, setStack] = useState<Stack>();
-  const [sortType, setSortType] = useState("Alphabetical");
   const [isLoading, setIsLoading] = useState(true);
+  const [isDeleting, setIsDeleting] = useState(false);
   const [currentTab, setCurrentTab] = useState("apps");
 
   const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
@@ -70,6 +74,28 @@ const ExpandedStack = () => {
     }
   };
 
+  const handleDelete = () => {
+    setIsDeleting(true);
+    api
+      .deleteStack(
+        "<token>",
+        {},
+        {
+          namespace,
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack.id,
+        }
+      )
+      .then(() => {
+        pushFiltered("/stacks", []);
+      })
+      .catch((err) => {
+        setCurrentError(err);
+        setIsDeleting(false);
+      });
+  };
+
   useEffect(() => {
     getStack();
   }, [stack_id]);
@@ -78,14 +104,25 @@ const ExpandedStack = () => {
     return <Loading />;
   }
 
+  if (isDeleting) {
+    return (
+      <Placeholder height="400px">
+        <div>
+          <h1>Deleting Stack</h1>
+          <p>This may take some time...</p>
+          <Loading />
+        </div>
+      </Placeholder>
+    );
+  }
+
   return (
     <div>
       <StackTitleWrapper>
-        <TitleSection
-          materialIconClass="material-icons-outlined"
-          icon={"lan"}
-          capitalize
-        >
+        <BackButton to="/stacks">
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection materialIconClass="material-icons-outlined" icon={"lan"}>
           {stack.name}
         </TitleSection>
         <NamespaceTag.Wrapper>
@@ -93,16 +130,21 @@ const ExpandedStack = () => {
           <NamespaceTag.Tag>{stack.namespace}</NamespaceTag.Tag>
         </NamespaceTag.Wrapper>
       </StackTitleWrapper>
-      <RevisionList
-        revisions={stack.revisions}
-        currentRevision={currentRevision}
-        latestRevision={stack.latest_revision}
-        stackId={stack.id}
-        stackNamespace={namespace}
-        onRevisionClick={(revision) => setCurrentRevision(revision)}
-        onRollback={() => getStack()}
-      ></RevisionList>
-      <Br />
+
+      {/* Stack error message */}
+      {currentRevision &&
+      currentRevision?.reason &&
+      currentRevision?.message?.length > 0 ? (
+        <StackErrorMessageStyles.Wrapper>
+          <i className="material-icons">history</i>
+          <StackErrorMessageStyles.Text color="#aaaabb">
+            {currentRevision?.status === "failed" ? "Error: " : ""}
+            {currentRevision?.message}
+          </StackErrorMessageStyles.Text>
+        </StackErrorMessageStyles.Wrapper>
+      ) : null}
+
+      <Break />
       <InfoWrapper>
         <LastDeployed>
           <Status
@@ -110,30 +152,20 @@ const ExpandedStack = () => {
             message={getStackStatusMessage(stack)}
           />
           <SepDot>•</SepDot>
-          <Text color="#aaaabb">
-            {!stack.latest_revision?.id
-              ? `No version found`
-              : `v${stack.latest_revision.id}`}
-          </Text>
-          <SepDot>•</SepDot>
           Last updated {readableDate(stack.updated_at)}
         </LastDeployed>
       </InfoWrapper>
 
-      {/* Stack error message */}
-      {stack.latest_revision &&
-      stack.latest_revision.status === "failed" &&
-      stack.latest_revision.message?.length > 0 ? (
-        <StackErrorMessageStyles.Wrapper>
-          <StackErrorMessageStyles.Title color="#b7b7c9">
-            Error reason:
-          </StackErrorMessageStyles.Title>
-          <StackErrorMessageStyles.Text color="#aaaabb">
-            {stack.latest_revision.message}
-          </StackErrorMessageStyles.Text>
-        </StackErrorMessageStyles.Wrapper>
-      ) : null}
-
+      <RevisionList
+        revisions={stack.revisions}
+        currentRevision={currentRevision}
+        latestRevision={stack.latest_revision}
+        stackId={stack.id}
+        stackNamespace={namespace}
+        onRevisionClick={(revision) => setCurrentRevision(revision)}
+        onRollback={() => getStack()}
+      ></RevisionList>
+      <Br />
       <TabSelector
         currentTab={currentTab}
         options={[
@@ -146,32 +178,24 @@ const ExpandedStack = () => {
                 {currentRevision.id !== stack.latest_revision.id ? (
                   <ChartListWrapper>
                     <Placeholder>
-                      Not available when previewing versions
+                      Not available when previewing revisions
                     </Placeholder>
                   </ChartListWrapper>
                 ) : (
-                  <>
-                    <SortSelector
-                      setSortType={setSortType}
-                      sortType={sortType}
+                  <ChartListWrapper>
+                    <ChartList
+                      currentCluster={currentCluster}
                       currentView="stacks"
+                      namespace={namespace}
+                      sortType="Alphabetical"
+                      appFilters={
+                        stack?.latest_revision?.resources?.map(
+                          (res) => res.name
+                        ) || []
+                      }
+                      closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
                     />
-
-                    <ChartListWrapper>
-                      <ChartList
-                        currentCluster={currentCluster}
-                        currentView="stacks"
-                        namespace={namespace}
-                        sortType="Alphabetical"
-                        appFilters={
-                          stack?.latest_revision?.resources?.map(
-                            (res) => res.name
-                          ) || []
-                        }
-                        closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-                      />
-                    </ChartListWrapper>
-                  </>
+                  </ChartListWrapper>
                 )}
               </>
             ),
@@ -190,21 +214,78 @@ const ExpandedStack = () => {
               </>
             ),
           },
+          {
+            label: "Env Groups",
+            value: "env_groups",
+            component: (
+              <>
+                <Gap></Gap>
+                <EnvGroups stack={stack} />
+              </>
+            ),
+          },
+          {
+            label: "Settings",
+            value: "settings",
+            component: (
+              <>
+                <Gap></Gap>
+                <Settings stackName={stack.name} onDelete={handleDelete} />
+              </>
+            ),
+          },
         ]}
         setCurrentTab={(tab) => {
           setCurrentTab(tab);
         }}
       ></TabSelector>
+      <PaddingBottom />
     </div>
   );
 };
 
 export default ExpandedStack;
 
+const PaddingBottom = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
+const Break = styled.div`
+  width: 100%;
+  height: 20px;
+`;
+
+const BackButton = styled(NavLink)`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
 const ChartListWrapper = styled.div`
   width: 100%;
   margin: auto;
-  margin-top: 20px;
   padding-bottom: 125px;
 `;
 
@@ -216,13 +297,18 @@ const Gap = styled.div`
 
 const StackErrorMessageStyles = {
   Text: styled(Text)`
-    font-size: 14px;
-    margin-bottom: 10px;
+    font-size: 13px;
   `,
   Wrapper: styled.div`
     display: flex;
-    flex-direction: column;
+    align-items: center;
+
     margin-top: 5px;
+    > i {
+      color: #ffffff44;
+      margin-right: 8px;
+      font-size: 20px;
+    }
   `,
   Title: styled(Text)`
     font-size: 16px;
@@ -233,11 +319,12 @@ const StackErrorMessageStyles = {
 const StackTitleWrapper = styled.div`
   width: 100%;
   display: flex;
-  justify-content: space-between;
+  position: relative;
   align-items: center;
 
   // Hotfix to make sure the title section and the namespace tag are aligned
   ${NamespaceTag.Wrapper} {
-    margin-bottom: 15px;
+    margin-left: 17px;
+    margin-bottom: 13px;
   }
 `;

+ 8 - 2
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -143,8 +143,9 @@ const _RevisionList = ({
         >
           <RevisionPreview>
             {currentRevision.id === latestRevision.id
-              ? `Current Revision v${currentRevision.id}`
-              : `Previewing Revision (Not Deployed) v${currentRevision.id}`}
+              ? `Current Revision`
+              : `Previewing Revision (Not Deployed)`}{" "}
+              - <Revision>No. {currentRevision.id}</Revision>
             <i className="material-icons">arrow_drop_down</i>
           </RevisionPreview>
         </RevisionHeader>
@@ -167,6 +168,11 @@ const _RevisionList = ({
 
 export default _RevisionList;
 
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
 const StyledRevisionSection = styled.div`
   display: flex;
   flex-direction: column;

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -159,12 +159,13 @@ const SourceConfigStyles = {
   `,
   ItemContainer: styled.div`
     background: #ffffff11;
-    border-radius: 15px;
-    padding: 20px 15px;
+    border-radius: 8px;
+    padding: 30px 35px 35px;
   `,
   ItemTitle: styled.div`
     font-size: 16px;
     width: fit-content;
+    font-weight: 500;
   `,
   TooltipItem: styled.div`
     font-size: 14px;

+ 112 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -0,0 +1,112 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { Card } from "../../launch/components/styles";
+import { Stack } from "../../types";
+import sliders from "assets/sliders.svg";
+import DynamicLink from "components/DynamicLink";
+import Placeholder from "components/Placeholder";
+import Loading from "components/Loading";
+
+type PopulatedEnvGroup = {
+  applications: string[];
+  created_at: string;
+  meta_version: number;
+  name: string;
+  namespace: string;
+  variables: Record<string, string>;
+  version: number;
+};
+
+const EnvGroups = ({ stack }: { stack: Stack }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [envGroups, setEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+
+  const getEnvGroups = async () => {
+    const stackEnvGroups = stack.latest_revision.env_groups;
+    return Promise.all(
+      stackEnvGroups.map((envGroup) =>
+        api
+          .getEnvGroup<PopulatedEnvGroup>(
+            "<token>",
+            {},
+            {
+              cluster_id: currentCluster.id,
+              id: currentProject.id,
+              name: envGroup.name,
+              namespace: stack.namespace,
+              version: envGroup.env_group_version,
+            }
+          )
+          .then((res) => res.data)
+      )
+    );
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getEnvGroups().then((envGroups) => {
+      if (!isSubscribed) {
+        return;
+      }
+      setEnvGroups(envGroups);
+      setIsLoading(false);
+    });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [stack]);
+
+  if (isLoading) {
+    return (
+      <Placeholder height="250px">
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (envGroups.length === 0) {
+    return (
+      <Placeholder height="250px">
+        <div>
+          <h3>No environment groups found for this stack</h3>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <Card.Grid style={{ marginTop: "0px" }}>
+      {envGroups.map((envGroup) => {
+        return (
+          <Card.Wrapper variant="unclickable">
+            <Card.Title>
+              <Card.SmallerIcon src={sliders}></Card.SmallerIcon>
+              {envGroup.name}
+            </Card.Title>
+
+            <Card.Actions>
+              <Card.ActionButton
+                as={DynamicLink}
+                to={{
+                  pathname: "/env-groups",
+                  search: `?namespace=${stack.namespace}&selected_env_group=${
+                    envGroup.name
+                  }&redirect_url=${encodeURIComponent(
+                    window.location.pathname
+                  )}`,
+                }}
+              >
+                <i className="material-icons-outlined">launch</i>
+              </Card.ActionButton>
+            </Card.Actions>
+          </Card.Wrapper>
+        );
+      })}
+    </Card.Grid>
+  );
+};
+
+export default EnvGroups;

+ 5 - 10
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx

@@ -92,7 +92,7 @@ const Select = <T extends unknown>({
         <SelectStyles.Selector
           className={className}
           onClick={() => setExpanded(!expanded)}
-          expanded={expanded}
+          expanded={!readOnly && expanded}
           readOnly={readOnly}
         >
           <SelectStyles.CurrentValue>
@@ -153,18 +153,15 @@ export const SelectStyles = {
     height: 35px;
     border: 1px solid #ffffff55;
     font-size: 13px;
+    color: ${props => props.readOnly ? "#ffffff44" : ""};
     padding: 5px 10px;
     padding-left: 15px;
     border-radius: 3px;
     display: flex;
     justify-content: space-between;
     align-items: center;
-    cursor: ${(props) => (props.readOnly ? "normal" : "pointer")};
+    cursor: ${(props) => (props.readOnly ? "not-allowed" : "pointer")};
     background: ${(props) => {
-      if (props.readOnly) {
-        return "#ffffff55";
-      }
-
       if (props.expanded) {
         return "#ffffff33";
       }
@@ -174,10 +171,8 @@ export const SelectStyles = {
     :hover {
       background: ${(props) => {
         if (props.readOnly) {
-          return "#ffffff55";
-        }
-
-        if (props.expanded) {
+          return "#ffffff11";
+        } else if (props.expanded) {
           return "#ffffff33";
         }
         return "#ffffff22";

+ 79 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx

@@ -0,0 +1,79 @@
+import Heading from "components/form-components/Heading";
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+const Settings = ({
+  stackName,
+  onDelete,
+}: {
+  stackName: string;
+  onDelete: () => void;
+}) => {
+  const { setCurrentOverlay } = useContext(Context);
+
+  const handleDelete = () => {
+    setCurrentOverlay({
+      message: `Are you sure you want to delete ${stackName}?`,
+      onYes: () => {
+        onDelete();
+        setCurrentOverlay(null);
+      },
+      onNo: () => setCurrentOverlay(null),
+    });
+  };
+  return (
+    <Wrapper>
+      <StyledSettingsSection>
+        <Heading>Settings</Heading>
+        <Button color="#b91133" onClick={handleDelete}>
+          Delete stack
+        </Button>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default Settings;
+
+const Wrapper = styled.div`
+  width: 100%;
+  padding-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  overflow: auto;
+  height: calc(100% - 55px);
+`;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin-top: 20px;
+  margin-bottom: 30px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;

+ 37 - 6
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -1,13 +1,13 @@
 import DynamicLink from "components/DynamicLink";
 import Loading from "components/Loading";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import Placeholder from "components/Placeholder";
 import styled from "styled-components";
 import { Stack } from "./types";
 import { readableDate } from "shared/string_utils";
-import { CardGrid, Card } from "./launch/components/styles";
+import { Card } from "./launch/components/styles";
 import Status, { StatusProps } from "./components/Status";
 import {
   Flex,
@@ -19,7 +19,13 @@ import {
 } from "./components/styles";
 import { getStackStatus, getStackStatusMessage } from "./shared";
 
-const StackList = ({ namespace }: { namespace: string }) => {
+const StackList = ({
+  namespace,
+  sortBy,
+}: {
+  namespace: string;
+  sortBy: "created_at" | "updated_at" | "alphabetical";
+}) => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
@@ -87,6 +93,21 @@ const StackList = ({ namespace }: { namespace: string }) => {
     };
   }, [namespace]);
 
+  const sortedStacks = useMemo(() => {
+    return (
+      stacks?.sort((a, b) => {
+        switch (sortBy) {
+          case "created_at":
+            return Date.parse(a.created_at) < Date.parse(b.created_at) ? 1 : -1;
+          case "updated_at":
+            return Date.parse(a.updated_at) < Date.parse(b.updated_at) ? 1 : -1;
+          default:
+            return a.name > b.name ? 1 : -1;
+        }
+      }) || []
+    );
+  }, [stacks, sortBy]);
+
   if (isLoading) {
     return <Loading />;
   }
@@ -102,9 +123,19 @@ const StackList = ({ namespace }: { namespace: string }) => {
     );
   }
 
+  if (sortedStacks.length === 0) {
+    return (
+      <Placeholder height="250px">
+        <div>
+          <h3>No stacks found with the given filters</h3>
+        </div>
+      </Placeholder>
+    );
+  }
+
   return (
     <>
-      <CardGrid>
+      <Card.Grid>
         {stacks.map((stack) => (
           <StackCard
             as={DynamicLink}
@@ -160,7 +191,7 @@ const StackList = ({ namespace }: { namespace: string }) => {
             </Flex>
           </StackCard>
         ))}
-      </CardGrid>
+      </Card.Grid>
     </>
   );
 };
@@ -221,7 +252,7 @@ const DataContainer = styled.div`
   overflow: hidden;
 `;
 
-const StackCard = styled(Card)`
+const StackCard = styled(Card.Wrapper)`
   font-size: 13px;
   font-weight: 500;
 `;

+ 84 - 73
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -15,11 +15,38 @@ import styled from "styled-components";
 import Heading from "components/form-components/Heading";
 import TitleSection from "components/TitleSection";
 import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { BackButton, Icon, Polymer } from "./components/styles";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import { CreateStackBody } from "../types";
 
 const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
 
+const parseEnvGroup = (namespace: string) => (
+  envGroup: CreateStackBody["env_groups"][0]
+): PopulatedEnvGroup => {
+  const variables = envGroup?.variables || {};
+  const secretVariables = envGroup?.secret_variables || {};
+
+  return {
+    name: envGroup.name,
+    version: 1,
+    namespace,
+    applications: envGroup.linked_applications,
+    meta_version: 2,
+    variables: {
+      ...variables,
+      ...Object.keys(secretVariables).reduce((acc, key) => {
+        acc[key] = "PORTERSECRET_" + key;
+        return acc;
+      }, {} as any),
+    },
+  };
+};
+
 const NewApp = () => {
-  const { addAppResource, newStack } = useContext(StacksLaunchContext);
+  const { addAppResource, newStack, namespace } = useContext(
+    StacksLaunchContext
+  );
   const { currentCluster } = useContext(Context);
 
   const params = useParams<{
@@ -72,15 +99,29 @@ const NewApp = () => {
   }, [params]);
 
   if (isLoading) {
-    return <Wrapper><Loading /></Wrapper>;
+    return (
+      <Wrapper>
+        <Loading />
+      </Wrapper>
+    );
   }
 
   if (hasError) {
     return <>Unexpected error</>;
   }
 
-  const handleSubmit = async (rawValues: any) => {
+  const handleSubmit = async ({
+    values: rawValues,
+    metadata,
+  }: {
+    values: any;
+    metadata: any;
+  }) => {
     setSaveButtonStatus("loading");
+    const syncedEnvGroups =
+      metadata["container.env"]?.added?.map(
+        ({ name }: { name: string }) => name
+      ) || [];
 
     // Convert dotted keys to nested objects
     let values: any = {};
@@ -165,13 +206,16 @@ const NewApp = () => {
       return;
     }
 
-    addAppResource({
-      name: appName,
-      source_config_name: newStack.source_configs[0]?.name || "",
-      template_name: params.template_name,
-      template_version: params.version,
-      values,
-    });
+    addAppResource(
+      {
+        name: appName,
+        source_config_name: newStack.source_configs[0]?.name || "",
+        template_name: params.template_name,
+        template_version: params.version,
+        values,
+      },
+      [...syncedEnvGroups]
+    );
 
     setSaveButtonStatus("successful");
     setTimeout(() => {
@@ -181,21 +225,24 @@ const NewApp = () => {
   };
 
   return (
-    <StyledLaunchFlow style={{ position: "relative" }}>
+    <>
       <TitleSection>
         <DynamicLink to={`/stacks/launch/overview`}>
           <BackButton>
-            <i className="material-icons">
-              keyboard_backspace
-            </i>
+            <i className="material-icons">keyboard_backspace</i>
           </BackButton>
         </DynamicLink>
         <Polymer>
-        <Icon src={hardcodedIcons[template.metadata.name]} />
+          <Icon src={hardcodedIcons[template.metadata.name]} />
         </Polymer>
-        Add {template.metadata.name.charAt(0).toUpperCase() + template.metadata.name.slice(1)} to Stack
+        Add{" "}
+        {template.metadata.name.charAt(0).toUpperCase() +
+          template.metadata.name.slice(1)}{" "}
+        to Stack
       </TitleSection>
-      <Heading>Application Name <Required>*</Required></Heading>
+      <Heading>
+        Application Name <Required>*</Required>
+      </Heading>
       <InputRow
         type="string"
         value={appName}
@@ -205,17 +252,26 @@ const NewApp = () => {
       />
 
       <div style={{ position: "relative" }}>
-      <Heading>Application Settings</Heading>
-      <Helper>Configure settings for this application.</Helper>
-      <PorterFormWrapper
-        formData={template.form}
-        onSubmit={handleSubmit}
-        isLaunch
-        saveValuesStatus={saveButtonStatus}
-        saveButtonText="Add Application"
-      />
+        <Heading>Application Settings</Heading>
+        <Helper>Configure settings for this application.</Helper>
+        <PorterFormWrapper
+          formData={template.form}
+          onSubmit={handleSubmit}
+          isLaunch
+          saveValuesStatus={saveButtonStatus}
+          saveButtonText="Add Application"
+          valuesToOverride={{ namespace }}
+          injectedProps={{
+            "key-value-array": {
+              availableSyncEnvGroups: newStack.env_groups.map(
+                parseEnvGroup(namespace)
+              ),
+            },
+          }}
+          includeMetadata
+        />
       </div>
-    </StyledLaunchFlow>
+    </>
   );
 };
 
@@ -231,51 +287,6 @@ const Wrapper = styled.div`
   margin-top: calc(50vh - 150px);
 `;
 
-const Icon = styled.img`
-  width: 40px;
-  margin-right: 14px;
-
-  opacity: 0;
-  animation: floatIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const BackButton = styled.div`
-  > i {
-    cursor: pointer;
-    font-size: 24px;
-    color: #969fbbaa;
-    margin-right: 10px;
-    padding: 3px;
-    margin-left: 0px;
-    border-radius: 100px;
-    :hover {
-      background: #ffffff11;
-    }
-  }
-`;
-
-const Polymer = styled.div`
-  margin-bottom: -6px;
-
-  > i {
-    color: #ffffff;
-    font-size: 24px;
-    margin-left: 5px;
-    margin-right: 18px;
-  }
-`;
-
 const StyledLaunchFlow = styled.div`
   min-width: 300px;
   width: calc(100% - 100px);
@@ -283,4 +294,4 @@ const StyledLaunchFlow = styled.div`
   margin-top: ${(props: { disableMarginTop?: boolean }) =>
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
   padding-bottom: 150px;
-`;
+`;

+ 156 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx

@@ -0,0 +1,156 @@
+import DynamicLink from "components/DynamicLink";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import TitleSection from "components/TitleSection";
+import React, { useContext, useMemo, useState } from "react";
+import { isAlphanumeric } from "shared/common";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
+import { BackButton, Icon, Polymer, SubmitButton } from "./components/styles";
+import { StacksLaunchContext } from "./Store";
+import sliders from "assets/sliders.svg";
+
+const envArrayToObject = (variables: KeyValueType[]) => {
+  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
+    acc[curr.key] = curr.value;
+    return acc;
+  }, {});
+};
+
+const NewEnvGroup = () => {
+  const { addEnvGroup } = useContext(StacksLaunchContext);
+  const [name, setName] = useState("");
+  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
+
+  const { pushFiltered } = useRouting();
+
+  const handleOnSubmit = () => {
+    const variables = envVariables.filter(
+      (variable) => !variable.locked && !variable.hidden
+    );
+    const secret_variables = envVariables.filter(
+      (variable) => variable.locked || variable.hidden
+    );
+
+    addEnvGroup({
+      name,
+      variables: envArrayToObject(variables),
+      secret_variables: envArrayToObject(secret_variables),
+      linked_applications: [],
+    });
+    setName("");
+    setEnvVariables([]);
+    pushFiltered("/stacks/launch/overview", []);
+    return;
+  };
+
+  const hasError = useMemo(() => {
+    if (!isAlphanumeric(name) || name === "") {
+      return { message: "Name cannot be empty." };
+    }
+
+    if (!envVariables.length) {
+      return { message: "Please add at least one environment variable." };
+    }
+
+    if (envVariables.some((variable) => !variable.value || !variable.key)) {
+      return { message: "Please fill in all environment variables." };
+    }
+
+    return null;
+  }, [name, envVariables]);
+
+  return (
+    <>
+      <TitleSection>
+        <DynamicLink to={`/stacks/launch/overview`}>
+          <BackButton>
+            <i className="material-icons">keyboard_backspace</i>
+          </BackButton>
+        </DynamicLink>
+        <Polymer>
+          <SliderIcon src={sliders} />
+        </Polymer>
+        Add a Env Group to Stack
+      </TitleSection>
+      <Heading isAtTop={true}>Name</Heading>
+      <Subtitle>
+        <Warning
+          makeFlush={true}
+          highlight={!isAlphanumeric(name) && name !== ""}
+        >
+          Lowercase letters, numbers, and "-" only.
+        </Warning>
+      </Subtitle>
+      <InputRow
+        type="text"
+        value={name}
+        setValue={(x: string) => {
+          setName(x);
+        }}
+        placeholder="ex: doctor-scientist"
+        width="100%"
+      />
+
+      <Heading>Environment Variables</Heading>
+      <Helper>
+        Set environment variables for your secrets and environment-specific
+        configuration.
+      </Helper>
+      <EnvGroupArray
+        values={envVariables}
+        setValues={(x: any) => setEnvVariables((prev) => [...x])}
+        fileUpload={true}
+        secretOption={true}
+      />
+
+      <SubmitButton
+        onClick={handleOnSubmit}
+        makeFlush
+        clearPosition
+        text="Save env group"
+        disabled={!!hasError}
+        statusPosition="left"
+        status={hasError?.message || ""}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroup;
+
+export const SliderIcon = styled.img`
+  width: 25px;
+  margin-right: 16px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 0px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;

+ 91 - 7
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -6,14 +6,21 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import useAuth from "shared/auth/useAuth";
 import { useRouting } from "shared/routing";
-import { CardGrid, SubmitButton } from "./components/styles";
-import { AppCard } from "./components/AppCard";
+import {
+  AddResourceButtonStyles,
+  SubmitButton,
+  Card,
+} from "./components/styles";
 import { AddResourceButton } from "./components/AddResourceButton";
 import styled from "styled-components";
 
 import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import TitleSection from "components/TitleSection";
+import DynamicLink from "components/DynamicLink";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import sliders from "assets/sliders.svg";
+import DocsHelper from "components/DocsHelper";
 
 const Overview = () => {
   const {
@@ -22,6 +29,8 @@ const Overview = () => {
     setStackName,
     setStackNamespace,
     submit,
+    removeAppResource,
+    removeEnvGroup,
   } = useContext(StacksLaunchContext);
   const { currentProject, currentCluster } = useContext(Context);
   const [isAuthorized] = useAuth();
@@ -99,7 +108,7 @@ const Overview = () => {
   }, [namespace, newStack.name]);
 
   return (
-    <StyledLaunchFlow style={{ position: "relative" }}>
+    <>
       <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
         <Polymer>
           <i className="material-icons">lan</i>
@@ -143,18 +152,71 @@ const Overview = () => {
         />
       </ClusterSection>
 
+      <Heading>
+        Env Groups
+        {/* <InlineDocsHelper
+          disableMargin={true}
+          tooltipText="Environment Groups"
+          link="https://docs.porter.run/deploying-applications/environment-groups"
+        /> */}
+      </Heading>
+      <Helper>Add scoped environment groups to this stack:</Helper>
+      <Card.Grid>
+        {newStack.env_groups.map((envGroup) => (
+          <Card.Wrapper variant="unclickable">
+            <Card.Title>
+              <Card.SmallerIcon src={sliders} />
+              {envGroup.name}
+            </Card.Title>
+            <Card.Actions>
+              <Card.ActionButton
+                onClick={() => {
+                  removeEnvGroup(envGroup);
+                }}
+              >
+                <i className="material-icons-outlined">close</i>
+              </Card.ActionButton>
+            </Card.Actions>
+          </Card.Wrapper>
+        ))}
+
+        <AddResourceButtonStyles.Wrapper>
+          <AddResourceButtonStyles.Flex>
+            <LinkMask to={`/stacks/launch/new-env-group`}></LinkMask>
+            <Icon>
+              <i className="material-icons">add</i>
+            </Icon>
+            Add a new env group
+          </AddResourceButtonStyles.Flex>
+        </AddResourceButtonStyles.Wrapper>
+      </Card.Grid>
+
       <Heading>Applications</Heading>
       <Helper>
         At least one application is required:
         <Required>*</Required>
       </Helper>
-      <CardGrid>
+      <Card.Grid>
         {newStack.app_resources.map((app) => (
-          <AppCard key={app.name} app={app} />
+          <Card.Wrapper variant="unclickable">
+            <Card.Title>
+              <Card.Icon src={hardcodedIcons[app.template_name]}></Card.Icon>
+              {app.name}
+            </Card.Title>
+            <Card.Actions>
+              <Card.ActionButton
+                onClick={() => {
+                  removeAppResource(app);
+                }}
+              >
+                <i className="material-icons-outlined">close</i>
+              </Card.ActionButton>
+            </Card.Actions>
+          </Card.Wrapper>
         ))}
 
         <AddResourceButton />
-      </CardGrid>
+      </Card.Grid>
 
       <SubmitButton
         disabled={!isValid || submitButtonStatus !== ""}
@@ -166,7 +228,7 @@ const Overview = () => {
       >
         Create Stack
       </SubmitButton>
-    </StyledLaunchFlow>
+    </>
   );
 };
 
@@ -229,3 +291,25 @@ const StyledLaunchFlow = styled.div`
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
   padding-bottom: 150px;
 `;
+
+const LinkMask = styled(DynamicLink)`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+`;
+
+const Icon = styled.div`
+  margin-bottom: -3px;
+  > i {
+    margin-right: 20px;
+    margin-left: 9px;
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const InlineDocsHelper = styled(DocsHelper)`
+  display: inline-block;
+`;

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

@@ -31,7 +31,7 @@ const SelectSource = () => {
   };
 
   return (
-    <StyledLaunchFlow style={{ position: "relative" }}>
+    <>
       <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
         <Polymer>
           <i className="material-icons">lan</i>
@@ -59,7 +59,7 @@ const SelectSource = () => {
         clearPosition
         makeFlush
       />
-    </StyledLaunchFlow>
+    </>
   );
 };
 
@@ -67,7 +67,7 @@ export default SelectSource;
 
 const Br = styled.div<{ height?: string }>`
   width: 100%;
-  height: ${props => props.height || "1px"};
+  height: ${(props) => props.height || "1px"};
 `;
 
 const Required = styled.div`
@@ -86,11 +86,3 @@ const Polymer = styled.div`
     margin-right: 18px;
   }
 `;
-
-const StyledLaunchFlow = styled.div`
-  width: calc(100% - 100px);
-  margin-left: 50px;
-  min-width: 300px;
-  margin-top: ${(props: { disableMarginTop?: boolean }) =>
-    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
-`;

+ 103 - 9
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -15,10 +15,17 @@ export type StacksLaunchContextType = {
     sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
   ) => void;
 
-  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+  addAppResource: (
+    appResource: CreateStackBody["app_resources"][0],
+    syncedEnvGroups: string[]
+  ) => void;
 
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
 
+  addEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => void;
+
+  removeEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => void;
+
   submit: () => Promise<void>;
 };
 
@@ -27,6 +34,7 @@ const defaultValues: StacksLaunchContextType = {
     name: "",
     app_resources: [],
     source_configs: [],
+    env_groups: [],
   },
 
   namespace: "",
@@ -42,6 +50,10 @@ const defaultValues: StacksLaunchContextType = {
 
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
 
+  addEnvGroup: () => {},
+
+  removeEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => {},
+
   submit: async () => {},
 };
 
@@ -92,25 +104,105 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
   };
 
   const addAppResource: StacksLaunchContextType["addAppResource"] = (
-    appResource
+    appResource,
+    syncedEnvGroups
   ) => {
-    setNewStack((prev) => ({
-      ...prev,
-      app_resources: [...prev.app_resources, appResource],
-    }));
+    setNewStack((prev) => {
+      const envGroups = syncedEnvGroups
+        .map((envGroupName) => {
+          return prev.env_groups.find(
+            (envGroup) => envGroup.name === envGroupName
+          );
+        })
+        .map((envGroup) => {
+          return {
+            ...envGroup,
+            linked_applications: [
+              ...envGroup.linked_applications,
+              appResource.name,
+            ],
+          };
+        });
+
+      // Replace prev.envGroups with envGroups based on name
+      const newEnvGroups = prev.env_groups.map((envGroup) => {
+        const newEnvGroup = envGroups.find(
+          (envGroup2) => envGroup2.name === envGroup.name
+        );
+        return newEnvGroup ? newEnvGroup : envGroup;
+      });
+
+      return {
+        ...prev,
+        app_resources: [...prev.app_resources, appResource],
+        env_groups: newEnvGroups,
+      };
+    });
   };
 
   const removeAppResource: StacksLaunchContextType["removeAppResource"] = (
     appResource
   ) => {
+    setNewStack((prev) => {
+      const removedAppName = appResource.name;
+      const newEnvGroups = prev.env_groups.map((envGroup) => {
+        const newLinkedApplications = envGroup.linked_applications.filter(
+          (linkedApplication) => linkedApplication !== removedAppName
+        );
+        return {
+          ...envGroup,
+          linked_applications: newLinkedApplications,
+        };
+      });
+
+      return {
+        ...prev,
+        app_resources: prev.app_resources.filter(
+          (ar) => ar.name !== appResource.name
+        ),
+        env_groups: newEnvGroups,
+      };
+    });
+  };
+
+  const addEnvGroup: StacksLaunchContextType["addEnvGroup"] = (envGroup) => {
     setNewStack((prev) => ({
       ...prev,
-      app_resources: prev.app_resources.filter(
-        (ar) => ar.name !== appResource.name
-      ),
+      env_groups: [...prev.env_groups, envGroup],
     }));
   };
 
+  const removeEnvGroup: StacksLaunchContextType["removeEnvGroup"] = (
+    envGroup
+  ) => {
+    setNewStack((prev) => {
+      const removedEnvGroup = prev.env_groups.find(
+        (eg) => eg.name === envGroup.name
+      );
+
+      if (removedEnvGroup.linked_applications.length > 0) {
+        const formatter = new Intl.ListFormat("en", {
+          style: "long",
+          type: "conjunction",
+        });
+
+        setCurrentError(
+          `Cannot remove env group ${
+            envGroup.name
+          } because it is linked to applications: ${formatter.format(
+            removedEnvGroup.linked_applications
+          )}`
+        );
+        return prev;
+      }
+
+      return {
+        ...prev,
+        env_groups: prev.env_groups.filter((eg) => eg.name !== envGroup.name),
+      };
+    });
+  };
+
   const submit: StacksLaunchContextType["submit"] = async () => {
     try {
       await api.createStack("<token>", newStack, {
@@ -134,6 +226,8 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
         addSourceConfig,
         addAppResource,
         removeAppResource,
+        addEnvGroup,
+        removeEnvGroup,
         submit,
       }}
     >

+ 0 - 54
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx

@@ -1,54 +0,0 @@
-import React, { useContext } from "react";
-import { StacksLaunchContext, StacksLaunchContextType } from "../Store";
-import { ButtonWithIcon, Card } from "./styles";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-
-import styled from "styled-components";
-
-export const AppCard = ({
-  app,
-}: {
-  app: StacksLaunchContextType["newStack"]["app_resources"][0];
-}) => {
-  const { removeAppResource } = useContext(StacksLaunchContext);
-
-  const handleDelete = () => {
-    removeAppResource(app);
-  };
-
-  return (
-    <UnclickableCard>
-      <Flex>
-        <Icon src={hardcodedIcons[app.template_name]} />
-        {app.name}
-      </Flex>
-      <DeleteButton onClick={handleDelete}>
-        <i className="material-icons-outlined">close</i>
-      </DeleteButton>
-    </UnclickableCard>
-  );
-};
-
-const UnclickableCard = styled(Card)`
-  cursor: default;
-  :hover {
-    border: 1px solid #ffffff0f;
-  }
-`;
-
-const DeleteButton = styled(ButtonWithIcon)`
-  margin-right: 5px;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 14px;
-  font-weight: 500;
-`;
-
-const Icon = styled.img`
-  height: 30px;
-  margin-right: 15px;
-  margin-left: 5px;
-`;

+ 130 - 45
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -1,39 +1,99 @@
 import SaveButton from "components/SaveButton";
 import styled from "styled-components";
 
-export const CardGrid = styled.div`
-  margin-top: 32px;
-  margin-bottom: 32px;
-  display: grid;
-  grid-row-gap: 25px;
-`;
+export const Card = {
+  Grid: styled.div`
+    margin-top: 32px;
+    margin-bottom: 32px;
+    display: grid;
+    grid-row-gap: 25px;
+  `,
+  Wrapper: styled.div<{ variant?: "clickable" | "unclickable" }>`
+    display: flex;
+    color: #ffffff;
+    background: #2b2e3699;
+    justify-content: space-between;
+    border-radius: 5px;
+    height: 75px;
+    padding: 12px;
+    padding-left: 14px;
+    border: 1px solid #ffffff0f;
+    align-items: center;
 
-export const Card = styled.div`
-  display: flex;
-  color: #ffffff;
-  background: #2b2e3699;
-  justify-content: space-between;
-  border-radius: 5px;
-  cursor: pointer;
-  height: 75px;
-  padding: 12px;
-  padding-left: 14px;
-  border: 1px solid #ffffff0f;
-  align-items: center;
-
-  :hover {
-    border: 1px solid #ffffff3c;
-  }
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
+    ${(props) => {
+      if (props.variant === "unclickable") {
+        return `
+          cursor: default;
+          :hover {
+            border: 1px solid #ffffff0f;
+          }
+        `;
+      }
+
+      return `
+        cursor: pointer;
+        :hover {
+          border: 1px solid #ffffff3c;
+        }
+      `;
+    }}
+
+    animation: fadeIn 0.5s;
+    @keyframes fadeIn {
+      from {
+        opacity: 0;
+      }
+      to {
+        opacity: 1;
+      }
     }
-    to {
-      opacity: 1;
+  `,
+
+  Title: styled.div`
+    display: flex;
+    align-items: center;
+    font-size: 14px;
+    font-weight: 500;
+  `,
+  SmallerIcon: styled.img`
+    height: 20px;
+    margin-right: 18px;
+    margin-left: 8px;
+  `,
+  Icon: styled.img`
+    height: 30px;
+    margin-right: 15px;
+    margin-left: 5px;
+  `,
+  Actions: styled.div`
+    margin-right: 5px;
+    display: flex;
+  `,
+  ActionButton: styled.button`
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    background-color: #ffffff11;
+    border: 1px solid #ffffff22;
+    cursor: pointer;
+    color: white;
+
+    :not(:first-child) {
+      margin-left: 10px;
     }
-  }
-`;
+
+    &:hover {
+      background-color: #ffffff3c;
+    }
+
+    > i {
+      font-size: 18px;
+    }
+  `,
+};
 
 export const SubmitButton = styled(SaveButton)`
   width: 100%;
@@ -42,7 +102,7 @@ export const SubmitButton = styled(SaveButton)`
 `;
 
 export const AddResourceButtonStyles = {
-  Wrapper: styled(Card)`
+  Wrapper: styled(Card.Wrapper)`
     align-items: center;
     position: relative;
     font-size: 14px;
@@ -127,22 +187,47 @@ export const SelectorStyles = {
   `,
 };
 
-export const ButtonWithIcon = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  background-color: #ffffff11;
-  border: 1px solid #ffffff22;
-  cursor: pointer;
-
-  &:hover {
-    background-color: #ffffff3c;
+export const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
   }
+`;
+
+export const Polymer = styled.div`
+  margin-bottom: -6px;
 
   > i {
-    font-size: 18px;
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+export const Icon = styled.img`
+  width: 40px;
+  margin-right: 14px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
   }
 `;

+ 29 - 14
dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx

@@ -2,6 +2,7 @@ import React from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import styled from "styled-components";
 import NewApp from "./NewApp";
+import NewEnvGroup from "./NewEnvGroup";
 import Overview from "./Overview";
 import SelectSource from "./SelectSource";
 import StacksLaunchContextProvider from "./Store";
@@ -12,20 +13,25 @@ const LaunchRoutes = () => {
   return (
     <LaunchContainer>
       <StacksLaunchContextProvider>
-        <Switch>
-          <Route path={`${path}/source`}>
-            <SelectSource />
-          </Route>
-          <Route path={`${path}/overview`}>
-            <Overview />
-          </Route>
-          <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
-            <NewApp />
-          </Route>
-          <Route path={`*`}>
-            <Redirect to={`${path}/source`} />
-          </Route>
-        </Switch>
+        <StyledLaunchFlow>
+          <Switch>
+            <Route path={`${path}/source`}>
+              <SelectSource />
+            </Route>
+            <Route path={`${path}/overview`}>
+              <Overview />
+            </Route>
+            <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
+              <NewApp />
+            </Route>
+            <Route path={`${path}/new-env-group`}>
+              <NewEnvGroup />
+            </Route>
+            <Route path={`*`}>
+              <Redirect to={`${path}/source`} />
+            </Route>
+          </Switch>
+        </StyledLaunchFlow>
       </StacksLaunchContextProvider>
     </LaunchContainer>
   );
@@ -37,3 +43,12 @@ const LaunchContainer = styled.div`
   margin: 0 auto;
   width: 100%;
 `;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  min-width: 300px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  margin-bottom: 50px;
+`;

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

@@ -20,6 +20,17 @@ export type CreateStackBody = {
       dockerfile?: unknown;
     };
   }[];
+
+  env_groups: {
+    name: string;
+    variables: {
+      [key: string]: string;
+    };
+    secret_variables: {
+      [key: string]: string;
+    };
+    linked_applications: string[];
+  }[];
 };
 
 export type CreateStackResponse = Stack;
@@ -41,6 +52,7 @@ export type Stack = {
 export type FullStackRevision = StackRevision & {
   resources: AppResource[];
   source_configs: SourceConfig[];
+  env_groups: EnvGroup[];
 };
 
 export type StackRevision = {
@@ -48,7 +60,14 @@ export type StackRevision = {
   created_at: string;
   status: "deploying" | "deployed" | "failed"; // type with enum
   stack_id: string;
-  reason: "DeployError" | "SaveError" | "RollbackError";
+  reason:
+    | "DeployError"
+    | "SaveError"
+    | "RollbackError"
+    | "EnvGroupUpgrade"
+    | "ApplicationUpgrade"
+    | "SourceConfigUpgrade"
+    | "Rollback";
   message: string;
 };
 
@@ -89,3 +108,13 @@ export type AppResource = {
     template_version: string;
   };
 };
+
+export type EnvGroup = {
+  env_group_version: number;
+  updated_at: string;
+  stack_id: string;
+  name: string;
+  stack_revision_id: number;
+  created_at: string;
+  id: string;
+};

+ 8 - 0
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -32,6 +32,7 @@ type PropsType = {
   syncedEnvGroups?: PopulatedEnvGroup[];
   setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
   normalEnvVarsOnly?: boolean;
+  availableEnvGroups?: PopulatedEnvGroup[];
 };
 
 type StateType = {
@@ -114,6 +115,13 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
   };
 
   componentDidMount() {
+    if (Array.isArray(this.props.availableEnvGroups)) {
+      this.setState({
+        envGroups: this.props.availableEnvGroups,
+        loading: false,
+      });
+      return;
+    }
     this.updateEnvGroups();
   }
 

+ 1 - 1
dashboard/src/shared/types.tsx

@@ -21,7 +21,7 @@ export interface DetailedIngressError {
 }
 
 export interface ChartType {
-  is_stack: boolean;
+  stack_id: string;
   image_repo_uri: string;
   git_action_config: any;
   build_config: BuildConfig;

+ 0 - 1
internal/models/release.go

@@ -35,7 +35,6 @@ func (r *Release) ToReleaseType() *types.PorterRelease {
 		ID:           r.ID,
 		WebhookToken: r.WebhookToken,
 		ImageRepoURI: r.ImageRepoURI,
-		IsStack:      r.StackResourceID != 0,
 	}
 
 	if r.GitActionConfig != nil {

+ 39 - 0
internal/models/stack.go

@@ -62,6 +62,8 @@ type StackRevision struct {
 	Resources []StackResource
 
 	SourceConfigs []StackSourceConfig
+
+	EnvGroups []StackEnvGroup
 }
 
 func (s StackRevision) ToStackRevisionMetaType(stackID string) types.StackRevisionMeta {
@@ -88,10 +90,17 @@ func (s StackRevision) ToStackRevisionType(stackID string) *types.StackRevision
 		resources = append(resources, *stackResource.ToStackResource(stackID, s.RevisionNumber, s.SourceConfigs))
 	}
 
+	envGroups := make([]types.StackEnvGroup, 0)
+
+	for _, stackEnvGroup := range s.EnvGroups {
+		envGroups = append(envGroups, *stackEnvGroup.ToStackEnvGroupType(stackID, s.RevisionNumber))
+	}
+
 	return &types.StackRevision{
 		StackRevisionMeta: &metaType,
 		SourceConfigs:     sourceConfigs,
 		Resources:         resources,
+		EnvGroups:         envGroups,
 		Reason:            s.Reason,
 		Message:           s.Message,
 	}
@@ -176,3 +185,33 @@ func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevision
 		ImageTag:        s.ImageTag,
 	}
 }
+
+type StackEnvGroup struct {
+	gorm.Model
+
+	StackRevisionID uint
+
+	Name string
+
+	Namespace string
+
+	ProjectID uint
+
+	ClusterID uint
+
+	UID string
+
+	EnvGroupVersion uint
+}
+
+func (s StackEnvGroup) ToStackEnvGroupType(stackID string, stackRevisionID uint) *types.StackEnvGroup {
+	return &types.StackEnvGroup{
+		CreatedAt:       s.CreatedAt,
+		UpdatedAt:       s.UpdatedAt,
+		StackID:         stackID,
+		StackRevisionID: stackRevisionID,
+		Name:            s.Name,
+		ID:              s.UID,
+		EnvGroupVersion: s.EnvGroupVersion,
+	}
+}

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.StackRevision{},
 		&models.StackResource{},
 		&models.StackSourceConfig{},
+		&models.StackEnvGroup{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 15 - 3
internal/repository/gorm/stack.go

@@ -49,7 +49,7 @@ func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace str
 	// query for each stack's revision
 	revisions := make([]*models.StackRevision, 0)
 
-	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
+	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Preload("EnvGroups").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
 	stack_revisions.id IN (
 	  SELECT s2.id FROM (SELECT MAX(stack_revisions.id) id FROM stack_revisions WHERE stack_revisions.stack_id IN (?) GROUP BY stack_revisions.stack_id) s2
 	)
@@ -83,6 +83,7 @@ func (repo *StackRepository) ReadStackByID(projectID, stackID uint) (*models.Sta
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.id = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
@@ -100,6 +101,7 @@ func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string)
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
@@ -127,7 +129,7 @@ func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision)
 func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 
-	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Preload("EnvGroups").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
 		return nil, err
 	}
 
@@ -137,7 +139,7 @@ func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.St
 func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 
-	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Preload("EnvGroups").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
 		return nil, err
 	}
 
@@ -181,3 +183,13 @@ func (repo *StackRepository) UpdateStackResource(resource *models.StackResource)
 
 	return resource, nil
 }
+
+func (repo *StackRepository) ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error) {
+	envGroup := &models.StackEnvGroup{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND namespace = ? AND name = ?", projectID, clusterID, namespace, name).Order("id desc").First(&envGroup).Error; err != nil {
+		return nil, err
+	}
+
+	return envGroup, nil
+}

+ 2 - 0
internal/repository/stack.go

@@ -17,4 +17,6 @@ type StackRepository interface {
 
 	ReadStackResource(resourceID uint) (*models.StackResource, error)
 	UpdateStackResource(resource *models.StackResource) (*models.StackResource, error)
+
+	ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error)
 }

+ 4 - 0
internal/repository/test/stack.go

@@ -58,3 +58,7 @@ func (repo *StackRepository) ReadStackResource(resourceID uint) (*models.StackRe
 func (repo *StackRepository) UpdateStackResource(resource *models.StackResource) (*models.StackResource, error) {
 	panic("unimplemented")
 }
+
+func (repo *StackRepository) ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error) {
+	panic("unimplemented")
+}

+ 23 - 0
internal/stacks/helpers.go

@@ -76,3 +76,26 @@ func CloneAppResources(
 
 	return res, nil
 }
+
+func CloneEnvGroups(envGroups []models.StackEnvGroup) ([]models.StackEnvGroup, error) {
+	res := make([]models.StackEnvGroup, 0)
+
+	for _, envGroup := range envGroups {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackEnvGroup{
+			UID:             uid,
+			Name:            envGroup.Name,
+			EnvGroupVersion: envGroup.EnvGroupVersion,
+			Namespace:       envGroup.Namespace,
+			ProjectID:       envGroup.ProjectID,
+			ClusterID:       envGroup.ClusterID,
+		})
+	}
+
+	return res, nil
+}

+ 121 - 0
internal/stacks/hooks.go

@@ -1,9 +1,11 @@
 package stacks
 
 import (
+	"errors"
 	"fmt"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -65,12 +67,131 @@ func UpdateHelmRevision(config *config.Config, projID, clusterID uint, rel *rele
 		}
 	}
 
+	clonedEnvGroups, err := CloneEnvGroups(stackRevision.EnvGroups)
+
+	if err != nil {
+		return err
+	}
+
+	stackRevision.Model = gorm.Model{}
+	stackRevision.RevisionNumber++
+	stackRevision.Resources = clonedAppResources
+	stackRevision.SourceConfigs = clonedSourceConfigs
+	stackRevision.EnvGroups = clonedEnvGroups
+	stackRevision.Status = "deployed"
+	stackRevision.Reason = "ApplicationUpgrade"
+	stackRevision.Message = fmt.Sprintf("The application %s was updated from version %d to %d", rel.Name, rel.Version-1, rel.Version)
+
+	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)
+
+	return err
+}
+
+func UpdateEnvGroupVersion(config *config.Config, projID, clusterID uint, envGroup *types.EnvGroup) error {
+	// read stack env group by params
+	stackEnvGroup, err := config.Repo.Stack().ReadStackEnvGroupFirstMatch(projID, clusterID, envGroup.Namespace, envGroup.Name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil
+		}
+
+		return err
+	}
+
+	// read the revision number corresponding and create a new revision of the stack
+	oldStackRevision, err := config.Repo.Stack().ReadStackRevision(stackEnvGroup.StackRevisionID)
+
+	if err != nil {
+		return err
+	}
+
+	// get the latest revision for that stack
+	stack, err := config.Repo.Stack().ReadStackByID(projID, oldStackRevision.StackID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(stack.Revisions) == 0 {
+		return fmt.Errorf("length of stack revision list was 0")
+	}
+
+	currStackRevision := stack.Revisions[0]
+	stackRevision := &currStackRevision
+
+	clonedSourceConfigs, err := CloneSourceConfigs(stackRevision.SourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedAppResources, err := CloneAppResources(stackRevision.Resources, stackRevision.SourceConfigs, clonedSourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedEnvGroups, err := CloneEnvGroups(stackRevision.EnvGroups)
+
+	if err != nil {
+		return err
+	}
+
+	for i, clonedEnvGroup := range clonedEnvGroups {
+		if clonedEnvGroup.Name == envGroup.Name {
+			clonedEnvGroups[i].EnvGroupVersion = envGroup.Version
+		}
+	}
+
+	// find all synced apps which should have an updated revision number
+	for i, clonedAppResource := range clonedAppResources {
+		for _, appName := range envGroup.Applications {
+			if clonedAppResource.Name == appName {
+				clonedAppResources[i].HelmRevisionID = clonedAppResource.HelmRevisionID + 1
+			}
+		}
+	}
+
 	stackRevision.Model = gorm.Model{}
 	stackRevision.RevisionNumber++
 	stackRevision.Resources = clonedAppResources
 	stackRevision.SourceConfigs = clonedSourceConfigs
+	stackRevision.EnvGroups = clonedEnvGroups
+	stackRevision.Reason = "EnvGroupUpgrade"
+	stackRevision.Message = fmt.Sprintf("The environment group %s was updated from version %d to %d", envGroup.Name, envGroup.Version-1, envGroup.Version)
+	stackRevision.Status = "deployed"
 
 	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)
 
 	return err
 }
+
+func GetStackForEnvGroup(config *config.Config, projID, clusterID uint, envGroup *types.EnvGroup) (string, error) {
+	// read stack env group by params
+	stackEnvGroup, err := config.Repo.Stack().ReadStackEnvGroupFirstMatch(projID, clusterID, envGroup.Namespace, envGroup.Name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return "", nil
+		}
+
+		return "", err
+	}
+
+	// read the revision number corresponding and create a new revision of the stack
+	oldStackRevision, err := config.Repo.Stack().ReadStackRevision(stackEnvGroup.StackRevisionID)
+
+	if err != nil {
+		return "", err
+	}
+
+	// get the latest revision for that stack
+	stack, err := config.Repo.Stack().ReadStackByID(projID, oldStackRevision.StackID)
+
+	if err != nil {
+		return "", err
+	}
+
+	return stack.UID, nil
+}