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

Merge branch 'nico/por-559-support-environment-group-creation-from' of github.com:porter-dev/porter into dev

jnfrati 3 лет назад
Родитель
Сommit
efef60e768
27 измененных файлов с 851 добавлено и 126 удалено
  1. 14 0
      api/server/handlers/namespace/create_env_group.go
  2. 121 43
      api/server/handlers/stack/create.go
  3. 36 0
      api/server/handlers/stack/list_revisions.go
  4. 12 1
      api/server/handlers/stack/rollback.go
  5. 4 1
      api/server/handlers/stack/update_source_put.go
  6. 57 5
      api/server/router/v1/stack.go
  7. 54 1
      api/types/stacks.go
  8. 6 0
      dashboard/src/components/porter-form/PorterForm.tsx
  9. 4 1
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  10. 24 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  11. 10 0
      dashboard/src/components/porter-form/types.ts
  12. 56 26
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  13. 104 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx
  14. 42 1
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  15. 50 6
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  16. 5 31
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx
  17. 27 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/EnvGroupCard.tsx
  18. 39 6
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  19. 4 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx
  20. 11 0
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  21. 8 0
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  22. 39 0
      internal/models/stack.go
  23. 1 0
      internal/repository/gorm/migrate.go
  24. 15 3
      internal/repository/gorm/stack.go
  25. 2 0
      internal/repository/stack.go
  26. 23 0
      internal/stacks/helpers.go
  27. 83 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/helm"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
 )
 )
 
 
 type CreateEnvGroupHandler struct {
 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, ","))))
 		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf(strings.Join(errStrArr, ","))))
 		return
 		return
 	}
 	}
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }
 }
 
 
 func rolloutApplications(
 func rolloutApplications(
@@ -367,3 +375,9 @@ func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]inte
 
 
 	return res, nil
 	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)
+}

+ 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/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 
 
 	helmrelease "helm.sh/helm/v3/pkg/release"
 	helmrelease "helm.sh/helm/v3/pkg/release"
@@ -73,6 +74,13 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 		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
 	// write stack to the database with creating status
 	stack := &models.Stack{
 	stack := &models.Stack{
 		ProjectID: proj.ID,
 		ProjectID: proj.ID,
@@ -86,6 +94,7 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				Status:         string(types.StackRevisionStatusDeploying),
 				Status:         string(types.StackRevisionStatusDeploying),
 				SourceConfigs:  sourceConfigs,
 				SourceConfigs:  sourceConfigs,
 				Resources:      resources,
 				Resources:      resources,
+				EnvGroups:      envGroups,
 			},
 			},
 		},
 		},
 	}
 	}
@@ -97,76 +106,98 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 		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 {
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		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]
 	revision := &stack.Revisions[0]
 
 
-	if len(deployErrs) > 0 {
+	if len(envGroupDeployErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		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 {
 	} 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 {
 			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)
 		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))
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 			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
 	// read the stack again to get the latest revision info
@@ -248,3 +303,26 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 
 
 	return res, nil
 	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
+}

+ 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
 package stack
 
 
 import (
 import (
+	"fmt"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 
 
@@ -86,8 +87,16 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	revision.SourceConfigs = newSourceConfigs
 	revision.SourceConfigs = newSourceConfigs
 	revision.Resources = appResources
 	revision.Resources = appResources
+	revision.EnvGroups = envGroups
 
 
 	revision, err = p.Repo().Stack().AppendNewRevision(revision)
 	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 {
 	if len(rollbackErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "RollbackError"
 		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 {
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
 		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)
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

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

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

+ 57 - 5
api/server/router/v1/stack.go

@@ -58,11 +58,11 @@ type stackRevisionPathParams struct {
 	// required: true
 	// required: true
 	StackID string `json:"stack_id"`
 	StackID string `json:"stack_id"`
 
 
-	// The stack revision number
+	// The stack revision id
 	// in: path
 	// in: path
 	// required: true
 	// required: true
 	// minimum: 1
 	// minimum: 1
-	StackRevisionNumber string `json:"stack_revision_number"`
+	RevisionID string `json:"revision_id"`
 }
 }
 
 
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
@@ -267,8 +267,60 @@ func getV1StackRoutes(
 		Router:   r,
 		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
 	// Gets a stack revision
 	//
 	//
@@ -283,7 +335,7 @@ func getV1StackRoutes(
 	//   - name: cluster_id
 	//   - name: cluster_id
 	//   - name: namespace
 	//   - name: namespace
 	//   - name: stack_id
 	//   - name: stack_id
-	//   - name: stack_revision_number
+	//   - name: revision_id
 	// responses:
 	// responses:
 	//   '200':
 	//   '200':
 	//     description: Successfully got the stack revision
 	//     description: Successfully got the stack revision

+ 54 - 1
api/types/stacks.go

@@ -17,6 +17,9 @@ type CreateStackRequest struct {
 	// registry or linked to a remote Git repository.
 	// registry or linked to a remote Git repository.
 	// required: true
 	// required: true
 	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
 	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
 // swagger:model
@@ -84,6 +87,9 @@ type Stack struct {
 	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
 	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
 }
 }
 
 
+// swagger:model
+type ListStackRevisionsResponse []StackRevision
+
 // swagger:model
 // swagger:model
 type StackListResponse []Stack
 type StackListResponse []Stack
 
 
@@ -109,7 +115,7 @@ type StackResource struct {
 	// If this is an app resource, app-specific information for the resource
 	// If this is an app resource, app-specific information for the resource
 	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
 	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"`
 	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
 }
 }
 
 
@@ -158,7 +164,34 @@ type StackRevision struct {
 	// The list of resources deployed in this revision
 	// The list of resources deployed in this revision
 	Resources []StackResource `json:"resources"`
 	Resources []StackResource `json:"resources"`
 
 
+	// The list of source configs deployed in this revision
 	SourceConfigs []StackSourceConfig `json:"source_configs"`
 	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 {
 type StackSourceConfig struct {
@@ -190,6 +223,26 @@ type StackSourceConfig struct {
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 	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
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 type CreateStackSourceConfigRequest struct {
 	// required: true
 	// required: true

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

@@ -4,6 +4,7 @@ import {
   CheckboxField,
   CheckboxField,
   CronField,
   CronField,
   FormField,
   FormField,
+  InjectedProps,
   InputField,
   InputField,
   KeyValueArrayField,
   KeyValueArrayField,
   ResourceListField,
   ResourceListField,
@@ -49,6 +50,7 @@ interface Props {
   hideSpacer?: boolean;
   hideSpacer?: boolean;
   // The tab to redirect to after saving the form
   // The tab to redirect to after saving the form
   redirectTabAfterSave?: string;
   redirectTabAfterSave?: string;
+  injectedProps?: InjectedProps;
 }
 }
 
 
 const PorterForm: React.FC<Props> = (props) => {
 const PorterForm: React.FC<Props> = (props) => {
@@ -63,10 +65,14 @@ const PorterForm: React.FC<Props> = (props) => {
   const { currentTab, setCurrentTab } = props;
   const { currentTab, setCurrentTab } = props;
 
 
   const renderSectionField = (field: FormField): JSX.Element => {
   const renderSectionField = (field: FormField): JSX.Element => {
+    const injected = props.injectedProps?.[field.type];
+
     const bundledProps = {
     const bundledProps = {
       ...field,
       ...field,
       isReadOnly,
       isReadOnly,
+      injectedProps: injected ?? {},
     };
     };
+
     switch (field.type) {
     switch (field.type) {
       case "heading":
       case "heading":
         return <Heading>{field.label}</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 React, { useState } from "react";
 
 
 import PorterForm from "./PorterForm";
 import PorterForm from "./PorterForm";
-import { PorterFormData } from "./types";
+import { InjectedProps, PorterFormData } from "./types";
 import { PorterFormContextProvider } from "./PorterFormContextProvider";
 import { PorterFormContextProvider } from "./PorterFormContextProvider";
 
 
 type PropsType = {
 type PropsType = {
@@ -23,6 +23,7 @@ type PropsType = {
   hideBottomSpacer?: boolean;
   hideBottomSpacer?: boolean;
   redirectTabAfterSave?: string;
   redirectTabAfterSave?: string;
   includeMetadata?: boolean;
   includeMetadata?: boolean;
+  injectedProps?: InjectedProps;
 };
 };
 
 
 const PorterFormWrapper: React.FC<PropsType> = ({
 const PorterFormWrapper: React.FC<PropsType> = ({
@@ -44,6 +45,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
   hideBottomSpacer,
   hideBottomSpacer,
   redirectTabAfterSave,
   redirectTabAfterSave,
   includeMetadata,
   includeMetadata,
+  injectedProps,
 }) => {
 }) => {
   const hashCode = (s: string) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
     return s?.split("").reduce(function (a, b) {
@@ -99,6 +101,7 @@ const PorterFormWrapper: React.FC<PropsType> = ({
           isLaunch={isLaunch}
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}
           hideSpacer={hideBottomSpacer}
           redirectTabAfterSave={redirectTabAfterSave}
           redirectTabAfterSave={redirectTabAfterSave}
+          injectedProps={injectedProps}
         />
         />
       </PorterFormContextProvider>
       </PorterFormContextProvider>
     </React.Fragment>
     </React.Fragment>

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

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

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

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

+ 56 - 26
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -19,7 +19,9 @@ import { hardcodedIcons } from "shared/hardcodedNameDict";
 const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
 const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
 
 
 const NewApp = () => {
 const NewApp = () => {
-  const { addAppResource, newStack } = useContext(StacksLaunchContext);
+  const { addAppResource, newStack, namespace } = useContext(
+    StacksLaunchContext
+  );
   const { currentCluster } = useContext(Context);
   const { currentCluster } = useContext(Context);
 
 
   const params = useParams<{
   const params = useParams<{
@@ -72,15 +74,30 @@ const NewApp = () => {
   }, [params]);
   }, [params]);
 
 
   if (isLoading) {
   if (isLoading) {
-    return <Wrapper><Loading /></Wrapper>;
+    return (
+      <Wrapper>
+        <Loading />
+      </Wrapper>
+    );
   }
   }
 
 
   if (hasError) {
   if (hasError) {
     return <>Unexpected error</>;
     return <>Unexpected error</>;
   }
   }
 
 
-  const handleSubmit = async (rawValues: any) => {
+  const handleSubmit = async ({
+    values: rawValues,
+    metadata,
+  }: {
+    values: any;
+    metadata: any;
+  }) => {
     setSaveButtonStatus("loading");
     setSaveButtonStatus("loading");
+    // console.log(metadata);
+    const syncedEnvGroups = metadata["container.env"]?.added?.map(
+      ({ name }: { name: string }) => name
+    );
+    // return;
 
 
     // Convert dotted keys to nested objects
     // Convert dotted keys to nested objects
     let values: any = {};
     let values: any = {};
@@ -165,13 +182,16 @@ const NewApp = () => {
       return;
       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");
     setSaveButtonStatus("successful");
     setTimeout(() => {
     setTimeout(() => {
@@ -185,17 +205,20 @@ const NewApp = () => {
       <TitleSection>
       <TitleSection>
         <DynamicLink to={`/stacks/launch/overview`}>
         <DynamicLink to={`/stacks/launch/overview`}>
           <BackButton>
           <BackButton>
-            <i className="material-icons">
-              keyboard_backspace
-            </i>
+            <i className="material-icons">keyboard_backspace</i>
           </BackButton>
           </BackButton>
         </DynamicLink>
         </DynamicLink>
         <Polymer>
         <Polymer>
-        <Icon src={hardcodedIcons[template.metadata.name]} />
+          <Icon src={hardcodedIcons[template.metadata.name]} />
         </Polymer>
         </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>
       </TitleSection>
-      <Heading>Application Name <Required>*</Required></Heading>
+      <Heading>
+        Application Name <Required>*</Required>
+      </Heading>
       <InputRow
       <InputRow
         type="string"
         type="string"
         value={appName}
         value={appName}
@@ -205,15 +228,22 @@ const NewApp = () => {
       />
       />
 
 
       <div style={{ position: "relative" }}>
       <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 as any,
+            },
+          }}
+          includeMetadata
+        />
       </div>
       </div>
     </StyledLaunchFlow>
     </StyledLaunchFlow>
   );
   );
@@ -283,4 +313,4 @@ const StyledLaunchFlow = styled.div`
   margin-top: ${(props: { disableMarginTop?: boolean }) =>
   margin-top: ${(props: { disableMarginTop?: boolean }) =>
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
   padding-bottom: 150px;
   padding-bottom: 150px;
-`;
+`;

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

@@ -0,0 +1,104 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import React, { useContext, 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 { SubmitButton } from "./components/styles";
+import { StacksLaunchContext } from "./Store";
+
+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 isDisabled = () =>
+    !isAlphanumeric(name) || name === "" || !envVariables.length;
+
+  const handleOnSubmit = () => {
+    const variables = envVariables.filter((variable) => !variable.locked);
+    const secret_variables = envVariables.filter((variable) => variable.locked);
+
+    addEnvGroup({
+      name,
+      variables: envArrayToObject(variables),
+      secret_variables: envArrayToObject(secret_variables),
+      linked_applications: [],
+    });
+    setName("");
+    setEnvVariables([]);
+    pushFiltered("/stacks/launch/overview", []);
+    return;
+  };
+
+  return (
+    <>
+      <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={isDisabled()}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroup;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  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")};
+`;

+ 42 - 1
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -6,7 +6,11 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import { useRouting } from "shared/routing";
 import { useRouting } from "shared/routing";
-import { CardGrid, SubmitButton } from "./components/styles";
+import {
+  AddResourceButtonStyles,
+  CardGrid,
+  SubmitButton,
+} from "./components/styles";
 import { AppCard } from "./components/AppCard";
 import { AppCard } from "./components/AppCard";
 import { AddResourceButton } from "./components/AddResourceButton";
 import { AddResourceButton } from "./components/AddResourceButton";
 import styled from "styled-components";
 import styled from "styled-components";
@@ -14,6 +18,8 @@ import styled from "styled-components";
 import Helper from "components/form-components/Helper";
 import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
+import DynamicLink from "components/DynamicLink";
+import EnvGroupCard from "./components/EnvGroupCard";
 
 
 const Overview = () => {
 const Overview = () => {
   const {
   const {
@@ -156,6 +162,23 @@ const Overview = () => {
         <AddResourceButton />
         <AddResourceButton />
       </CardGrid>
       </CardGrid>
 
 
+      <Heading>Env groups</Heading>
+      <CardGrid>
+        {newStack.env_groups.map((envGroup) => (
+          <EnvGroupCard key={envGroup.name} envGroup={envGroup} />
+        ))}
+
+        <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>
+      </CardGrid>
+
       <SubmitButton
       <SubmitButton
         disabled={!isValid || submitButtonStatus !== ""}
         disabled={!isValid || submitButtonStatus !== ""}
         text="Create Stack"
         text="Create Stack"
@@ -229,3 +252,21 @@ const StyledLaunchFlow = styled.div`
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
     props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
   padding-bottom: 150px;
   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;
+  }
+`;

+ 50 - 6
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -15,10 +15,15 @@ export type StacksLaunchContextType = {
     sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
     sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
   ) => void;
   ) => void;
 
 
-  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+  addAppResource: (
+    appResource: CreateStackBody["app_resources"][0],
+    syncedEnvGroups: string[]
+  ) => void;
 
 
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
 
 
+  addEnvGroup: (envGroup: CreateStackBody["env_groups"][0]) => void;
+
   submit: () => Promise<void>;
   submit: () => Promise<void>;
 };
 };
 
 
@@ -27,6 +32,7 @@ const defaultValues: StacksLaunchContextType = {
     name: "",
     name: "",
     app_resources: [],
     app_resources: [],
     source_configs: [],
     source_configs: [],
+    env_groups: [],
   },
   },
 
 
   namespace: "",
   namespace: "",
@@ -42,6 +48,8 @@ const defaultValues: StacksLaunchContextType = {
 
 
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
   removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
 
 
+  addEnvGroup: () => {},
+
   submit: async () => {},
   submit: async () => {},
 };
 };
 
 
@@ -92,12 +100,40 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
   };
   };
 
 
   const addAppResource: StacksLaunchContextType["addAppResource"] = (
   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"] = (
   const removeAppResource: StacksLaunchContextType["removeAppResource"] = (
@@ -111,6 +147,13 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
     }));
     }));
   };
   };
 
 
+  const addEnvGroup: StacksLaunchContextType["addEnvGroup"] = (envGroup) => {
+    setNewStack((prev) => ({
+      ...prev,
+      env_groups: [...prev.env_groups, envGroup],
+    }));
+  };
+
   const submit: StacksLaunchContextType["submit"] = async () => {
   const submit: StacksLaunchContextType["submit"] = async () => {
     try {
     try {
       await api.createStack("<token>", newStack, {
       await api.createStack("<token>", newStack, {
@@ -134,6 +177,7 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
         addSourceConfig,
         addSourceConfig,
         addAppResource,
         addAppResource,
         removeAppResource,
         removeAppResource,
+        addEnvGroup,
         submit,
         submit,
       }}
       }}
     >
     >

+ 5 - 31
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx

@@ -1,10 +1,8 @@
 import React, { useContext } from "react";
 import React, { useContext } from "react";
 import { StacksLaunchContext, StacksLaunchContextType } from "../Store";
 import { StacksLaunchContext, StacksLaunchContextType } from "../Store";
-import { ButtonWithIcon, Card } from "./styles";
+import { ButtonWithIcon, Card, Flex, Icon } from "./styles";
 import { hardcodedIcons } from "shared/hardcodedNameDict";
 import { hardcodedIcons } from "shared/hardcodedNameDict";
 
 
-import styled from "styled-components";
-
 export const AppCard = ({
 export const AppCard = ({
   app,
   app,
 }: {
 }: {
@@ -17,38 +15,14 @@ export const AppCard = ({
   };
   };
 
 
   return (
   return (
-    <UnclickableCard>
+    <Card variant="unclickable">
       <Flex>
       <Flex>
         <Icon src={hardcodedIcons[app.template_name]} />
         <Icon src={hardcodedIcons[app.template_name]} />
         {app.name}
         {app.name}
       </Flex>
       </Flex>
-      <DeleteButton onClick={handleDelete}>
+      <ButtonWithIcon variant="delete" onClick={handleDelete}>
         <i className="material-icons-outlined">close</i>
         <i className="material-icons-outlined">close</i>
-      </DeleteButton>
-    </UnclickableCard>
+      </ButtonWithIcon>
+    </Card>
   );
   );
 };
 };
-
-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;
-`;

+ 27 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/EnvGroupCard.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { ButtonWithIcon, Card, Flex, Icon } from "./styles";
+
+const EnvGroupCard = ({
+  envGroup: { name },
+}: {
+  envGroup: { name: string };
+}) => {
+  const handleDelete = () => {
+    console.error("NOT IMPLEMENTED");
+  };
+
+  return (
+    <Card variant="unclickable">
+      <Flex>
+        <Icon src={""} />
+        {name}
+      </Flex>
+      <ButtonWithIcon variant="delete" onClick={handleDelete}>
+        <i className="material-icons-outlined">close</i>
+      </ButtonWithIcon>
+    </Card>
+  );
+};
+
+export default EnvGroupCard;

+ 39 - 6
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -8,22 +8,36 @@ export const CardGrid = styled.div`
   grid-row-gap: 25px;
   grid-row-gap: 25px;
 `;
 `;
 
 
-export const Card = styled.div`
+export const Card = styled.div<{ variant?: "clickable" | "unclickable" }>`
   display: flex;
   display: flex;
   color: #ffffff;
   color: #ffffff;
   background: #2b2e3699;
   background: #2b2e3699;
   justify-content: space-between;
   justify-content: space-between;
   border-radius: 5px;
   border-radius: 5px;
-  cursor: pointer;
   height: 75px;
   height: 75px;
   padding: 12px;
   padding: 12px;
   padding-left: 14px;
   padding-left: 14px;
   border: 1px solid #ffffff0f;
   border: 1px solid #ffffff0f;
   align-items: center;
   align-items: center;
 
 
-  :hover {
-    border: 1px solid #ffffff3c;
-  }
+  ${(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;
   animation: fadeIn 0.5s;
   @keyframes fadeIn {
   @keyframes fadeIn {
     from {
     from {
@@ -127,7 +141,7 @@ export const SelectorStyles = {
   `,
   `,
 };
 };
 
 
-export const ButtonWithIcon = styled.div`
+export const ButtonWithIcon = styled.div<{ variant?: "normal" | "delete" }>`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
@@ -138,6 +152,12 @@ export const ButtonWithIcon = styled.div`
   border: 1px solid #ffffff22;
   border: 1px solid #ffffff22;
   cursor: pointer;
   cursor: pointer;
 
 
+  ${({ variant }) => {
+    if (variant === "delete") {
+      return "margin-right: 5px;";
+    }
+  }}
+
   &:hover {
   &:hover {
     background-color: #ffffff3c;
     background-color: #ffffff3c;
   }
   }
@@ -146,3 +166,16 @@ export const ButtonWithIcon = styled.div`
     font-size: 18px;
     font-size: 18px;
   }
   }
 `;
 `;
+
+export const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+export const Icon = styled.img`
+  height: 30px;
+  margin-right: 15px;
+  margin-left: 5px;
+`;

+ 4 - 0
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 { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import styled from "styled-components";
 import styled from "styled-components";
 import NewApp from "./NewApp";
 import NewApp from "./NewApp";
+import NewEnvGroup from "./NewEnvGroup";
 import Overview from "./Overview";
 import Overview from "./Overview";
 import SelectSource from "./SelectSource";
 import SelectSource from "./SelectSource";
 import StacksLaunchContextProvider from "./Store";
 import StacksLaunchContextProvider from "./Store";
@@ -22,6 +23,9 @@ const LaunchRoutes = () => {
           <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
           <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
             <NewApp />
             <NewApp />
           </Route>
           </Route>
+          <Route path={`${path}/new-env-group`}>
+            <NewEnvGroup />
+          </Route>
           <Route path={`*`}>
           <Route path={`*`}>
             <Redirect to={`${path}/source`} />
             <Redirect to={`${path}/source`} />
           </Route>
           </Route>

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

@@ -20,6 +20,17 @@ export type CreateStackBody = {
       dockerfile?: unknown;
       dockerfile?: unknown;
     };
     };
   }[];
   }[];
+
+  env_groups: {
+    name: string;
+    variables: {
+      [key: string]: string;
+    };
+    secret_variables: {
+      [key: string]: string;
+    };
+    linked_applications: string[];
+  }[];
 };
 };
 
 
 export type CreateStackResponse = Stack;
 export type CreateStackResponse = Stack;

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

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

+ 39 - 0
internal/models/stack.go

@@ -62,6 +62,8 @@ type StackRevision struct {
 	Resources []StackResource
 	Resources []StackResource
 
 
 	SourceConfigs []StackSourceConfig
 	SourceConfigs []StackSourceConfig
+
+	EnvGroups []StackEnvGroup
 }
 }
 
 
 func (s StackRevision) ToStackRevisionMetaType(stackID string) types.StackRevisionMeta {
 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))
 		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{
 	return &types.StackRevision{
 		StackRevisionMeta: &metaType,
 		StackRevisionMeta: &metaType,
 		SourceConfigs:     sourceConfigs,
 		SourceConfigs:     sourceConfigs,
 		Resources:         resources,
 		Resources:         resources,
+		EnvGroups:         envGroups,
 		Reason:            s.Reason,
 		Reason:            s.Reason,
 		Message:           s.Message,
 		Message:           s.Message,
 	}
 	}
@@ -176,3 +185,33 @@ func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevision
 		ImageTag:        s.ImageTag,
 		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.StackRevision{},
 		&models.StackResource{},
 		&models.StackResource{},
 		&models.StackSourceConfig{},
 		&models.StackSourceConfig{},
+		&models.StackEnvGroup{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&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
 	// query for each stack's revision
 	revisions := make([]*models.StackRevision, 0)
 	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 (
 	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
 	  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.Resources").
 		Preload("Revisions.SourceConfigs").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.id = ?", projectID, stackID).First(&stack).Error; err != nil {
 		Where("stacks.project_id = ? AND stacks.id = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -100,6 +101,7 @@ func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string)
 		}).
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
 		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -127,7 +129,7 @@ func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision)
 func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
 func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 	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
 		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) {
 func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 	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
 		return nil, err
 	}
 	}
 
 
@@ -181,3 +183,13 @@ func (repo *StackRepository) UpdateStackResource(resource *models.StackResource)
 
 
 	return resource, nil
 	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)
 	ReadStackResource(resourceID uint) (*models.StackResource, error)
 	UpdateStackResource(resource *models.StackResource) (*models.StackResource, error)
 	UpdateStackResource(resource *models.StackResource) (*models.StackResource, error)
+
+	ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error)
 }
 }

+ 23 - 0
internal/stacks/helpers.go

@@ -76,3 +76,26 @@ func CloneAppResources(
 
 
 	return res, nil
 	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
+}

+ 83 - 0
internal/stacks/hooks.go

@@ -1,9 +1,11 @@
 package stacks
 package stacks
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 
 
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 	"helm.sh/helm/v3/pkg/release"
 	"helm.sh/helm/v3/pkg/release"
 )
 )
@@ -65,10 +67,91 @@ 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 _, clonedEnvGroup := range clonedEnvGroups {
+		if clonedEnvGroup.Name == envGroup.Name {
+			clonedEnvGroup.EnvGroupVersion = envGroup.Version
+		}
+	}
+
 	stackRevision.Model = gorm.Model{}
 	stackRevision.Model = gorm.Model{}
 	stackRevision.RevisionNumber++
 	stackRevision.RevisionNumber++
 	stackRevision.Resources = clonedAppResources
 	stackRevision.Resources = clonedAppResources
 	stackRevision.SourceConfigs = clonedSourceConfigs
 	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)
 	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)