sdess09 2 роки тому
батько
коміт
51c46ef438
31 змінених файлів з 2270 додано та 690 видалено
  1. 113 0
      api/server/handlers/environment_groups/create.go
  2. 71 0
      api/server/handlers/environment_groups/delete.go
  3. 124 0
      api/server/handlers/environment_groups/list.go
  4. 3 0
      api/server/handlers/porter_app/create.go
  5. 98 5
      api/server/handlers/porter_app/parse.go
  6. 1 0
      api/server/handlers/porter_app/rollback.go
  7. 20 9
      api/server/handlers/release/get.go
  8. 88 0
      api/server/router/cluster.go
  9. 4 2
      api/types/porter_app.go
  10. 13 0
      dashboard/src/components/porter-form/types.ts
  11. 24 104
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  12. 106 107
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvGroupModal.tsx
  13. 56 133
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx
  14. 39 39
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/ExpandableEnvGroup.tsx
  15. 50 76
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  16. 67 4
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  17. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  18. 62 10
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  19. 5 4
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks.tsx
  20. 46 29
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  21. 549 150
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  22. 24 13
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroupDashboard.tsx
  23. 40 0
      dashboard/src/shared/api.tsx
  24. 6 0
      go.work.sum
  25. 6 2
      internal/helm/agent.go
  26. 183 0
      internal/kubernetes/environment_groups/create.go
  27. 72 0
      internal/kubernetes/environment_groups/delete.go
  28. 88 0
      internal/kubernetes/environment_groups/get.go
  29. 228 0
      internal/kubernetes/environment_groups/list.go
  30. 78 0
      internal/kubernetes/environment_groups/sync.go
  31. 5 2
      zarf/helm/.serverenv

+ 113 - 0
api/server/handlers/environment_groups/create.go

@@ -0,0 +1,113 @@
+package environment_groups
+
+import (
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type UpdateEnvironmentGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateEnvironmentGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateEnvironmentGroupHandler {
+	return &UpdateEnvironmentGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type UpdateEnvironmentGroupRequest struct {
+	// Name of the env group to create or update
+	Name string `json:"name"`
+
+	// Variables are values which are not sensitive. All values must be a string due to a kubernetes limitation.
+	Variables map[string]string `json:"variables"`
+
+	// SecretVariables are sensitive values. All values must be a string due to a kubernetes limitation.
+	SecretVariables map[string]string `json:"secret_variables"`
+}
+type UpdateEnvironmentGroupResponse struct {
+	// Name of the env group to create or update
+	Name string `json:"name"`
+
+	// Variables are variables which should are not sensitive. All values must be a string due to a kubernetes limitation.
+	Variables map[string]string `json:"variables,omitempty"`
+
+	// SecretVariables are sensitive variables. All values must be a string due to a kubernetes limitation.
+	SecretVariables map[string]string `json:"secret_variables,omitempty"`
+
+	CreatedAt time.Time `json:"created_at"`
+}
+
+func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-env-group")
+	defer span.End()
+
+	request := &UpdateEnvironmentGroupRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
+	)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	secrets := make(map[string][]byte)
+	for k, v := range request.SecretVariables {
+		secrets[k] = []byte(v)
+	}
+
+	envGroup := environment_groups.EnvironmentGroup{
+		Name:            request.Name,
+		Variables:       request.Variables,
+		SecretVariables: secrets,
+		CreatedAtUTC:    time.Now().UTC(),
+	}
+
+	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to create or update environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	envGroupResponse := &UpdateEnvironmentGroupResponse{
+		Name:      envGroup.Name,
+		CreatedAt: envGroup.CreatedAtUTC,
+	}
+	c.WriteResult(w, r, envGroupResponse)
+
+	// TODO: Syncing applications that are linked is currently done by the frontend. This should be done entirely
+	// applicationsToSync, err := environment_groups.LinkedApplications(ctx, agent, envGroup.Name)
+	// if err != nil {
+	// 	err := telemetry.Error(ctx, span, err, "unable to find linked applications for environment group")
+	// 	c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+	// 	return
+	// }
+	// for _, app := range applicationsToSync {
+	// 	TODO: Call porter app update
+	// }
+}

+ 71 - 0
api/server/handlers/environment_groups/delete.go

@@ -0,0 +1,71 @@
+package environment_groups
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type DeleteEnvironmentGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteEnvironmentGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteEnvironmentGroupHandler {
+	return &DeleteEnvironmentGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type DeleteEnvironmentGroupRequest struct {
+	// Name of the env group to delete
+	Name string `json:"name"`
+}
+
+func (c *DeleteEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-env-group")
+	defer span.End()
+
+	request := &DeleteEnvironmentGroupRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if request.Name == "" {
+		err := telemetry.Error(ctx, span, nil, "environment group name is required for deletion")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group-name", Value: request.Name},
+	)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	err = environment_groups.DeleteEnvironmentGroup(ctx, agent, request.Name)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to delete environment group")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 124 - 0
api/server/handlers/environment_groups/list.go

@@ -0,0 +1,124 @@
+package environment_groups
+
+import (
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	environmentgroups "github.com/porter-dev/porter/internal/kubernetes/environment_groups"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type ListEnvironmentGroupsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListEnvironmentGroupsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListEnvironmentGroupsHandler {
+	return &ListEnvironmentGroupsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type ListEnvironmentGroupsResponse struct {
+	EnvironmentGroups []EnvironmentGroupListItem `json:"environment_groups,omitempty"`
+}
+
+type EnvironmentGroupListItem struct {
+	Name               string            `json:"name"`
+	LatestVersion      int               `json:"latest_version"`
+	Variables          map[string]string `json:"variables"`
+	SecretVariables    map[string]string `json:"secret_variables"`
+	CreatedAtUTC       time.Time         `json:"created_at"`
+	LinkedApplications []string          `json:"linked_applications,omitempty"`
+}
+
+func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-env-groups")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to connect to cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusServiceUnavailable))
+		return
+	}
+
+	allEnvGroupVersions, err := environmentgroups.ListEnvironmentGroups(ctx, agent, environmentgroups.WithNamespace(environmentgroups.Namespace_EnvironmentGroups))
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to list all environment groups")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	envGroupSet := make(map[string]struct{})
+	for _, envGroup := range allEnvGroupVersions {
+		if envGroup.Name == "" {
+			continue
+		}
+		if _, ok := envGroupSet[envGroup.Name]; !ok {
+			envGroupSet[envGroup.Name] = struct{}{}
+		}
+	}
+
+	var envGroups []EnvironmentGroupListItem
+	for envGroupName := range envGroupSet {
+		latestVersion, err := environmentgroups.LatestBaseEnvironmentGroup(ctx, agent, envGroupName)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "unable to get latest environment groups")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		applications, err := environmentgroups.LinkedApplications(ctx, agent, latestVersion.Name)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "unable to get linked applications")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		applicationSetForEnvGroup := make(map[string]struct{})
+		for _, app := range applications {
+			if app.Namespace == "" {
+				continue
+			}
+			if _, ok := applicationSetForEnvGroup[app.Namespace]; !ok {
+				applicationSetForEnvGroup[app.Namespace] = struct{}{}
+			}
+		}
+		var linkedApplications []string
+		for appNamespace := range applicationSetForEnvGroup {
+			porterAppName := strings.TrimPrefix(appNamespace, "porter-stack-")
+			linkedApplications = append(linkedApplications, porterAppName)
+		}
+
+		secrets := make(map[string]string)
+		for k, v := range latestVersion.SecretVariables {
+			secrets[k] = string(v)
+		}
+		envGroups = append(envGroups, EnvironmentGroupListItem{
+			Name:               latestVersion.Name,
+			LatestVersion:      latestVersion.Version,
+			Variables:          latestVersion.Variables,
+			SecretVariables:    secrets,
+			CreatedAtUTC:       latestVersion.CreatedAtUTC,
+			LinkedApplications: linkedApplications,
+		})
+	}
+
+	c.WriteResult(w, r, ListEnvironmentGroupsResponse{EnvironmentGroups: envGroups})
+}

+ 3 - 0
api/server/handlers/porter_app/create.go

@@ -160,6 +160,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 
 	chart, values, preDeployJobValues, err := parse(
+		ctx,
 		ParseConf{
 			PorterYaml:                porterYaml,
 			ImageInfo:                 imageInfo,
@@ -167,6 +168,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			ProjectID:                 cluster.ProjectID,
 			UserUpdate:                request.UserUpdate,
 			EnvGroups:                 request.EnvGroups,
+			EnvironmentGroups:         request.EnvironmentGroups,
 			Namespace:                 namespace,
 			ExistingHelmValues:        releaseValues,
 			ExistingChartDependencies: releaseDependencies,
@@ -379,6 +381,7 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			Repo:       c.Repo(),
 			Registries: registries,
 		}
+
 		// update the chart
 		_, err = helmAgent.UpgradeInstallChart(ctx, conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 		if err != nil {

+ 98 - 5
api/server/handlers/porter_app/parse.go

@@ -1,6 +1,7 @@
 package porter_app
 
 import (
+	"context"
 	"fmt"
 	"strconv"
 	"strings"
@@ -11,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/domain"
+	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/stefanmcshane/helm/pkg/chart"
@@ -86,6 +88,8 @@ type ParseConf struct {
 	UserUpdate bool
 	// EnvGroups used for synced env groups
 	EnvGroups []string
+	// EnvironmentGroups are used for syncing environment groups using ConfigMaps and Secrets from porter-env-groups namespace. This should be used instead of EnvGroups
+	EnvironmentGroups []string
 	// Namespace used for synced env groups
 	Namespace string
 	// ExistingHelmValues is the existing values for the helm release, if it exists
@@ -104,7 +108,7 @@ type ParseConf struct {
 	AddCustomNodeSelector bool
 }
 
-func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]interface{}, error) {
+func parse(ctx context.Context, conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]interface{}, error) {
 	parsed := &PorterStackYAML{}
 
 	if conf.FullHelmValues != "" {
@@ -170,6 +174,31 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 		services = parsed.Services
 	}
 
+	for serviceName := range services {
+		if len(conf.EnvironmentGroups) != 0 {
+			if _, ok := services[serviceName].Config["labels"]; !ok {
+				services[serviceName].Config["labels"] = make(map[string]string)
+			}
+			if _, ok := services[serviceName].Config["labels"].(map[string]any); ok {
+				delete(services[serviceName].Config["labels"].(map[string]any), environment_groups.LabelKey_LinkedEnvironmentGroup)
+			}
+			switch services[serviceName].Config["labels"].(type) {
+			case map[string]any:
+				services[serviceName].Config["labels"].(map[string]any)[environment_groups.LabelKey_LinkedEnvironmentGroup] = strings.Join(conf.EnvironmentGroups, ".")
+			case map[string]string:
+				services[serviceName].Config["labels"].(map[string]string)[environment_groups.LabelKey_LinkedEnvironmentGroup] = strings.Join(conf.EnvironmentGroups, ".")
+			case any:
+				if val, ok := services[serviceName].Config["labels"].(string); ok {
+					if val == "" {
+						services[serviceName].Config["labels"] = map[string]string{
+							environment_groups.LabelKey_LinkedEnvironmentGroup: strings.Join(conf.EnvironmentGroups, "."),
+						}
+					}
+				}
+			}
+		}
+	}
+
 	application := &Application{
 		Env:      parsed.Env,
 		Services: services,
@@ -177,13 +206,14 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 		Release:  parsed.Release,
 	}
 
-	values, err := buildUmbrellaChartValues(application, synced_env, conf.ImageInfo, conf.ExistingHelmValues, conf.SubdomainCreateOpts, conf.InjectLauncherToStartCommand, conf.ShouldValidateHelmValues, conf.UserUpdate, conf.AddCustomNodeSelector)
+
+	values, err := buildUmbrellaChartValues(ctx, application, synced_env, conf.ImageInfo, conf.ExistingHelmValues, conf.SubdomainCreateOpts, conf.InjectLauncherToStartCommand, conf.ShouldValidateHelmValues, conf.UserUpdate, conf.Namespace,  conf.AddCustomNodeSelector)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error building values", err)
 	}
 	convertedValues := convertMap(values).(map[string]interface{})
 
-	chart, err := buildUmbrellaChart(application, conf.ServerConfig, conf.ProjectID, conf.ExistingChartDependencies)
+	umbrellaChart, err := buildUmbrellaChart(application, conf.ServerConfig, conf.ProjectID, conf.ExistingChartDependencies)
 	if err != nil {
 		return nil, nil, nil, fmt.Errorf("%s: %w", "error building chart", err)
 	}
@@ -194,10 +224,11 @@ func parse(conf ParseConf) (*chart.Chart, map[string]interface{}, map[string]int
 		preDeployJobValues = buildPreDeployJobChartValues(application.Release, application.Env, synced_env, conf.ImageInfo, conf.InjectLauncherToStartCommand, conf.ExistingHelmValues, strings.TrimSuffix(strings.TrimPrefix(conf.Namespace, "porter-stack-"), "")+"-r", conf.UserUpdate, conf.AddCustomNodeSelector)
 	}
 
-	return chart, convertedValues, preDeployJobValues, nil
+	return umbrellaChart, convertedValues, preDeployJobValues, nil
 }
 
 func buildUmbrellaChartValues(
+	ctx context.Context,
 	application *Application,
 	syncedEnv []*SyncedEnvSection,
 	imageInfo types.ImageInfo,
@@ -206,6 +237,7 @@ func buildUmbrellaChartValues(
 	injectLauncher bool,
 	shouldValidateHelmValues bool,
 	userUpdate bool,
+	namespace string,
 	addCustomNodeSelector bool,
 ) (map[string]interface{}, error) {
 	values := make(map[string]interface{})
@@ -237,7 +269,12 @@ func buildUmbrellaChartValues(
 			return nil, fmt.Errorf("error validating service \"%s\": %s", name, validateErr)
 		}
 
-		err := createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
+		err := syncEnvironmentGroupToNamespaceIfLabelsExist(ctx, opts.k8sAgent, service, namespace)
+		if err != nil {
+			return nil, fmt.Errorf("error syncing environment group to namespace: %w", err)
+		}
+
+		err = createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
 		if err != nil {
 			return nil, err
 		}
@@ -286,6 +323,59 @@ func buildUmbrellaChartValues(
 	return values, nil
 }
 
+// syncEnvironmentGroupToNamespaceIfLabelsExist will sync the latest version of the environment group to the target namespace if the service has the appropriate label.
+func syncEnvironmentGroupToNamespaceIfLabelsExist(ctx context.Context, agent *kubernetes.Agent, service *Service, targetNamespace string) error {
+	var linkedGroupNames string
+
+	// patchwork because we are not consistent with the type of labels
+	if labels, ok := service.Config["labels"].(map[string]any); ok {
+		if linkedGroup, ok := labels[environment_groups.LabelKey_LinkedEnvironmentGroup].(string); ok {
+			linkedGroupNames = linkedGroup
+		}
+	}
+	if labels, ok := service.Config["labels"].(map[string]string); ok {
+		if linkedGroup, ok := labels[environment_groups.LabelKey_LinkedEnvironmentGroup]; ok {
+			linkedGroupNames = linkedGroup
+		}
+	}
+
+	for _, linkedGroupName := range strings.Split(linkedGroupNames, ".") {
+		inp := environment_groups.SyncLatestVersionToNamespaceInput{
+			BaseEnvironmentGroupName: linkedGroupName,
+			TargetNamespace:          targetNamespace,
+		}
+
+		syncedEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, agent, inp)
+		if err != nil {
+			return fmt.Errorf("error syncing environment group: %w", err)
+		}
+		if syncedEnvironment.EnvironmentGroupVersionedName != "" {
+			if service.Config["configMapRefs"] == nil {
+				service.Config["configMapRefs"] = []string{}
+			}
+			if service.Config["secretRefs"] == nil {
+				service.Config["secretRefs"] = []string{}
+			}
+
+			switch service.Config["configMapRefs"].(type) {
+			case []string:
+				service.Config["configMapRefs"] = append(service.Config["configMapRefs"].([]string), syncedEnvironment.EnvironmentGroupVersionedName)
+			case []any:
+				service.Config["configMapRefs"] = append(service.Config["configMapRefs"].([]any), syncedEnvironment.EnvironmentGroupVersionedName)
+			}
+
+			switch service.Config["configMapRefs"].(type) {
+			case []string:
+				service.Config["secretRefs"] = append(service.Config["secretRefs"].([]string), syncedEnvironment.EnvironmentGroupVersionedName)
+			case []any:
+				service.Config["secretRefs"] = append(service.Config["secretRefs"].([]any), syncedEnvironment.EnvironmentGroupVersionedName)
+			}
+		}
+	}
+
+	return nil
+}
+
 // we can add to this function up later or use an alternative
 func validateHelmValues(values map[string]interface{}, shouldValidateHelmValues bool, appType string) string {
 	if shouldValidateHelmValues {
@@ -790,6 +880,7 @@ func convertHelmValuesToPorterYaml(helmValues string) (*PorterStackYAML, error)
 		return nil, err
 	}
 	services := make(map[string]*Service)
+
 	for k, v := range values {
 		if k == "global" {
 			continue
@@ -798,11 +889,13 @@ func convertHelmValuesToPorterYaml(helmValues string) (*PorterStackYAML, error)
 		if serviceName == "" {
 			return nil, fmt.Errorf("invalid service key: %s. make sure that service key ends in either -web, -wkr, or -job", k)
 		}
+
 		services[serviceName] = &Service{
 			Config: convertMap(v).(map[string]interface{}),
 			Type:   &serviceType,
 		}
 	}
+
 	return &PorterStackYAML{
 		Services: services,
 	}, nil

+ 1 - 0
api/server/handlers/porter_app/rollback.go

@@ -112,6 +112,7 @@ func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	}
 
 	chart, values, _, err := parse(
+		ctx,
 		ParseConf{
 			ImageInfo:    imageInfo,
 			ServerConfig: c.Config(),

+ 20 - 9
api/server/handlers/release/get.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"github.com/stefanmcshane/helm/pkg/release"
 	"gorm.io/gorm"
@@ -33,16 +34,20 @@ func NewReleaseGetHandler(
 }
 
 func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-release")
+	defer span.End()
+
+	helmRelease, _ := ctx.Value(types.ReleaseScope).(*release.Release)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "release-name", Value: helmRelease.Name})
 
 	res := &types.Release{
 		Release: helmRelease,
 	}
 
 	// look up the release in the database; if not found, do not populate Porter fields
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	release, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
-
 	if err == nil {
 		res.PorterRelease = release.ToReleaseType()
 
@@ -56,7 +61,8 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if release.BuildConfig != 0 {
 			bc, err := c.Repo().BuildConfig().GetBuildConfig(release.BuildConfig)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get build config")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
@@ -66,26 +72,30 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if release.StackResourceID != 0 {
 			stackResource, err := c.Repo().Stack().ReadStackResource(release.StackResourceID)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get stack resource")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
 			stackRevision, err := c.Repo().Stack().ReadStackRevision(stackResource.StackRevisionID)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get stack revision")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
 			stack, err := c.Repo().Stack().ReadStackByID(cluster.ProjectID, stackRevision.StackID)
 			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				err = telemetry.Error(ctx, span, err, "unable to get stack")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
 
 			res.StackID = stack.UID
 		}
 	} else if err != gorm.ErrRecordNotFound {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	} else {
 		res.PorterRelease = &types.PorterRelease{}
@@ -122,7 +132,8 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// look for the form using the dynamic client
 	dynClient, err := c.GetDynamicClient(r, cluster)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		err = telemetry.Error(ctx, span, err, "unable to get dynamic client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 

+ 88 - 0
api/server/router/cluster.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/database"
 	"github.com/porter-dev/porter/api/server/handlers/environment"
+	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -1604,5 +1605,92 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/environment-groups
+	updateEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/environment-groups",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updateEnvironmentGroupHandler := environment_groups.NewUpdateEnvironmentGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateEnvironmentGroupEndpoint,
+		Handler:  updateEnvironmentGroupHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/environment-groups}
+	deleteEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/environment-groups",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteEnvironmentGroupHandler := environment_groups.NewDeleteEnvironmentGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEnvironmentGroupEndpoint,
+		Handler:  deleteEnvironmentGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/environment-groups
+	listEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/environment-groups",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listEnvironmentGroupHandler := environment_groups.NewListEnvironmentGroupsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvironmentGroupEndpoint,
+		Handler:  listEnvironmentGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 4 - 2
api/types/porter_app.go

@@ -48,8 +48,10 @@ type CreatePorterAppRequest struct {
 	ImageInfo        ImageInfo `json:"image_info" form:"omitempty"`
 	OverrideRelease  bool      `json:"override_release"`
 	EnvGroups        []string  `json:"env_groups"`
-	UserUpdate       bool      `json:"user_update"`
-	FullHelmValues   string    `json:"full_helm_values"`
+	// EnvironmentGroups are the list of environment groups that this app is linked to. This should be used instead of EnvGroups.
+	EnvironmentGroups []string `json:"environment_groups"`
+	UserUpdate        bool     `json:"user_update"`
+	FullHelmValues    string   `json:"full_helm_values"`
 }
 
 type UpdatePorterAppRequest struct {

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

@@ -233,6 +233,19 @@ export type PopulatedEnvGroup = {
   meta_version: number;
   stack_id?: string;
 };
+
+export type NewPopulatedEnvGroup = {
+  name: string;
+  current_version: number;
+  variables: {
+    [key: string]: string;
+  };
+  secret_variables: {
+    [key: string]: string;
+  };
+  linked_applications: any[];
+  created_at: number;
+};
 export interface KeyValueArrayFieldState {
   values: {
     key: string;

+ 24 - 104
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -43,7 +43,7 @@ import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
-import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
+import { NewPopulatedEnvGroup, PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
 import EventFocusView from "./activity-feed/events/focus-views/EventFocusView";
 import HelmValuesTab from "./HelmValuesTab";
@@ -112,9 +112,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
-  const [syncedEnvGroups, setSyncedEnvGroups] = useState<PopulatedEnvGroup[]>([])
-  const [deletedEnvGroups, setDeleteEnvGroups] = useState<PopulatedEnvGroup[]>([])
+  const [syncedEnvGroups, setSyncedEnvGroups] = useState<NewPopulatedEnvGroup[]>([])
+  const [deletedEnvGroups, setDeleteEnvGroups] = useState<NewPopulatedEnvGroup[]>([])
   const [porterApp, setPorterApp] = useState<PorterApp>();
+
   // this is the version of the porterApp that is being edited. on save, we set the real porter app to be this version
   const [tempPorterApp, setTempPorterApp] = useState<PorterApp>();
   const [buildView, setBuildView] = useState<BuildMethod>("docker");
@@ -129,7 +130,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   }
   const eventId = queryParams.get('event_id');
   const selectedTab: ValidTab = tab != null && validTabs.includes(tab) ? tab : DEFAULT_TAB;
-
   useEffect(() => {
     if (!_.isEqual(_.omitBy(porterApp, _.isEmpty), _.omitBy(tempPorterApp, _.isEmpty))) {
       setButtonStatus("");
@@ -202,36 +202,30 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         resPorterApp?.data?.porter_yaml_path ?? "porter.yaml",
         newAppData
       );
-      let envGroups: PartialEnvGroup[] = [];
-      envGroups = await api
-        .listEnvGroups<PartialEnvGroup[]>(
+
+      const envGroups: NewPopulatedEnvGroup[] = await api
+        .getAllEnvGroups<any[]>(
           "<token>",
           {},
           {
-            id: currentProject.id,
-            namespace: "porter-env-group",
-            cluster_id: currentCluster.id,
+            id: currentProject?.id,
+            cluster_id: currentCluster?.id,
           }
         )
-        .then((res) => res.data);
-      const populateEnvGroupsPromises = envGroups?.map((envGroup) =>
-        api
-          .getEnvGroup<PopulatedEnvGroup>(
-            "<token>",
-            {},
-            {
-              id: currentProject.id,
-              cluster_id: currentCluster.id,
-              name: envGroup.name,
-              namespace: envGroup.namespace,
-              version: envGroup.version,
-            }
-          )
-          .then((res) => res.data)
-      );
-      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
-      const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(newAppData.chart.name));
-      setSyncedEnvGroups(filteredEnvGroups)
+        .then((res) => res?.data?.environment_groups) ?? []
+        .catch((error) => {
+          console.error("Failed to fetch environment groups:", error);
+          return [];
+        });
+      let filteredEnvGroups: NewPopulatedEnvGroup[] = [];
+
+      if (envGroups) {
+        filteredEnvGroups = envGroups?.filter(envGroup =>
+          envGroup?.linked_applications?.length > 0 && envGroup?.linked_applications?.includes(appName)
+        );
+      }
+
+      setSyncedEnvGroups(filteredEnvGroups);
       setPorterJson(porterJson);
       setAppData(newAppData);
       // annoying that we have to parse buildpacks like this but alas
@@ -315,27 +309,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const deletePorterApp = async (deleteGHWorkflowFile?: boolean) => {
     setDeleting(true);
     const { appName } = props.match.params as any;
-    if (syncedEnvGroups) {
-      const removeApplicationToEnvGroupPromises = syncedEnvGroups?.map((envGroup: any) => {
-        return api.removeApplicationFromEnvGroup(
-          "<token>",
-          {
-            name: envGroup?.name,
-            app_name: appData.chart.name,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            namespace: "porter-env-group",
-          }
-        );
-      });
-      try {
-        await Promise.all(removeApplicationToEnvGroupPromises);
-      } catch (error) {
-        // TODO: Handle error
-      }
-    }
     try {
       await api.deletePorterApp(
         "<token>",
@@ -409,59 +382,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   };
 
   const updatePorterApp = async (options: Partial<CreateUpdatePorterAppOptions>) => {
-    //setting the EnvGroups Config Maps
-    const filteredEnvGroups = deletedEnvGroups.filter((deletedEnvGroup) => {
-      return !syncedEnvGroups.some((syncedEnvGroup) => {
-        return syncedEnvGroup.name === deletedEnvGroup.name;
-      });
-    });
-    setDeleteEnvGroups(filteredEnvGroups);
-    if (deletedEnvGroups) {
-      const removeApplicationToEnvGroupPromises = deletedEnvGroups?.map((envGroup: any) => {
-        return api.removeApplicationFromEnvGroup(
-          "<token>",
-          {
-            name: envGroup?.name,
-            app_name: appData.chart.name,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            namespace: "porter-env-group",
-          }
-        );
-      });
-
-      try {
-        await Promise.all(removeApplicationToEnvGroupPromises);
-      } catch (error) {
-        setCurrentError(
-          "We couldn't remove the synced env group from the application, please try again."
-        );
-      }
-    }
-    const addApplicationToEnvGroupPromises = syncedEnvGroups?.map(
-      (envGroup: any) => {
-        return api.addApplicationToEnvGroup(
-          "<token>",
-          {
-            name: envGroup?.name,
-            app_name: appData.chart.name,
-          },
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            namespace: "porter-env-group",
-          }
-        );
-      }
-    );
-
-    try {
-      await Promise.all(addApplicationToEnvGroupPromises);
-    } catch (error) {
-      // TODO: handle error
-    }
     try {
       setButtonStatus("loading");
       if (
@@ -488,7 +408,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           repo_name: tempPorterApp.repo_name,
           git_branch: tempPorterApp.git_branch,
           buildpacks: "",
-          env_groups: syncedEnvGroups?.map((env) => env.name),
+          environment_groups: syncedEnvGroups?.map((env) => env.name),
           user_update: true,
           ...options,
         }

+ 106 - 107
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvGroupModal.tsx

@@ -6,10 +6,7 @@ import Loading from "components/Loading";
 import Modal from "components/porter/Modal";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
-import ExpandableSection from "components/porter/ExpandableSection";
-import Fieldset from "components/porter/Fieldset";
 import Button from "components/porter/Button";
-import Select from "components/porter/Select";
 import api from "shared/api";
 import { getGithubAction } from "./utils";
 import AceEditor from "react-ace";
@@ -27,6 +24,7 @@ import {
 import {
   PartialEnvGroup,
   PopulatedEnvGroup,
+  NewPopulatedEnvGroup,
 } from "components/porter-form/types";
 import { KeyValueType } from "../../../cluster-dashboard/env-groups/EnvGroupArray";
 import { set } from "zod";
@@ -36,8 +34,8 @@ type Props = RouteComponentProps & {
   availableEnvGroups?: PartialEnvGroup[];
   setValues: (x: KeyValueType[]) => void;
   values: KeyValueType[];
-  syncedEnvGroups: PopulatedEnvGroup[];
-  setSyncedEnvGroups: (values: PopulatedEnvGroup[]) => void;
+  syncedEnvGroups: NewPopulatedEnvGroup[];
+  setSyncedEnvGroups: (values: NewPopulatedEnvGroup[]) => void;
   namespace: string;
   newApp?: boolean;
 }
@@ -61,43 +59,28 @@ const EnvGroupModal: React.FC<Props> = ({
   const [cloneSuccess, setCloneSuccess] = useState(false);
 
   const updateEnvGroups = async () => {
-    let envGroups: PartialEnvGroup[] = [];
+    let populatedEnvGroups: any[] = [];
     try {
-      envGroups = await api
-        .listEnvGroups<PartialEnvGroup[]>(
+      populatedEnvGroups = await api
+        .getAllEnvGroups<any[]>(
           "<token>",
           {},
           {
             id: currentProject.id,
-            namespace: "porter-env-group",
             cluster_id: currentCluster.id,
           }
         )
-        .then((res) => res.data);
+        .then((res) => res.data.environment_groups);
     } catch (error) {
       setLoading(false)
       setError(true);
       return;
     }
 
-    const populateEnvGroupsPromises = envGroups.map((envGroup) =>
-      api
-        .getEnvGroup<PopulatedEnvGroup>(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            cluster_id: currentCluster.id,
-            name: envGroup.name,
-            namespace: envGroup.namespace,
-            version: envGroup.version,
-          }
-        )
-        .then((res) => res.data)
-    );
+
 
     try {
-      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
+
       setEnvGroups(populatedEnvGroups)
       setLoading(false)
 
@@ -114,6 +97,7 @@ const EnvGroupModal: React.FC<Props> = ({
   }, [values]);
 
   useEffect(() => {
+    setLoading(true)
     if (Array.isArray(availableEnvGroups)) {
       setEnvGroups(availableEnvGroups);
       setLoading(false);
@@ -122,28 +106,6 @@ const EnvGroupModal: React.FC<Props> = ({
     updateEnvGroups();
   }, []);
 
-  const cloneEnvGroup = async () => {
-    setCloneSuccess(false);
-    try {
-      await api.cloneEnvGroup(
-        "<token>",
-        {
-          name: selectedEnvGroup.name,
-          namespace: namespace,
-          clone_name: selectedEnvGroup.name,
-          version: selectedEnvGroup.version,
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: "porter-env-group",
-        }
-      );
-      setCloneSuccess(true);
-    } catch (error) {
-      console.log(error);
-    }
-  };
   const renderEnvGroupList = () => {
     if (loading) {
       return (
@@ -151,14 +113,10 @@ const EnvGroupModal: React.FC<Props> = ({
           <Loading />
         </LoadingWrapper>
       );
-    } else if (!envGroups?.length) {
-      return (
-        <Placeholder>
-          No environment groups found in this namespace
-        </Placeholder>
-      );
     } else {
-      return envGroups
+      const sortedEnvGroups = envGroups.slice().sort((a, b) => a.name.localeCompare(b.name));
+
+      return sortedEnvGroups
         .filter((envGroup) => {
           if (!Array.isArray(syncedEnvGroups)) {
             return true;
@@ -187,9 +145,6 @@ const EnvGroupModal: React.FC<Props> = ({
     if (shouldSync) {
 
       syncedEnvGroups.push(selectedEnvGroup);
-      if (!newApp) {
-        cloneEnvGroup();
-      }
       setSyncedEnvGroups(syncedEnvGroups);
     }
     else {
@@ -217,66 +172,88 @@ const EnvGroupModal: React.FC<Props> = ({
         Load env group
       </Text>
       <Spacer height="15px" />
-      {syncedEnvGroups.length != envGroups.length ? (<>
-        <Text color="helper">
-          Select an Env Group to load into your application.
-        </Text>
-        <Spacer y={0.5} />
-        <GroupModalSections>
-          <SidebarSection $expanded={!selectedEnvGroup}>
-            <EnvGroupList>{renderEnvGroupList()}</EnvGroupList>
-          </SidebarSection>
-          {selectedEnvGroup && (
-            <><SidebarSection>
-
-              <GroupEnvPreview>
-                {isObject(selectedEnvGroup?.variables) ? (
-                  <>
-                    {Object.entries(selectedEnvGroup?.variables || {})
-                      .map(
-                        ([key, value]) =>
-                          `${key}=${formattedEnvironmentValue(value)}`
+      <ColumnContainer>
+
+        <ScrollableContainer>
+          {syncedEnvGroups.length != envGroups.length ? (<>
+            <Text color="helper">
+              Select an Env Group to load into your application.
+            </Text>
+            <Spacer y={0.5} />
+            <GroupModalSections>
+              <SidebarSection $expanded={!selectedEnvGroup}>
+                <EnvGroupList>{renderEnvGroupList()}</EnvGroupList>
+              </SidebarSection>
+              {selectedEnvGroup && (
+                <><SidebarSection>
+
+                  <GroupEnvPreview>
+                    {
+                      isObject(selectedEnvGroup?.variables) || isObject(selectedEnvGroup?.secret_variables) ? (
+                        <>
+                          {[
+                            ...Object.entries(selectedEnvGroup?.variables || {}).map(([key, value]) => ({
+                              source: 'variables',
+                              key,
+                              value,
+                            })),
+                            ...Object.entries(selectedEnvGroup?.secret_variables || {}).map(([key, value]) => ({
+                              source: 'secret_variables',
+                              key,
+                              value,
+                            })),
+                          ]
+                            .map(({ key, value, source }, index) => (
+                              <div key={index}>
+                                <span className="key">{key} = </span>
+                                <span className="value">{formattedEnvironmentValue(source === 'secret_variables' ? "****" : value)}</span>
+                              </div>
+                            ))}
+                        </>
+                      ) : (
+                        <>This environment group has no variables</>
                       )
-                      .join("\n")}
-                  </>
-                ) : (
-                  <>This environment group has no variables</>
-                )}
-              </GroupEnvPreview>
-              {/* {clashingKeys?.length > 0 && (
-                <>
-                  <ClashingKeyRowDivider />
-                  {this.renderEnvGroupPreview(clashingKeys)}
+                    }
+                  </GroupEnvPreview>
+                </SidebarSection>
+
                 </>
-              )} */}
-            </SidebarSection>
-              <Checkbox
-                checked={shouldSync}
-                toggleChecked={() =>
-                  setShouldSync((!shouldSync))
-                }
-              >
-                <Text color="helper">Sync Env Group</Text>
-              </Checkbox>
-            </>
+              )
+
+              }
+
+            </GroupModalSections>
+            <Spacer y={1} />
+
+            <Spacer y={1} />
+          </>
+          ) : (
+
+            loading ? (
+              < LoadingWrapper >
+                < Loading />
+              </LoadingWrapper>)
+              : (<Text >
+                No selectable Env Groups
+              </Text>)
+
           )
 
           }
+        </ScrollableContainer>
+      </ColumnContainer>
+      <SubmitButtonContainer>
 
-        </GroupModalSections>
-        <Spacer y={1} />
-
-        <Spacer y={1} />
         <Button
           onClick={onSubmit}
           disabled={!selectedEnvGroup}
         >
           Load Env Group
-        </Button> </>
-      ) : (<Text >
-        No selectable Env Groups
-      </Text>)}
-    </Modal>
+        </Button>
+      </SubmitButtonContainer>
+
+
+    </Modal >
   )
 }
 
@@ -344,6 +321,12 @@ const GroupEnvPreview = styled.pre`
   white-space: pre-line;
   word-break: break-word;
   user-select: text;
+  .key {
+    color: white;
+  }
+  .value {
+    color: #3a48ca;
+  }
 `;
 const GroupModalSections = styled.div`
   margin-top: 20px;
@@ -354,3 +337,19 @@ const GroupModalSections = styled.div`
   grid-template-columns: 1fr 1fr;
   max-height: 365px;
 `;
+const ColumnContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: stretch; 
+`;
+
+const ScrollableContainer = styled.div`
+  flex: 1; 
+  overflow-y: auto;
+  max-height: 300px; 
+`;
+
+const SubmitButtonContainer = styled.div`
+  margin-top: 10px;
+  text-align: right;
+`;

+ 56 - 133
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx

@@ -8,22 +8,23 @@ import Error from "components/porter/Error";
 import sliders from "assets/sliders.svg";
 import EnvGroupModal from "./EnvGroupModal";
 import ExpandableEnvGroup from "./ExpandableEnvGroup";
-import { PopulatedEnvGroup, PartialEnvGroup } from "../../../../../components/porter-form/types";
+import { PopulatedEnvGroup, PartialEnvGroup, NewPopulatedEnvGroup } from "../../../../../components/porter-form/types";
 import _, { isObject, differenceBy, omit } from "lodash";
 import api from "../../../../../shared/api";
 import { Context } from "../../../../../shared/Context";
+import yaml from "js-yaml";
 
 interface EnvVariablesTabProps {
   envVars: any;
   setEnvVars: (x: any) => void;
   status: React.ReactNode;
   updatePorterApp: any;
-  syncedEnvGroups: PopulatedEnvGroup[];
-  setSyncedEnvGroups: (values: PopulatedEnvGroup[]) => void;
+  syncedEnvGroups: NewPopulatedEnvGroup[];
+  setSyncedEnvGroups: (values: NewPopulatedEnvGroup[]) => void;
   clearStatus: () => void;
   appData: any;
-  deletedEnvGroups: PopulatedEnvGroup[];
-  setDeletedEnvGroups: (values: PopulatedEnvGroup[]) => void;
+  deletedEnvGroups: NewPopulatedEnvGroup[];
+  setDeletedEnvGroups: (values: NewPopulatedEnvGroup[]) => void;
 }
 
 export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
@@ -38,10 +39,13 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
   clearStatus,
   appData,
 }) => {
+  const [hovered, setHovered] = useState(false);
+
   const [showEnvModal, setShowEnvModal] = useState(false);
   const [envGroups, setEnvGroups] = useState<any>([])
   const { currentCluster, currentProject } = useContext(Context);
 
+  const [values, setValues] = React.useState<string>(yaml.dump(appData.chart.config));
   useEffect(() => {
     setEnvVars(envVars);
   }, [envVars]);
@@ -50,62 +54,44 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
   }, []);
 
   const updateEnvGroups = async () => {
-    let envGroups: PartialEnvGroup[] = [];
+    let populateEnvGroupsPromises: NewPopulatedEnvGroup[] = [];
     try {
-      envGroups = await api
-        .listEnvGroups<PartialEnvGroup[]>(
+      populateEnvGroupsPromises = await api
+        .getAllEnvGroups<NewPopulatedEnvGroup[]>(
           "<token>",
           {},
           {
             id: currentProject.id,
-            namespace: "porter-env-group",
             cluster_id: currentCluster.id,
           }
         )
-        .then((res) => res.data);
+        .then((res) => res?.data?.environment_groups);
     } catch (error) {
-      // setLoading(false)
-      // setError(true);
       return;
     }
-    const populateEnvGroupsPromises = envGroups.map((envGroup) =>
-      api
-        .getEnvGroup<PopulatedEnvGroup>(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            cluster_id: currentCluster.id,
-            name: envGroup.name,
-            namespace: envGroup.namespace,
-            version: envGroup.version,
-          }
-        )
-        .then((res) => res.data)
-    );
 
     try {
       const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
       setEnvGroups(populatedEnvGroups)
-      // setLoading(false)
-      const filteredEnvGroups = populatedEnvGroups.filter(envGroup => envGroup.applications.includes(appData.chart.name));
-
+      const filteredEnvGroups = populatedEnvGroups?.filter(envGroup =>
+        envGroup.linked_applications && envGroup.linked_applications.includes(appData.chart.name)
+      );
       setSyncedEnvGroups(filteredEnvGroups)
 
     } catch (error) {
-      // setLoading(false)
-      // setError(true);
       return;
     }
   }
 
-  const deleteEnvGroup = (envGroup: PopulatedEnvGroup) => {
+  const deleteEnvGroup = (envGroup: NewPopulatedEnvGroup) => {
 
     setDeletedEnvGroups([...deletedEnvGroups, envGroup]);
     setSyncedEnvGroups(syncedEnvGroups?.filter(
       (env) => env.name !== envGroup.name
     ))
   }
+  const maxEnvGroupsReached = syncedEnvGroups.length >= 4;
+
   return (
     <>
       <Text size={16}>Environment variables</Text>
@@ -125,11 +111,18 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
       />
       {currentProject.env_group_enabled && (
         <>
-          <LoadButton
-            onClick={() => setShowEnvModal(true)}
-          >
-            <img src={sliders} /> Load from Env Group
-          </LoadButton>
+          <TooltipWrapper
+            onMouseOver={() => setHovered(true)}
+            onMouseOut={() => setHovered(false)}>
+            <LoadButton
+              disabled={maxEnvGroupsReached}
+              onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
+            >
+              <img src={sliders} /> Load from Env Group
+            </LoadButton>
+            <TooltipText visible={maxEnvGroupsReached && hovered}>Max 4 Env Groups allowed</TooltipText>
+          </TooltipWrapper>
+
           {showEnvModal && <EnvGroupModal
             setValues={(x: any) => {
               if (status !== "") {
@@ -166,7 +159,7 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
       <Spacer y={0.5} />
       <Button
         onClick={() => {
-          updatePorterApp();
+          updatePorterApp()
         }}
         status={status}
         loadingText={"Updating..."}
@@ -204,11 +197,13 @@ const AddRowButton = styled.div`
   }
 `;
 
-const LoadButton = styled(AddRowButton)`
-  background: none;
-  border: 1px solid #ffffff55;
+const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
+  background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")};
+  border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+
   > i {
-    color: #ffffff44;
+    color: ${(props) => (props.disabled ? "#aaaaaa44" : "#ffffff44")};
     font-size: 16px;
     margin-left: 8px;
     margin-right: 10px;
@@ -220,14 +215,10 @@ const LoadButton = styled(AddRowButton)`
     width: 14px;
     margin-left: 10px;
     margin-right: 12px;
+    opacity: ${(props) => (props.disabled ? "0.5" : "1")};
   }
 `;
 
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-top: 5px;
-`;
 
 type InputProps = {
   disabled?: boolean;
@@ -292,16 +283,6 @@ export const MultiLineInput = styled.textarea<InputProps>`
   }
 `;
 
-const Label = styled.div`
-  color: #ffffff;
-  margin-bottom: 10px;
-`;
-
-const StyledInputArray = styled.div`
-  margin-bottom: 15px;
-  margin-top: 22px;
-`;
-
 const fadeIn = keyframes`
   from {
     opacity: 0;
@@ -311,83 +292,25 @@ const fadeIn = keyframes`
   }
 `;
 
-const StyledCard = styled.div`
-  border: 1px solid #ffffff44;
-  background: #ffffff11;
-  margin-bottom: 5px;
-  border-radius: 8px;
-  margin-top: 15px;
-  padding: 10px 14px;
-  overflow: hidden;
-  font-size: 13px;
-  animation: ${fadeIn} 0.5s;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  height: 25px;
-  align-items: center;
-  justify-content: space-between;
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  height: 40px;
-  width: 100%;
-  align-items: center;
-`;
-
-const EventInformation = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  height: 100%;
-`;
-
-const EventName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const ActionContainer = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  height: 100%;
-`;
-
-const ActionButton = styled.button`
+const TooltipWrapper = styled.div`
   position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  width: 30px;
-  height: 30px;
-  margin-left: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  cursor: pointer;
-  color: #aaaabb;
-  border: 1px solid #ffffff00;
-
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff44;
-  }
-
-  > span {
-    font-size: 20px;
-  }
+  display: inline-block;
 `;
 
-const NoVariablesTextWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff99;
+const TooltipText = styled.span`
+  visibility: ${(props) => (props.visible ? 'visible' : 'hidden')};
+  width: 240px;
+  color: #fff;
+  text-align: center;
+  padding: 5px 0;
+  border-radius: 6px;
+  position: absolute;
+  z-index: 1;
+  bottom: 100%;
+  left: 50%;
+  margin-left: -120px;
+  opacity: ${(props) => (props.visible ? '1' : '0')};
+  transition: opacity 0.3s;
+  font-size: 12px;
 `;
 

+ 39 - 39
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/ExpandableEnvGroup.tsx

@@ -40,50 +40,50 @@ const ExpandableEnvGroup: React.FC<{
           <>
             {isObject(envGroup.variables) ? (
               <>
-                {Object.entries(envGroup.variables || {})?.map(
-                  ([key, value], i: number) => {
-                    // Preprocess non-string env values set via raw Helm values
-                    if (typeof value === "object") {
-                      value = JSON.stringify(value);
-                    } else {
-                      value = String(value);
-                    }
+                {[
+                  ...Object.entries(envGroup?.variables || {}).map(([key, value]) => ({
+                    key,
+                    value,
+                    source: 'variables',
+                  })),
+                  ...Object.entries(envGroup.secret_variables || {}).map(([key, value]) => ({
+                    key,
+                    value,
+                    source: 'secret_variables',
+                  })),
+                ].map(({ key, value, source }, i: number) => {
+                  // Preprocess non-string env values set via raw Helm values
+                  if (typeof value === "object") {
+                    value = JSON.stringify(value);
+                  } else {
+                    value = String(value);
+                  }
 
-                    return (
-                      <InputWrapper key={i}>
+                  return (
+                    <InputWrapper key={i}>
+                      <KeyInput placeholder="ex: key" width="270px" value={key} disabled />
+                      <Spacer x={0.5} inline />
+                      {source === 'secret_variables' ? (
                         <KeyInput
-                          placeholder="ex: key"
+                          placeholder="ex: value"
                           width="270px"
-                          value={key}
+                          value={value}
                           disabled
+                          type="password"
                         />
-                        <Spacer x={.5} inline />
-                        {value?.includes("PORTERSECRET") ? (
-                          <KeyInput
-                            placeholder="ex: value"
-                            width="270px"
-                            value={value}
-                            disabled
-                            type={
-                              value.includes("PORTERSECRET")
-                                ? "password"
-                                : "text"
-                            }
-                          />
-                        ) : (
-                          <MultiLineInput
-                            placeholder="ex: value"
-                            width="270px"
-                            value={value}
-                            disabled
-                            rows={value?.split("\n").length}
-                            spellCheck={false}
-                          ></MultiLineInput>
-                        )}
-                      </InputWrapper>
-                    );
-                  }
-                )}
+                      ) : (
+                        <MultiLineInput
+                          placeholder="ex: value"
+                          width="270px"
+                          value={value}
+                          disabled
+                          rows={value?.split("\n").length}
+                          spellCheck={false}
+                        ></MultiLineInput>
+                      )}
+                    </InputWrapper>
+                  );
+                })}
               </>
             ) : (
               <NoVariablesTextWrapper>

+ 50 - 76
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -21,7 +21,7 @@ import Container from "components/porter/Container";
 
 import SourceSettings from "./SourceSettings";
 import Services from "./Services";
-import EnvGroupArray, { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import GithubActionModal from "./GithubActionModal";
 import Error from "components/porter/Error";
 import { PorterJson, PorterYamlSchema, createFinalPorterYaml } from "./schema";
@@ -29,7 +29,7 @@ import { ImageInfo, Service } from "./serviceTypes";
 import GithubConnectModal from "./GithubConnectModal";
 import Link from "components/porter/Link";
 import { BuildMethod, PorterApp } from "../types/porterApp";
-import { PartialEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
+import { NewPopulatedEnvGroup, PartialEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
 import EnvGroupArrayStacks from "main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks";
 import EnvGroupModal from "../expanded-app/env-vars/EnvGroupModal";
 import ExpandableEnvGroup from "../expanded-app/env-vars/ExpandableEnvGroup";
@@ -75,6 +75,7 @@ interface PorterJsonWithPath {
 
 const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const [porterApp, setPorterApp] = useState<PorterApp>(PorterApp.empty());
+  const [hovered, setHovered] = useState(false);
 
   const [imageTag, setImageTag] = useState("latest");
   const { currentCluster, currentProject } = useContext(Context);
@@ -105,9 +106,9 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const [existingApps, setExistingApps] = useState<string[]>([]);
   const [appNameInputError, setAppNameInputError] = useState<string | undefined>(undefined);
 
-  const [syncedEnvGroups, setSyncedEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+  const [syncedEnvGroups, setSyncedEnvGroups] = useState<NewPopulatedEnvGroup[]>([]);
   const [showEnvModal, setShowEnvModal] = useState(false);
-  const [deletedEnvGroups, setDeleteEnvGroups] = useState<PopulatedEnvGroup[]>([])
+  const [deletedEnvGroups, setDeleteEnvGroups] = useState<NewPopulatedEnvGroup[]>([])
 
   // this advances the step in the case that a user chooses a repo that doesn't have a porter.yaml
   useEffect(() => {
@@ -337,7 +338,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         git_repo_id: porterApp.git_repo_id,
         build_context: porterApp.build_context,
         image_repo_uri: porterApp.image_repo_uri,
-        env_groups: syncedEnvGroups?.map((env: PopulatedEnvGroup) => env.name),
+        environment_groups: syncedEnvGroups?.map((env: NewPopulatedEnvGroup) => env.name),
         user_update: true,
       }
       if (porterApp.image_repo_uri && imageTag) {
@@ -369,68 +370,6 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
         props.history.push(`/apps/${porterApp.name}`);
       }
 
-
-      const filteredEnvGroups = deletedEnvGroups.filter((deletedEnvGroup) => {
-        return !syncedEnvGroups.some((syncedEnvGroup) => {
-          return syncedEnvGroup.name === deletedEnvGroup.name;
-        });
-      });
-      setDeleteEnvGroups(filteredEnvGroups);
-      if (deletedEnvGroups) {
-        const removeApplicationToEnvGroupPromises = deletedEnvGroups?.map((envGroup: any) => {
-          return api.removeApplicationFromEnvGroup(
-            "<token>",
-            {
-              name: envGroup?.name,
-              app_name: porterApp.name,
-            },
-            {
-              project_id: currentProject.id,
-              cluster_id: currentCluster.id,
-              namespace: "porter-env-group",
-            }
-          );
-        });
-
-        try {
-          await Promise.all(removeApplicationToEnvGroupPromises);
-        } catch (err: any) {
-          const errMessage =
-            err?.response?.data?.error ??
-            err?.toString() ??
-            "An error occurred while deploying your app. Please try again.";
-          setDeploymentError(errMessage);
-          updateStackStep("stack-launch-failure", errMessage);
-        }
-      }
-      const addApplicationToEnvGroupPromises = syncedEnvGroups?.map(
-        (envGroup: any) => {
-          return api.addApplicationToEnvGroup(
-            "<token>",
-            {
-              name: envGroup?.name,
-              app_name: porterApp.name,
-            },
-            {
-              project_id: currentProject.id,
-              cluster_id: currentCluster.id,
-              namespace: "porter-env-group",
-            }
-          );
-        }
-      );
-
-      try {
-        await Promise.all(addApplicationToEnvGroupPromises);
-      } catch (err: any) {
-        const errMessage =
-          err?.response?.data?.error ??
-          err?.toString() ??
-          "An error occurred while deploying your app. Please try again.";
-        setDeploymentError(errMessage);
-        updateStackStep("stack-launch-failure", errMessage);
-      }
-
       // log analytics event that we successfully deployed
       updateStackStep("stack-launch-success");
 
@@ -448,6 +387,8 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       setDeploying(false);
     }
   };
+  const maxEnvGroupsReached = syncedEnvGroups.length >= 4;
+
 
   return (
     <CenterWrapper>
@@ -580,11 +521,18 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 />
                 {currentProject?.env_group_enabled && (
                   <>
-                    <LoadButton
-                      onClick={() => setShowEnvModal(true)}
-                    >
-                      <img src={sliders} /> Load from Env Group
-                    </LoadButton>
+                    <TooltipWrapper
+                      onMouseOver={() => setHovered(true)}
+                      onMouseOut={() => setHovered(false)}>
+                      <LoadButton
+                        disabled={maxEnvGroupsReached}
+                        onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
+                      >
+                        <img src={sliders} /> Load from Env Group
+                      </LoadButton>
+                      <TooltipText visible={maxEnvGroupsReached && hovered}>Max 4 Env Groups allowed</TooltipText>
+                    </TooltipWrapper>
+
                     {showEnvModal && <EnvGroupModal
                       setValues={(x: any) => {
                         setFormState({ ...formState, envVariables: x });
@@ -772,11 +720,13 @@ const AddRowButton = styled.div`
     justify-content: center;
   }
 `;
-const LoadButton = styled(AddRowButton)`
-  background: none;
-  border: 1px solid #ffffff55;
+const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
+  background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")};
+  border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+
   > i {
-    color: #ffffff44;
+    color: ${(props) => (props.disabled ? "#aaaaaa44" : "#ffffff44")};
     font-size: 16px;
     margin-left: 8px;
     margin-right: 10px;
@@ -788,5 +738,29 @@ const LoadButton = styled(AddRowButton)`
     width: 14px;
     margin-left: 10px;
     margin-right: 12px;
+    opacity: ${(props) => (props.disabled ? "0.5" : "1")};
   }
 `;
+
+const TooltipWrapper = styled.div`
+  position: relative;
+  display: inline-block;
+`;
+
+const TooltipText = styled.span`
+  visibility: ${(props) => (props.visible ? 'visible' : 'hidden')};
+  width: 240px;
+  color: #fff;
+  text-align: center;
+  padding: 5px 0;
+  border-radius: 6px;
+  position: absolute;
+  z-index: 1;
+  bottom: 100%;
+  left: 50%;
+  margin-left: -120px;
+  opacity: ${(props) => (props.visible ? '1' : '0')};
+  transition: opacity 0.3s;
+  font-size: 12px;
+`;
+

+ 67 - 4
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -42,8 +42,11 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
   }
 
   isDisabled = () => {
+    const { envGroupName } = this.state;
     return (
-      !isAlphanumeric(this.state.envGroupName) || this.state.envGroupName === ""
+      !isAlphanumeric(envGroupName) ||
+      envGroupName === "" ||
+      envGroupName.length > 15
     );
   };
 
@@ -128,6 +131,65 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       });
   };
 
+  createEnv = () => {
+    this.setState({ submitStatus: "loading" });
+
+    let apiEnvVariables: Record<string, string> = {};
+    let secretEnvVariables: Record<string, string> = {};
+
+    let envVariables = this.state.envVariables;
+    envVariables
+      .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are marked as deleted and are duplicates
+        let numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) =>
+                _envVar.key === envVar.key && !_envVar.deleted
+            )
+          );
+        }
+      })
+      .forEach((envVar: KeyValueType) => {
+        if (!envVar.deleted) {
+          if (envVar.hidden) {
+            secretEnvVariables[envVar.key] = envVar.value;
+          } else {
+            apiEnvVariables[envVar.key] = envVar.value;
+          }
+        }
+      });
+
+    api
+      .createEnvironmentGroups(
+        "<token>",
+        {
+          name: this.state.envGroupName,
+          variables: apiEnvVariables,
+          secret_variables: secretEnvVariables,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.props.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ submitStatus: "successful" });
+        // console.log(res);
+        this.props.goBack();
+      })
+      .catch((err) => {
+        this.setState({ submitStatus: "Could not create" });
+      });
+  };
+
   updateNamespaces = () => {
     let { currentProject } = this.context;
     api
@@ -175,11 +237,12 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
               <Warning
                 makeFlush={true}
                 highlight={
-                  !isAlphanumeric(this.state.envGroupName) &&
+                  (!isAlphanumeric(this.state.envGroupName) ||
+                    this.state.envGroupName.length > 15) &&
                   this.state.envGroupName !== ""
                 }
               >
-                Lowercase letters, numbers, and "-" only.
+                Lowercase letters, numbers, and "-" only. Maximum 15 characters.
               </Warning>
             </Subtitle>
             <DarkMatter antiHeight="-29px" />
@@ -233,7 +296,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
             text="Create env group"
             clearPosition={true}
             statusPosition="right"
-            onClick={this.onSubmit}
+            onClick={this?.context?.currentProject.simplified_view_enabled ? this.createEnv : this.onSubmit}
             status={
               this.isDisabled()
                 ? "Missing required fields"

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx

@@ -33,7 +33,7 @@ export default class EnvGroup extends Component<PropsType, StateType> {
     let name = envGroup?.name;
     let timestamp = envGroup?.created_at;
     let namespace = envGroup?.namespace;
-    let version = envGroup?.version;
+    let version = this.context?.currentProject.simplified_view_enabled ? envGroup?.latest_version : envGroup?.version ;
 
     return (
       <Link to={`/env-groups/${name}${window.location.search}`} target="_self">

+ 62 - 10
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -22,6 +22,7 @@ type PropsType = {
   disabled?: boolean;
   fileUpload?: boolean;
   secretOption?: boolean;
+  setButtonDisabled?: (x: boolean) => void;
 };
 
 const EnvGroupArray = ({
@@ -31,9 +32,20 @@ const EnvGroupArray = ({
   disabled,
   fileUpload,
   secretOption,
+  setButtonDisabled,
 }: PropsType) => {
   const [showEditorModal, setShowEditorModal] = useState(false);
-
+  const incorrectRegex = (key: string) => {
+    var pattern = /^[a-zA-Z0-9._-]+$/;
+    if (setButtonDisabled) {
+      setButtonDisabled(!pattern.test(key))
+    }
+    if (key) {
+      // The test() method tests for a match in a string
+      return !pattern.test(key);
+    }
+    return false;
+  };
   useEffect(() => {
     if (!values) {
       setValues([]);
@@ -94,6 +106,7 @@ const EnvGroupArray = ({
                     }}
                     disabled={disabled || entry.locked}
                     spellCheck={false}
+                    override={incorrectRegex(entry.key)}
                   />
                   <Spacer />
 
@@ -110,6 +123,7 @@ const EnvGroupArray = ({
                       disabled={disabled || entry.locked}
                       type={entry.hidden ? "password" : "text"}
                       spellCheck={false}
+                      override={incorrectRegex(entry.key)}
                     />
                   ) : (
                     <MultiLineInput
@@ -124,6 +138,7 @@ const EnvGroupArray = ({
                       rows={entry.value?.split("\n").length}
                       disabled={disabled || entry.locked}
                       spellCheck={false}
+                      override={incorrectRegex(entry.key)}
                     />
                   )}
                   {secretOption && (
@@ -297,23 +312,19 @@ const InputWrapper = styled.div`
   align-items: center;
   margin-top: 5px;
 `;
-
-const Input = styled.input`
+const Input = styled.input<InputProps>`
   outline: none;
   border: none;
   margin-bottom: 5px;
   font-size: 13px;
   background: #ffffff11;
-  border: 1px solid #ffffff55;
+  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #ffffff55')};
   border-radius: 3px;
-  width: ${(props: { disabled?: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled?: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
+  width: ${(props) => props.width ? props.width : "270px"};
+  color: ${(props) => props.disabled ? "#ffffff44" : "white"};
   padding: 5px 10px;
   height: 35px;
 `;
-
 const Label = styled.div`
   color: #ffffff;
   margin-bottom: 10px;
@@ -322,4 +333,45 @@ const Label = styled.div`
 const StyledInputArray = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
-`;
+`;
+
+export const MultiLineInputer = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #ffffff55')};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;

+ 5 - 4
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArrayStacks.tsx

@@ -6,7 +6,7 @@ import EnvEditorModal from "main/home/modals/EnvEditorModal";
 import upload from "assets/upload.svg";
 import { MultiLineInput } from "components/porter-form/field-components/KeyValueArray";
 import { dotenv_parse } from "shared/string_utils";
-import { PopulatedEnvGroup } from "components/porter-form/types";
+import { NewPopulatedEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 export type KeyValueType = {
@@ -24,7 +24,7 @@ type PropsType = {
   disabled?: boolean;
   fileUpload?: boolean;
   secretOption?: boolean;
-  syncedEnvGroups?: PopulatedEnvGroup[];
+  syncedEnvGroups?: NewPopulatedEnvGroup[];
 };
 
 const EnvGroupArray = ({
@@ -44,9 +44,10 @@ const EnvGroupArray = ({
     }
   }, [values]);
   const isKeyOverriding = (key: string) => {
-
     if (!syncedEnvGroups) return false;
-    return syncedEnvGroups.some(envGroup => key in envGroup.variables);
+    return syncedEnvGroups.some(envGroup =>
+      key in envGroup.variables || key in envGroup?.secret_variables
+    );
   };
 
   const readFile = (env: string) => {

+ 46 - 29
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -37,38 +37,55 @@ const EnvGroupList: React.FunctionComponent<Props> = (props) => {
   const updateEnvGroups = async () => {
     let { currentProject, currentCluster } = context;
     try {
-      const envGroups = await api
-        .listEnvGroups(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            namespace: namespace,
-            cluster_id: currentCluster.id,
-          }
-        )
-        .then((res) => {
-          return res.data;
-        });
-
+      let envGroups: any[] = []
+      if (currentProject?.simplified_view_enabled) {
+        envGroups = await api
+          .getAllEnvGroups(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              cluster_id: currentCluster.id,
+            }
+          )
+          .then((res) => {
+            return res.data?.environment_groups;
+          });
+      } else {
+        envGroups = await api
+          .listEnvGroups(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              namespace: namespace,
+              cluster_id: currentCluster.id,
+            }
+          )
+          .then((res) => {
+            return res.data;
+          });
+      }
       let sortedGroups = envGroups;
-      switch (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
-          );
+      if (sortedGroups) {
+        switch (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)
       setIsLoading(false);
       setHasError(true);
     }
@@ -113,7 +130,7 @@ const EnvGroupList: React.FunctionComponent<Props> = (props) => {
           <i className="material-icons">error</i> Error connecting to cluster.
         </Placeholder>
       );
-    } else if (envGroups.length === 0) {
+    } else if (!envGroups || envGroups.length === 0) {
       return (
         <Placeholder height="370px">
           <i className="material-icons">category</i>

+ 549 - 150
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -5,13 +5,15 @@ import React, {
   useMemo,
   useState,
 } from "react";
+import yaml from "js-yaml";
+import { createFinalPorterYaml, PorterYamlSchema } from "../../app-dashboard/new-app-flow/schema"
 import styled, { keyframes } from "styled-components";
 import backArrow from "assets/back_arrow.png";
 import key from "assets/key.svg";
 import loading from "assets/loading.gif";
 import leftArrow from "assets/left-arrow.svg";
 
-import { ClusterType } from "shared/types";
+import { ChartType, ClusterType, CreateUpdatePorterAppOptions } from "shared/types";
 import { Context } from "shared/Context";
 import { isAlphanumeric } from "shared/common";
 import api from "shared/api";
@@ -24,20 +26,26 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import _, { remove, update } from "lodash";
-import { PopulatedEnvGroup } from "components/porter-form/types";
+import _, { flatMapDepth, remove, update } from "lodash";
+import { NewPopulatedEnvGroup, PopulatedEnvGroup } from "components/porter-form/types";
 import { isAuthorized } from "shared/auth/authorization-helpers";
 import useAuth from "shared/auth/useAuth";
 import { fillWithDeletedVariables } from "components/porter-form/utils";
 import DynamicLink from "components/DynamicLink";
 import DocsHelper from "components/DocsHelper";
 import Spacer from "components/porter/Spacer";
+import EnvGroups from "../stacks/ExpandedStack/components/EnvGroups";
+import { PorterJson } from "main/home/app-dashboard/new-app-flow/schema";
+import { BuildMethod, PorterApp } from "main/home/app-dashboard/types/porterApp";
+import { Service } from "main/home/app-dashboard/new-app-flow/serviceTypes";
+import { consoleSandbox } from "@sentry/utils";
 
 type PropsType = WithAuthProps & {
   namespace: string;
   envGroup: any;
   currentCluster: ClusterType;
   closeExpanded: () => void;
+  allEnvGroups?: NewPopulatedEnvGroup[];
 };
 
 type StateType = {
@@ -61,12 +69,15 @@ type EnvGroup = {
 
 type EditableEnvGroup = Omit<PopulatedEnvGroup, "variables"> & {
   variables: KeyValueType[];
+  linked_applications?: string[];
+  secret_variables?: KeyValueType[];
 };
 
 export const ExpandedEnvGroupFC = ({
   envGroup,
   namespace,
   closeExpanded,
+  allEnvGroups,
 }: PropsType) => {
   const {
     currentProject,
@@ -76,13 +87,24 @@ export const ExpandedEnvGroupFC = ({
   } = useContext(Context);
   const [isAuthorized] = useAuth();
 
+  const [workflowCheckPassed, setWorkflowCheckPassed] = useState<boolean>(
+    false
+  );
+  const [isLoading, setIsLoading] = useState(true);
+
   const [currentTab, setCurrentTab] = useState("variables-editor");
   const [isDeleting, setIsDeleting] = useState(false);
   const [buttonStatus, setButtonStatus] = useState("");
+  const [services, setServices] = useState<Service[]>([]);
+  const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
+  const [subdomain, setSubdomain] = useState<string>("");
+
 
   const [currentEnvGroup, setCurrentEnvGroup] = useState<EditableEnvGroup>(
     null
   );
+  const [hasBuiltImage, setHasBuiltImage] = useState<boolean>(false);
+
   const [originalEnvVars, setOriginalEnvVars] = useState<
     {
       key: string;
@@ -90,14 +112,48 @@ export const ExpandedEnvGroupFC = ({
     }[]
   >();
 
+
+  const fetchPorterYamlContent = async (
+    porterYaml: string,
+    appData: any
+  ) => {
+    try {
+      if (porterYaml && appData && appData?.app?.git_repo_id) {
+        const res = await api.getPorterYamlContents(
+          "<token>",
+          {
+            path: porterYaml,
+          },
+          {
+            project_id: appData.app.project_id,
+            git_repo_id: appData.app.git_repo_id,
+            owner: appData.app.repo_name?.split("/")[0],
+            name: appData.app.repo_name?.split("/")[1],
+            kind: "github",
+            branch: appData.app.git_branch,
+          }
+        );
+        if (res.data == null || res.data == "") {
+          return undefined;
+        }
+        const parsedYaml = yaml.load(atob(res.data));
+
+        return parsedYaml
+      }
+    } catch (err) {
+      // TODO: handle error
+      console.log("No Porter Yaml")
+
+    }
+  };
+
   const tabOptions = useMemo(() => {
     if (!isAuthorized("env_group", "", ["get", "delete"])) {
       return [{ value: "variables-editor", label: "Environment variables" }];
     }
-
     if (
       !isAuthorized("env_group", "", ["get", "delete"]) &&
-      currentEnvGroup?.applications?.length
+      (currentProject?.simplified_view_enabled ? currentEnvGroup?.linked_applications?.length : currentEnvGroup?.applications?.length)
     ) {
       return [
         { value: "variables-editor", label: "Environment variables" },
@@ -105,7 +161,7 @@ export const ExpandedEnvGroupFC = ({
       ];
     }
 
-    if (currentEnvGroup?.applications?.length) {
+    if (currentProject?.simplified_view_enabled ? currentEnvGroup?.linked_applications?.length : currentEnvGroup?.applications?.length) {
       return [
         { value: "variables-editor", label: "Environment variables" },
         { value: "applications", label: "Linked applications" },
@@ -119,51 +175,124 @@ export const ExpandedEnvGroupFC = ({
     ];
   }, [currentEnvGroup]);
   const populateEnvGroup = async () => {
-    try {
-      const populatedEnvGroup = await api
-        .getEnvGroup<PopulatedEnvGroup>(
-          "<token>",
-          {},
-          {
-            name: envGroup.name,
-            id: currentProject.id,
-            namespace: namespace,
-            cluster_id: currentCluster.id,
-          }
-        )
-        .then((res) => res.data);
-      updateEnvGroup(populatedEnvGroup);
-    } catch (error) {
-      console.log(error);
+
+    if (currentProject?.simplified_view_enabled) {
+      try {
+        const populatedEnvGroup = await api
+          .getAllEnvGroups(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              cluster_id: currentCluster.id,
+            }
+          )
+          .then((res) => res.data.environment_groups);
+        updateEnvGroup(populatedEnvGroup.find((i: any) => i.name === envGroup.name));
+      } catch (error) {
+        console.log(error);
+      }
+    } else {
+      try {
+        const populatedEnvGroup = await api
+          .getEnvGroup<NewPopulatedEnvGroup>(
+            "<token>",
+            {},
+            {
+              name: envGroup.name,
+              id: currentProject.id,
+              namespace: namespace,
+              cluster_id: currentCluster.id,
+            }
+          )
+          .then((res) => res.data);
+        updateEnvGroup(populatedEnvGroup);
+      } catch (error) {
+        console.log(error);
+      }
     }
   };
 
-  const updateEnvGroup = (populatedEnvGroup: PopulatedEnvGroup) => {
-    const variables: KeyValueType[] = Object.entries(
-      populatedEnvGroup.variables || {}
-    ).map(([key, value]) => ({
-      key: key,
-      value: value,
-      hidden: value.includes("PORTERSECRET"),
-      locked: value.includes("PORTERSECRET"),
-      deleted: false,
-    }));
-
-    setOriginalEnvVars(
-      Object.entries(populatedEnvGroup.variables || {}).map(([key, value]) => ({
-        key,
-        value,
-      }))
-    );
+  const updateEnvGroup = (populatedEnvGroup: NewPopulatedEnvGroup) => {
+
+
+    if (currentProject?.simplified_view_enabled) {
+      const normal_variables: KeyValueType[] = Object.entries(
+        populatedEnvGroup.variables || {}
+      ).map(([key, value]) => ({
+        key: key,
+        value: value,
+        hidden: value.includes("PORTERSECRET"),
+        locked: value.includes("PORTERSECRET"),
+        deleted: false,
+      }));
+      const secret_variables: KeyValueType[] = Object.entries(
+        populatedEnvGroup.secret_variables || {}
+      ).map(([key, value]) => ({
+        key: key,
+        value: value,
+        hidden: true,
+        locked: true,
+        deleted: false,
+      }));
+      const variables = [...normal_variables, ...secret_variables];
+
+
+      setOriginalEnvVars(
+        Object.entries({
+          ...(populatedEnvGroup?.variables || {}),
+          ...(populatedEnvGroup.secret_variables || {}),
+        }).map(([key, value]) => ({
+          key,
+          value,
+        }))
+      );
+
+      setCurrentEnvGroup({
+        ...populatedEnvGroup,
+        variables,
+      });
+
+    } else {
+      const variables: KeyValueType[] = Object.entries(
+        populatedEnvGroup.variables || {}
+      ).map(([key, value]) => ({
+        key: key,
+        value: value,
+        hidden: value.includes("PORTERSECRET"),
+        locked: value.includes("PORTERSECRET"),
+        deleted: false,
+      }));
+
+      setOriginalEnvVars(
+        Object.entries(populatedEnvGroup?.variables || {}).map(([key, value]) => ({
+          key,
+          value,
+        }))
+      );
+
+      setCurrentEnvGroup({
+        ...populatedEnvGroup,
+        variables,
+      });
 
-    setCurrentEnvGroup({
-      ...populatedEnvGroup,
-      variables,
-    });
+    }
   };
 
   const deleteEnvGroup = async () => {
     const { name, stack_id } = currentEnvGroup;
+    if (currentProject?.simplified_view_enabled) {
+      return api.deleteNewEnvGroup(
+        "<token>",
+        {
+          name: name,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+    }
 
     if (stack_id?.length) {
       return api.removeStackEnvGroup(
@@ -179,6 +308,7 @@ export const ExpandedEnvGroupFC = ({
       );
     }
 
+
     return api.deleteEnvGroup(
       "<token>",
       {
@@ -206,33 +336,279 @@ export const ExpandedEnvGroupFC = ({
       });
   };
 
+  const getPorterApp = async ({ appName }: { appName: string }) => {
+    try {
+      if (!currentCluster || !currentProject) {
+        return;
+      }
+      const resPorterApp = await api.getPorterApp(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          name: appName,
+        }
+      );
+      const resChartData = await api.getChart(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          namespace: `porter-stack-${appName}`,
+          cluster_id: currentCluster.id,
+          name: appName,
+          revision: 0,
+        }
+      );
+
+      let preDeployChartData;
+      // get the pre-deploy chart
+      try {
+        preDeployChartData = await api.getChart(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            namespace: `porter-stack-${appName}`,
+            cluster_id: currentCluster.id,
+            name: `${appName}-r`,
+            // this is always latest because we do not tie the pre-deploy chart to the umbrella chart
+            revision: 0,
+          }
+        );
+      } catch (err) {
+        // that's ok if there's an error, just means there is no pre-deploy chart
+      }
+
+      // update apps and release
+      const newAppData = {
+        app: resPorterApp?.data,
+        chart: resChartData?.data,
+        releaseChart: preDeployChartData?.data,
+      };
+      const porterJson = await fetchPorterYamlContent(
+        resPorterApp?.data?.porter_yaml_path ?? "porter.yaml",
+        newAppData
+      );
+
+      let filteredEnvGroups: NewPopulatedEnvGroup[] = []
+      filteredEnvGroups = allEnvGroups?.filter(envGroup =>
+        envGroup.linked_applications && envGroup.linked_applications.includes(appName)
+      );
+
+      const parsedPorterApp = { ...resPorterApp?.data, buildpacks: newAppData.app.buildpacks?.split(",") ?? [] };
+      const buildView = !_.isEmpty(parsedPorterApp.dockerfile) ? "docker" : "buildpacks"
+
+      const [newServices, newEnvVars] = updateServicesAndEnvVariables(
+        resChartData?.data,
+        preDeployChartData?.data,
+        porterJson,
+      );
+      const finalPorterYaml = createFinalPorterYaml(
+        newServices,
+        newEnvVars,
+        porterJson,
+        // if we are using a heroku buildpack, inject a PORT env variable
+        newAppData.app.builder != null && newAppData.app.builder.includes("heroku")
+      );
+
+
+      // Only check GHA status if no built image is set
+      const hasBuiltImage = !!resChartData.data.config?.global?.image
+        ?.repository;
+      if (hasBuiltImage || !resPorterApp.data.repo_name) {
+        setWorkflowCheckPassed(true);
+        setHasBuiltImage(true);
+      } else {
+        try {
+          await api.getBranchContents(
+            "<token>",
+            {
+              dir: `./.github/workflows/porter_stack_${resPorterApp.data.name}.yml`,
+            },
+            {
+              project_id: currentProject.id,
+              git_repo_id: resPorterApp.data.git_repo_id,
+              kind: "github",
+              owner: resPorterApp.data.repo_name.split("/")[0],
+              name: resPorterApp.data.repo_name.split("/")[1],
+              branch: resPorterApp.data.git_branch,
+            }
+          );
+          setWorkflowCheckPassed(true);
+
+        } catch (err) {
+          // Handle unmerged PR
+          if (err.response?.status === 404) {
+            try {
+              // Check for user-copied porter.yml as fallback
+              const resPorterYml = await api.getBranchContents(
+                "<token>",
+                { dir: `./.github/workflows/porter.yml` },
+                {
+                  project_id: currentProject.id,
+                  git_repo_id: resPorterApp.data.git_repo_id,
+                  kind: "github",
+                  owner: resPorterApp.data.repo_name.split("/")[0],
+                  name: resPorterApp.data.repo_name.split("/")[1],
+                  branch: resPorterApp.data.git_branch,
+                }
+              );
+              setWorkflowCheckPassed(true);
+            } catch (err) {
+              setWorkflowCheckPassed(false);
+            }
+          }
+        }
+      }
+
+      if (
+        currentCluster != null &&
+        currentProject != null
+      ) {
+
+        const yamlString = yaml.dump(finalPorterYaml);
+        const base64Encoded = btoa(yamlString);
+
+        const updatedPorterApp = {
+          porter_yaml: base64Encoded,
+          override_release: true,
+          ...PorterApp.empty(),
+          build_context: newAppData?.build_context,
+          repo_name: newAppData?.repo_name,
+          git_branch: newAppData?.git_branch,
+          buildpacks: "",
+          //full_helm_values: yaml.dump(values),
+          environment_groups: filteredEnvGroups?.map((env) => env.name),
+          user_update: true,
+        }
+
+        if (buildView === "docker") {
+          updatedPorterApp.dockerfile = newAppData?.dockerfile;
+          updatedPorterApp.builder = "null";
+          updatedPorterApp.buildpacks = "null";
+        } else {
+          updatedPorterApp.builder = newAppData?.builder;
+          updatedPorterApp.buildpacks = newAppData?.buildpacks?.join(",");
+          updatedPorterApp.dockerfile = "null";
+        }
+
+        await api.createPorterApp(
+          "<token>",
+          updatedPorterApp,
+          {
+            cluster_id: currentCluster.id,
+            project_id: currentProject.id,
+            stack_name: appName,
+          }
+        );
+      } else {
+        setButtonStatus("error");
+      }
+    } catch (err) {
+      // TODO: handle error
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const updateServicesAndEnvVariables = (
+    currentChart?: ChartType,
+    releaseChart?: ChartType,
+    porterJson?: PorterJson,
+  ): [Service[], KeyValueType[]] => {
+    // handle normal chart
+    const helmValues = currentChart?.config;
+    const defaultValues = (currentChart?.chart as any)?.values;
+    let newServices: Service[] = [];
+    let envVars: KeyValueType[] = [];
+
+    if (
+      (defaultValues && Object.keys(defaultValues).length > 0) ||
+      (helmValues && Object.keys(helmValues).length > 0)
+    ) {
+      newServices = Service.deserialize(helmValues, defaultValues, porterJson);
+      const { global, ...helmValuesWithoutGlobal } = helmValues;
+      if (Object.keys(helmValuesWithoutGlobal).length > 0) {
+        envVars = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
+        setEnvVars(envVars);
+        const subdomain = Service.retrieveSubdomainFromHelmValues(
+          newServices,
+          helmValuesWithoutGlobal
+        );
+        setSubdomain(subdomain);
+      }
+    }
+
+    // handle release chart
+    if (releaseChart?.config || porterJson?.release) {
+      const release = Service.deserializeRelease(releaseChart?.config, porterJson);
+      newServices.push(release);
+    }
+
+    setServices(newServices);
+
+    return [newServices, envVars];
+  };
+
   const handleUpdateValues = async () => {
     setButtonStatus("loading");
     const name = currentEnvGroup.name;
-    let variables = currentEnvGroup.variables;
+    let variables = currentEnvGroup?.variables;
+    if (currentEnvGroup.meta_version === 2 || currentProject?.simplified_view_enabled) {
 
-    if (currentEnvGroup.meta_version === 2) {
       const secretVariables = remove(variables, (envVar) => {
         return !envVar.value.includes("PORTERSECRET") && envVar.hidden;
       }).reduce(
         (acc, variable) => ({
           ...acc,
-          [variable.key]: variable.value,
+          [variable.key]: variable?.value,
         }),
         {}
       );
 
-      const normalVariables = variables.reduce(
+      const normalVariables = variables?.reduce(
         (acc, variable) => ({
           ...acc,
-          [variable.key]: variable.value,
+          [variable.key]: variable?.value,
         }),
         {}
       );
-      //Create the Env Group
-      try {
-        const updatedEnvGroup = await api
-          .updateEnvGroup<PopulatedEnvGroup>(
+      
+      if (currentProject?.simplified_view_enabled) {
+        try {
+
+          const normal_variables: KeyValueType[] = Object.entries(
+            normalVariables || {}
+          ).map(([key, value]) => ({
+            key: key,
+            value: value,
+            hidden: value.includes("PORTERSECRET"),
+            locked: value.includes("PORTERSECRET"),
+            deleted: false,
+          }));
+
+          const secret_variables: KeyValueType[] = Object.entries(
+            secretVariables || {}
+          ).map(([key, value]) => ({
+            key: key,
+            value: value,
+            hidden: true,
+            locked: true,
+            deleted: false,
+          }));
+          const variables = [...normal_variables, ...secret_variables];
+
+
+          setCurrentEnvGroup({
+            ...currentEnvGroup,
+            variables,
+          });
+
+
+          let linkedApp: string[] = currentEnvGroup?.linked_applications;
+          await api.createEnvironmentGroups(
             "<token>",
             {
               name,
@@ -240,78 +616,59 @@ export const ExpandedEnvGroupFC = ({
               secret_variables: secretVariables,
             },
             {
-              project_id: currentProject.id,
+              id: currentProject.id,
               cluster_id: currentCluster.id,
-              namespace,
             }
-          )
-          .then((res) => res.data);
-        if (!currentProject?.simplified_view_enabled) {
-          setButtonStatus("successful");
-        }
-        updateEnvGroup(updatedEnvGroup);
-
-        setTimeout(() => setButtonStatus(""), 1000);
-
-
-        if (currentProject?.simplified_view_enabled) {
-          setButtonStatus("loading");
-          //Clone the env group to all applications
-          try {
-            // Check if updatedEnvGroup.applications is an array and it has elements
-            if (Array.isArray(updatedEnvGroup.applications) && updatedEnvGroup.applications.length) {
-
-              for (const application of updatedEnvGroup.applications) {
-                await api.cloneEnvGroup(
-                  "<token>",
-                  {
-                    name: updatedEnvGroup.name,
-                    namespace: "porter-stack-" + application,  // Use the application's namespace
-                    clone_name: updatedEnvGroup.name,
-                    version: updatedEnvGroup.version,
-                  },
-                  {
-                    id: currentProject.id,
-                    cluster_id: currentCluster.id,
-                    namespace: "porter-env-group",
-                  }
-                );
-              }
+          );
+          //const newEnvGroup = await pollForEnvGroup(name, currentProject, currentCluster);
+          if (linkedApp) {
+            for (let appName of linkedApp) {
+              await getPorterApp({ appName: appName });
             }
-          } catch (error) {
-            setCurrentError(error);
           }
+          const populatedEnvGroup = await api.getAllEnvGroups("<token>", {}, {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }).then(res => res.data.environment_groups);
 
-          //Update the Stacks Env Groups with the new variables
-          try {
-            setButtonStatus("loading");
-            await api
-              .updateStacksEnvGroup<PopulatedEnvGroup>(
-                "<token>",
-                {
-                  name,
-                  variables: normalVariables,
-                  secret_variables: secretVariables,
-                  apps: updatedEnvGroup.applications,
-                },
-                {
-                  project_id: currentProject.id,
-                  cluster_id: currentCluster.id,
-                  namespace,
-                }
-              )
-            setButtonStatus("successful")
-          } catch (error) {
-            setButtonStatus("Couldn't update successfully");
-            setCurrentError(error);
+          const newEnvGroup = populatedEnvGroup.find((i: any) => i.name === name);
+
+          updateEnvGroup(newEnvGroup);
+          setButtonStatus("successful");
+        } catch (error) {
+          setButtonStatus("Couldn't update successfully");
+          setCurrentError(error);
+          setTimeout(() => setButtonStatus(""), 1000);
+        }
+      } else {
+        try {
+          const updatedEnvGroup = await api
+            .updateEnvGroup<PopulatedEnvGroup>(
+              "<token>",
+              {
+                name,
+                variables: normalVariables,
+                secret_variables: secretVariables,
+              },
+              {
+                project_id: currentProject.id,
+                cluster_id: currentCluster.id,
+                namespace,
+              }
+            )
+            .then((res) => res.data);
+          if (!currentProject?.simplified_view_enabled) {
+            setButtonStatus("successful");
           }
+          updateEnvGroup(updatedEnvGroup);
 
+          setTimeout(() => setButtonStatus(""), 1000);
+        }
+        catch (error) {
+          setButtonStatus("Couldn't update successfully");
+          setCurrentError(error);
+          setTimeout(() => setButtonStatus(""), 1000);
         }
-      }
-      catch (error) {
-        setButtonStatus("Couldn't update successfully");
-        setCurrentError(error);
-        setTimeout(() => setButtonStatus(""), 1000);
       }
     }
     else {
@@ -413,8 +770,6 @@ export const ExpandedEnvGroupFC = ({
         };
       }, {});
 
-      // console.log({ normalObject, secretObject });
-
       try {
         const updatedEnvGroup = await api
           .updateConfigMap(
@@ -443,7 +798,9 @@ export const ExpandedEnvGroupFC = ({
   };
 
   const renderTabContents = () => {
-    const { variables } = currentEnvGroup;
+    let { variables, secret_variables } = currentEnvGroup;
+
+    //const mergeVar = variables.concat(secret_variables);
 
     switch (currentTab) {
       case "variables-editor":
@@ -537,6 +894,7 @@ const EnvGroupVariablesEditor = ({
   handleUpdateValues: () => void;
 }) => {
   const [isAuthorized] = useAuth();
+  const [buttonDisabled, setButtonDisabled] = useState(false)
 
   return (
     <TabWrapper>
@@ -547,6 +905,7 @@ const EnvGroupVariablesEditor = ({
           configuration.
         </Helper>
         <EnvGroupArray
+          setButtonDisabled={setButtonDisabled}
           values={variables}
           setValues={(x: any) => {
             onChange(x);
@@ -568,7 +927,7 @@ const EnvGroupVariablesEditor = ({
           text="Update"
           onClick={() => handleUpdateValues()}
           status={buttonStatus}
-          disabled={buttonStatus == "loading"}
+          disabled={buttonStatus == "loading" || buttonDisabled}
           makeFlush={true}
           clearPosition={true}
           statusPosition="right"
@@ -600,11 +959,19 @@ const EnvGroupSettings = ({
 
   const canDelete = useMemo(() => {
     // add a case for when applications is null - in this case this is a deprecated env group version
-    if (!envGroup?.applications) {
-      return true;
-    }
+    if (currentProject?.simplified_view_enabled) {
+      if (!envGroup?.linked_applications) {
+        return true;
+      }
+
+      return envGroup?.linked_applications?.length === 0;
+    } else {
+      if (!envGroup?.applications) {
+        return true;
+      }
 
-    return envGroup?.applications?.length === 0;
+      return envGroup?.applications?.length === 0;
+    }
   }, [envGroup]);
 
   const cloneEnvGroup = async () => {
@@ -626,7 +993,6 @@ const EnvGroupSettings = ({
       );
       setCloneSuccess(true);
     } catch (error) {
-      console.log(error);
       setCurrentError(error);
     }
   };
@@ -710,34 +1076,67 @@ const ApplicationsList = ({ envGroup }: { envGroup: EditableEnvGroup }) => {
           disableMargin
         />
       </HeadingWrapper>
-      {envGroup.applications.map((appName) => {
-        return (
-          <StyledCard>
-            <Flex>
-              <ContentContainer>
-                <EventInformation>
-                  <EventName>{appName}</EventName>
-                </EventInformation>
-              </ContentContainer>
-              <ActionContainer>
-                {currentProject?.simplified_view_enabled ? (<ActionButton
-                  to={`/apps/${appName}`}
-                  target="_blank"
-                >
-                  <span className="material-icons-outlined">open_in_new</span>
-                </ActionButton>)
-                  :
-                  (<ActionButton
-                    to={`/applications/${currentCluster.name}/${envGroup.namespace}/${appName}`}
+      {currentProject?.simplified_view_enabled ? (
+        envGroup.linked_applications.map((appName) => {
+          return (
+            <StyledCard>
+              <Flex>
+                <ContentContainer>
+                  <EventInformation>
+                    <EventName>{appName}</EventName>
+                  </EventInformation>
+                </ContentContainer>
+                <ActionContainer>
+                  {currentProject?.simplified_view_enabled ? (<ActionButton
+                    to={`/apps/${appName}`}
                     target="_blank"
                   >
                     <span className="material-icons-outlined">open_in_new</span>
-                  </ActionButton>)}
-              </ActionContainer>
-            </Flex>
-          </StyledCard>
-        );
-      })}
+                  </ActionButton>)
+                    :
+                    (<ActionButton
+                      to={`/applications/${currentCluster.name}/${envGroup.namespace}/${appName}`}
+                      target="_blank"
+                    >
+                      <span className="material-icons-outlined">open_in_new</span>
+                    </ActionButton>)}
+                </ActionContainer>
+              </Flex>
+            </StyledCard>
+          );
+        })
+      )
+        :
+        (envGroup.applications.map((appName) => {
+          return (
+            <StyledCard>
+              <Flex>
+                <ContentContainer>
+                  <EventInformation>
+                    <EventName>{appName}</EventName>
+                  </EventInformation>
+                </ContentContainer>
+                <ActionContainer>
+                  {currentProject?.simplified_view_enabled ? (<ActionButton
+                    to={`/apps/${appName}`}
+                    target="_blank"
+                  >
+                    <span className="material-icons-outlined">open_in_new</span>
+                  </ActionButton>)
+                    :
+                    (<ActionButton
+                      to={`/applications/${currentCluster.name}/${envGroup.namespace}/${appName}`}
+                      target="_blank"
+                    >
+                      <span className="material-icons-outlined">open_in_new</span>
+                    </ActionButton>)}
+                </ActionContainer>
+              </Flex>
+            </StyledCard>
+          );
+        }))
+      }
+
     </>
   );
 };

+ 24 - 13
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroupDashboard.tsx

@@ -43,18 +43,29 @@ const EnvGroupDashboard = (props: PropsType) => {
             return [];
           }
         }
-
-        const res = await api.listEnvGroups(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            namespace: currentProject?.simplified_view_enabled ? "porter-env-group" : namespace,
-            cluster_id: props.currentCluster.id,
-          }
-        );
-
-        return res.data;
+        let res: any[] = [];
+        if (currentProject?.simplified_view_enabled) {
+          res = await api.getAllEnvGroups(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              cluster_id: props.currentCluster.id,
+            }
+          );
+        } else {
+
+          res = await api.listEnvGroups(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              namespace: currentProject?.simplified_view_enabled ? "porter-env-group" : namespace,
+              cluster_id: props.currentCluster.id,
+            }
+          );
+        }
+        return currentProject?.simplified_view_enabled ? res.data?.environment_groups : res.data;
       } catch (err) {
         throw err;
       }
@@ -72,7 +83,6 @@ const EnvGroupDashboard = (props: PropsType) => {
     }
 
     const envGroup = envGroups.find((envGroup) => envGroup.name === name);
-
     setExpandedEnvGroup(envGroup);
   }, [envGroups, params]);
 
@@ -96,6 +106,7 @@ const EnvGroupDashboard = (props: PropsType) => {
 
     return (
       <ExpandedEnvGroup
+        allEnvGroups={envGroups}
         isAuthorized={props.isAuthorized}
         namespace={(currentProject?.simplified_view_enabled && currentProject?.capi_provisioner_enabled) ? "porter-env-group" : expandedEnvGroup?.namespace ?? namespace}
         currentCluster={props.currentCluster}

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

@@ -1587,6 +1587,16 @@ const upgradeChartValues = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/0/upgrade`;
 });
 
+const getAllEnvGroups = baseApi<
+  {},
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
+});
+
 const listEnvGroups = baseApi<
   {},
   {
@@ -1652,6 +1662,20 @@ const createEnvGroup = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroup/create`;
 });
 
+const createEnvironmentGroups = baseApi<
+  {
+    name: string;
+    variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
+  },
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
+});
+
 const cloneEnvGroup = baseApi<
   {
     name: string;
@@ -1761,6 +1785,19 @@ const deleteEnvGroup = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroup`;
 });
 
+const deleteNewEnvGroup = baseApi<
+  {
+    name: string;
+  },
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("DELETE", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
+});
+
+
 const deleteConfigMap = baseApi<
   {
     name: string;
@@ -2693,6 +2730,7 @@ export default {
   createGitlabIntegration,
   createEmailVerification,
   createEnvironment,
+  createEnvironmentGroups,
   updateEnvironment,
   deleteEnvironment,
   createPreviewEnvironmentDeployment,
@@ -2856,8 +2894,10 @@ export default {
   updateEnvGroup,
   updateStacksEnvGroup,
   listEnvGroups,
+  getAllEnvGroups,
   getEnvGroup,
   deleteEnvGroup,
+  deleteNewEnvGroup,
   addApplicationToEnvGroup,
   removeApplicationFromEnvGroup,
   provisionDatabase,

+ 6 - 0
go.work.sum

@@ -127,6 +127,9 @@ github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjr
 github.com/go-redis/redis v6.15.8+incompatible h1:BKZuG6mCnRj5AOaWJXoCgf6rqTYnYJLe4en2hxT7r9o=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
+github.com/hanwen/go-fuse/v2 v2.1.1-0.20220112183258-f57e95bda82d h1:ibbzF2InxMOS+lLCphY9PHNKPURDUBNKaG6ErSq8gJQ=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
 github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
@@ -139,6 +142,7 @@ github.com/porter-dev/api-contracts v0.0.63/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQ
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
 github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
 github.com/tchap/go-patricia v2.2.6+incompatible h1:JvoDL7JSoIP2HDE8AbDH3zC8QBPxmzYe32HHy5yQ+Ck=
+go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
 go.opentelemetry.io/contrib v0.20.0 h1:ubFQUn0VCZ0gPwIoJfBJVpeBlyRMxu8Mm/huKWYd9p0=
 go.opentelemetry.io/otel/exporters/otlp v0.20.0 h1:PTNgq9MRmQqqJY0REVbZFvwkYOA85vbdQU/nVfxDyqg=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -153,3 +157,5 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+helm.sh/helm/v3 v3.7.1 h1:kED/HWx09QHHSJhYaJY6ttj/BhmzBmT1oupKslncibY=
+k8s.io/cri-api v0.23.1 h1:0DHL/hpTf4Fp+QkUXFefWcp1fhjXr9OlNdY9X99c+O8=

+ 6 - 2
internal/helm/agent.go

@@ -554,7 +554,6 @@ func (a *Agent) UpgradeInstallChart(
 
 	cmd := action.NewUpgrade(a.ActionConfig)
 	cmd.Install = true
-
 	if cmd.Version == "" && cmd.Devel {
 		cmd.Version = ">0.0.0-0"
 	}
@@ -593,7 +592,12 @@ func (a *Agent) UpgradeInstallChart(
 		}
 	}
 
-	return cmd.Run(conf.Name, conf.Chart, conf.Values)
+	release, err := cmd.RunWithContext(ctx, conf.Name, conf.Chart, conf.Values)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error running helm upgrade")
+	}
+
+	return release, nil
 }
 
 // UninstallChart uninstalls a chart

+ 183 - 0
internal/kubernetes/environment_groups/create.go

@@ -0,0 +1,183 @@
+package environment_groups
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/telemetry"
+	v1 "k8s.io/api/core/v1"
+	k8serror "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// CreateOrUpdateBaseEnvironmentGroup creates a new environment group in the porter-env-group namespace. If porter-env-group does not exist, it will be created.
+// If no existing environmentGroup exists by this name, a new one will be created as version 1, denoted by the label "porter.run/environment-group-version: 1".
+// If an environmentGroup already exists by this name, a new version will be created, and the label will be updated to reflect the new version.
+// Providing the Version field to this function will be ignored in order to not accidentally overwrite versions
+func CreateOrUpdateBaseEnvironmentGroup(ctx context.Context, a *kubernetes.Agent, environmentGroup EnvironmentGroup) error {
+	ctx, span := telemetry.NewSpan(ctx, "create-environment-group")
+	defer span.End()
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-group", Value: environmentGroup.Name})
+
+	if environmentGroup.Name == "" {
+		return telemetry.Error(ctx, span, nil, "environment group name cannot be empty")
+	}
+
+	_, err := a.Clientset.CoreV1().Namespaces().Get(ctx, Namespace_EnvironmentGroups, metav1.GetOptions{})
+	if err != nil {
+		if !k8serror.IsNotFound(err) {
+			return telemetry.Error(ctx, span, err, "unable to check if global environment group exists")
+		}
+
+		_, err = a.Clientset.CoreV1().Namespaces().Create(ctx, &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: Namespace_EnvironmentGroups}}, metav1.CreateOptions{})
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "unable to create global environment group")
+		}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-group-namespace", Value: Namespace_EnvironmentGroups})
+
+	latestEnvironmentGroup, err := LatestBaseEnvironmentGroup(ctx, a, environmentGroup.Name)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to get latest base environment group by name")
+	}
+
+	newEnvironmentGroup := EnvironmentGroup{
+		Name:            environmentGroup.Name,
+		Variables:       environmentGroup.Variables,
+		SecretVariables: environmentGroup.SecretVariables,
+		Version:         latestEnvironmentGroup.Version + 1,
+		CreatedAtUTC:    environmentGroup.CreatedAtUTC,
+	}
+
+	err = createVersionedEnvironmentGroupInNamespace(ctx, a, newEnvironmentGroup, Namespace_EnvironmentGroups)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to create new versioned environment group")
+	}
+
+	return nil
+}
+
+// createEnvironmentGroupInTargetNamespace creates a new environment group in the target namespace. If you want to create a new base environment group, use CreateOrUpdateBaseEnvironmentGroup instead.
+// This should only be used for sync from a base environment to a target environment.
+// If the target namespace does not exist, it will be created for you.
+func createEnvironmentGroupInTargetNamespace(ctx context.Context, a *kubernetes.Agent, namespace string, environmentGroup EnvironmentGroup) (string, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-environment-group-in-target")
+	defer span.End()
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group", Value: environmentGroup.Name},
+		telemetry.AttributeKV{Key: "target-namespace", Value: namespace},
+	)
+
+	// var configMapName string
+	configMapName := fmt.Sprintf("%s.%d", environmentGroup.Name, environmentGroup.Version)
+	if environmentGroup.Name == "" {
+		return configMapName, telemetry.Error(ctx, span, nil, "environment group name cannot be empty")
+	}
+	if environmentGroup.Version == 0 {
+		return configMapName, telemetry.Error(ctx, span, nil, "environment group version cannot be empty")
+	}
+	if namespace == "" {
+		return configMapName, telemetry.Error(ctx, span, nil, "target namespace cannot be empty")
+	}
+
+	_, err := a.Clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{})
+	if err != nil {
+		if !k8serror.IsNotFound(err) {
+			return configMapName, telemetry.Error(ctx, span, err, "unable to check if target namespace exists")
+		}
+
+		_, err = a.Clientset.CoreV1().Namespaces().Create(ctx, &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{})
+		if err != nil {
+			return configMapName, telemetry.Error(ctx, span, err, "unable to create new target namespace")
+		}
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-group-namespace", Value: namespace})
+
+	err = createVersionedEnvironmentGroupInNamespace(ctx, a, environmentGroup, namespace)
+	if err != nil {
+		return configMapName, telemetry.Error(ctx, span, err, "error creating environment group clone in target namespace")
+	}
+
+	return configMapName, nil
+}
+
+// createVersionedEnvironmentGroupInNamespace creates a new environment group in the target namespace. This is used to keep the configmap and secret version for an environment variable in sync
+func createVersionedEnvironmentGroupInNamespace(ctx context.Context, a *kubernetes.Agent, environmentGroup EnvironmentGroup, targetNamespace string) error {
+	ctx, span := telemetry.NewSpan(ctx, "create-environment-group-on-cluster")
+	defer span.End()
+
+	configMap := v1.ConfigMap{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      fmt.Sprintf("%s.%d", environmentGroup.Name, environmentGroup.Version),
+			Namespace: targetNamespace,
+			Labels: map[string]string{
+				LabelKey_EnvironmentGroupName:    environmentGroup.Name,
+				LabelKey_EnvironmentGroupVersion: strconv.Itoa(environmentGroup.Version),
+			},
+		},
+		Data: environmentGroup.Variables,
+	}
+	err := createConfigMapWithVersion(ctx, a, configMap, environmentGroup.Version)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to create new environment group variables version")
+	}
+
+	secret := v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      fmt.Sprintf("%s.%d", environmentGroup.Name, environmentGroup.Version),
+			Namespace: targetNamespace,
+			Labels: map[string]string{
+				LabelKey_EnvironmentGroupName:    environmentGroup.Name,
+				LabelKey_EnvironmentGroupVersion: strconv.Itoa(environmentGroup.Version),
+			},
+		},
+		Data: environmentGroup.SecretVariables,
+	}
+
+	err = createSecretWithVersion(ctx, a, secret, environmentGroup.Version)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to create new environment group secret variables version")
+	}
+
+	return nil
+}
+
+func createConfigMapWithVersion(ctx context.Context, a *kubernetes.Agent, configMap v1.ConfigMap, version int) error {
+	ctx, span := telemetry.NewSpan(ctx, "create-environment-group-configmap")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "configmap-label", Value: configMap.Labels[LabelKey_EnvironmentGroupName]},
+		telemetry.AttributeKV{Key: "configmap-version", Value: configMap.Labels[LabelKey_EnvironmentGroupVersion]},
+		telemetry.AttributeKV{Key: "configmap-name", Value: configMap.Name},
+		telemetry.AttributeKV{Key: "configmap-namespace", Value: configMap.Namespace},
+	)
+
+	_, err := a.Clientset.CoreV1().ConfigMaps(configMap.Namespace).Create(ctx, &configMap, metav1.CreateOptions{})
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to create environment group configmap")
+	}
+
+	return nil
+}
+
+func createSecretWithVersion(ctx context.Context, a *kubernetes.Agent, secret v1.Secret, version int) error {
+	ctx, span := telemetry.NewSpan(ctx, "create-environment-group-secret")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "secret-label", Value: secret.Labels[LabelKey_EnvironmentGroupName]},
+		telemetry.AttributeKV{Key: "secret-version", Value: secret.Labels[LabelKey_EnvironmentGroupVersion]},
+		telemetry.AttributeKV{Key: "secret-name", Value: secret.Name},
+		telemetry.AttributeKV{Key: "secret-namespace", Value: secret.Namespace},
+	)
+
+	_, err := a.Clientset.CoreV1().Secrets(secret.Namespace).Create(ctx, &secret, metav1.CreateOptions{})
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to create environment group secret")
+	}
+
+	return nil
+}

+ 72 - 0
internal/kubernetes/environment_groups/delete.go

@@ -0,0 +1,72 @@
+package environment_groups
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/telemetry"
+	k8serror "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// DeleteEnvironmentGroup deletes an environment group and all of its versions from all namespaces, only if there are no linked applications
+func DeleteEnvironmentGroup(ctx context.Context, a *kubernetes.Agent, name string) error {
+	ctx, span := telemetry.NewSpan(ctx, "delete-environment-groups")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-group", Value: name})
+
+	if name == "" {
+		return telemetry.Error(ctx, span, nil, "environment group name cannot be empty")
+	}
+
+	environmentGroups, err := ListEnvironmentGroups(ctx, a, WithEnvironmentGroupName(name), WithNamespace(Namespace_EnvironmentGroups))
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to list environment groups")
+	}
+
+	for _, environmentGroup := range environmentGroups {
+		applications, err := LinkedApplications(ctx, a, environmentGroup.Name)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "unable to list linked applications")
+		}
+		if len(applications) > 0 {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "linked-applications", Value: len(applications)})
+			return telemetry.Error(ctx, span, nil, "unable to delete environment group with linked applications")
+		}
+	}
+
+	allConfigMapsInAllNamespaces, err := a.Clientset.CoreV1().ConfigMaps(metav1.NamespaceAll).List(ctx,
+		metav1.ListOptions{LabelSelector: fmt.Sprintf("%s=%s", LabelKey_EnvironmentGroupName, name)},
+	)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to list environment group variables")
+	}
+
+	for _, val := range allConfigMapsInAllNamespaces.Items {
+		labelName := fmt.Sprintf("%s.%s", val.Labels[LabelKey_EnvironmentGroupName], val.Labels[LabelKey_EnvironmentGroupVersion])
+
+		err := a.Clientset.CoreV1().ConfigMaps(val.Namespace).Delete(ctx,
+			labelName,
+			metav1.DeleteOptions{},
+		)
+		if err != nil {
+			if !k8serror.IsNotFound(err) {
+				return telemetry.Error(ctx, span, err, "unable to delete environment group variables")
+			}
+		}
+
+		err = a.Clientset.CoreV1().Secrets(val.Namespace).Delete(ctx,
+			labelName,
+			metav1.DeleteOptions{},
+		)
+		if err != nil {
+			if !k8serror.IsNotFound(err) {
+				return telemetry.Error(ctx, span, err, "unable to delete environment group secret variables")
+			}
+		}
+	}
+
+	return nil
+}

+ 88 - 0
internal/kubernetes/environment_groups/get.go

@@ -0,0 +1,88 @@
+package environment_groups
+
+import (
+	"context"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// LatestBaseEnvironmentGroup returns the most recent version of an environment group stored in the porter-env-group namespace
+func LatestBaseEnvironmentGroup(ctx context.Context, a *kubernetes.Agent, environmentGroupName string) (EnvironmentGroup, error) {
+	ctx, span := telemetry.NewSpan(ctx, "latest-base-env-group")
+	defer span.End()
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-group-name", Value: environmentGroupName})
+
+	var eg EnvironmentGroup
+
+	baseEnvironmentGroupVersions, err := ListEnvironmentGroups(ctx, a, WithEnvironmentGroupName(environmentGroupName), WithNamespace(Namespace_EnvironmentGroups))
+	if err != nil {
+		return eg, telemetry.Error(ctx, span, err, "unable to list base environment groups")
+	}
+
+	var highestVersionEnvironmentGroup EnvironmentGroup
+	for _, baseEnvironmentGroup := range baseEnvironmentGroupVersions {
+		if baseEnvironmentGroup.Version > highestVersionEnvironmentGroup.Version {
+			highestVersionEnvironmentGroup = baseEnvironmentGroup
+		}
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "highest-version", Value: highestVersionEnvironmentGroup.Version},
+		telemetry.AttributeKV{Key: "highest-version-name", Value: highestVersionEnvironmentGroup.Name},
+	)
+
+	return highestVersionEnvironmentGroup, nil
+}
+
+// EnvironmentGroupInTargetNamespaceInput contains all information required to check if an environment group exists in a target namespace.
+// If you are looking for envrionment groups in the base namespace, consider using LatestBaseEnvironmentGroup or ListBaseEnvironmentGroups instead
+type EnvironmentGroupInTargetNamespaceInput struct {
+	// Name is the environment group name which can be found on the configmap label
+	Name      string
+	Version   int
+	Namespace string
+}
+
+// EnvironmentGroupInTargetNamespace checks if an environment group of a specific name and version exists in a target namespace.
+// If an environment group exists, it will be returned
+func EnvironmentGroupInTargetNamespace(ctx context.Context, a *kubernetes.Agent, inp EnvironmentGroupInTargetNamespaceInput) (EnvironmentGroup, error) {
+	ctx, span := telemetry.NewSpan(ctx, "env-group-in-target-namespace")
+	defer span.End()
+
+	var eg EnvironmentGroup
+
+	if inp.Name == "" {
+		return eg, telemetry.Error(ctx, span, nil, "must provide an environment group name")
+	}
+	if inp.Version == 0 {
+		return eg, telemetry.Error(ctx, span, nil, "must provide an environment group version to check for")
+	}
+	if inp.Namespace == "" {
+		return eg, telemetry.Error(ctx, span, nil, "must provide a namespace to check")
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group-name", Value: inp.Name},
+		telemetry.AttributeKV{Key: "environment-group-version", Value: inp.Version},
+		telemetry.AttributeKV{Key: "namespace", Value: inp.Namespace},
+	)
+
+	environmentGroups, err := ListEnvironmentGroups(ctx, a, WithEnvironmentGroupName(inp.Name), WithEnvironmentGroupVersion(inp.Version), WithNamespace(inp.Namespace))
+	if err != nil {
+		return eg, telemetry.Error(ctx, span, err, "unable to list environment groups in target namespace")
+	}
+
+	if len(environmentGroups) > 1 {
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "expected-results", Value: 1},
+			telemetry.AttributeKV{Key: "actual-results", Value: len(environmentGroups)},
+		)
+		return eg, telemetry.Error(ctx, span, nil, "unexpected number of versions found in namespace")
+	}
+
+	if len(environmentGroups) == 1 {
+		return environmentGroups[0], nil
+	}
+
+	return eg, nil
+}

+ 228 - 0
internal/kubernetes/environment_groups/list.go

@@ -0,0 +1,228 @@
+package environment_groups
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/telemetry"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	LabelKey_LinkedEnvironmentGroup  = "porter.run/linked-environment-group"
+	LabelKey_EnvironmentGroupVersion = "porter.run/environment-group-version"
+	LabelKey_EnvironmentGroupName    = "porter.run/environment-group-name"
+
+	// Namespace_EnvironmentGroups is the base namespace for storing all environment groups.
+	// The configmaps and secrets here should be considered the source's of truth for a given version
+	Namespace_EnvironmentGroups = "porter-env-group"
+)
+
+// EnvironmentGroup represents a ConfigMap in the porter-env-group namespace
+type EnvironmentGroup struct {
+	// Name is the environment group name which can be found in the labels (LabelKey_EnvironmentGroupName) of the ConfigMap. This is NOT the configmap name
+	Name string `json:"name"`
+	// Version is the environment group version which can be found in the labels (LabelKey_EnvironmentGroupVersion) of the ConfigMap. This is NOT included in the configmap name
+	Version int `json:"latest_version"`
+	// Variables are non-secret values for the EnvironmentGroup. This usually will be a configmap
+	Variables map[string]string `json:"variables"`
+	// SecretVariables are secret values for the EnvironmentGroup. This usually will be a Secret on the kubernetes cluster
+	SecretVariables map[string][]byte `json:"variables_secrets"`
+	// CreatedAt is only used for display purposes and is in UTC Unix time
+	CreatedAtUTC time.Time `json:"created_at"`
+}
+
+type environmentGroupOptions struct {
+	namespace                    string
+	environmentGroupLabelName    string
+	environmentGroupLabelVersion int
+}
+
+// EnvironmentGroupOption is a function that modifies ListEnvironmentGroups
+type EnvironmentGroupOption func(*environmentGroupOptions)
+
+// WithNamespace filters all environment groups in a given namespace
+func WithNamespace(namespace string) EnvironmentGroupOption {
+	return func(opts *environmentGroupOptions) {
+		opts.namespace = namespace
+	}
+}
+
+// WithEnvironmentGroupName filters all environment groups by name
+func WithEnvironmentGroupName(name string) EnvironmentGroupOption {
+	return func(opts *environmentGroupOptions) {
+		opts.environmentGroupLabelName = name
+	}
+}
+
+// WithEnvironmentGroupVersion filters all environment groups by version
+func WithEnvironmentGroupVersion(version int) EnvironmentGroupOption {
+	return func(opts *environmentGroupOptions) {
+		opts.environmentGroupLabelVersion = version
+	}
+}
+
+// ListEnvironmentGroups returns all environment groups stored in the provided namespace. If none is set, it will use the namespace "porter-env-group"
+func ListEnvironmentGroups(ctx context.Context, a *kubernetes.Agent, listOpts ...EnvironmentGroupOption) ([]EnvironmentGroup, error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-environment-groups")
+	defer span.End()
+
+	var opts environmentGroupOptions
+	for _, opt := range listOpts {
+		opt(&opts)
+	}
+	if opts.namespace == "" {
+		opts.namespace = Namespace_EnvironmentGroups
+	}
+
+	var labelSelectors []string
+	if opts.environmentGroupLabelName != "" {
+		labelSelectors = append(labelSelectors, fmt.Sprintf("%s=%s", LabelKey_EnvironmentGroupName, opts.environmentGroupLabelName))
+	}
+	if opts.environmentGroupLabelVersion != 0 {
+		labelSelectors = append(labelSelectors, fmt.Sprintf("%s=%d", LabelKey_EnvironmentGroupVersion, opts.environmentGroupLabelVersion))
+	}
+	labelSelector := strings.Join(labelSelectors, ",")
+	listOptions := metav1.ListOptions{
+		LabelSelector: labelSelector,
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "namespace", Value: opts.namespace},
+		telemetry.AttributeKV{Key: "label-selector", Value: labelSelector},
+	)
+
+	configMapListResp, err := a.Clientset.CoreV1().ConfigMaps(opts.namespace).List(ctx, listOptions)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "unable to list environment group variables")
+	}
+	secretListResp, err := a.Clientset.CoreV1().Secrets(opts.namespace).List(ctx, listOptions)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "unable to list environment groups secret varialbes")
+	}
+
+	// envGroupSet's key is the environment group's versioned name
+	envGroupSet := make(map[string]EnvironmentGroup)
+	for _, cm := range configMapListResp.Items {
+		name, ok := cm.Labels[LabelKey_EnvironmentGroupName]
+		if !ok {
+			continue // missing name label, not an environment group
+		}
+		versionString, ok := cm.Labels[LabelKey_EnvironmentGroupVersion]
+		if !ok {
+			continue // missing version label, not an environment group
+		}
+		version, err := strconv.Atoi(versionString)
+		if err != nil {
+			continue // invalid version label as it should be an int, not an environment group
+		}
+
+		if _, ok := envGroupSet[cm.Name]; !ok {
+			envGroupSet[cm.Name] = EnvironmentGroup{}
+		}
+		envGroupSet[cm.Name] = EnvironmentGroup{
+			Name:            name,
+			Version:         version,
+			Variables:       cm.Data,
+			SecretVariables: envGroupSet[cm.Name].SecretVariables,
+			CreatedAtUTC:    cm.CreationTimestamp.Time.UTC(),
+		}
+	}
+
+	for _, secret := range secretListResp.Items {
+		name, ok := secret.Labels[LabelKey_EnvironmentGroupName]
+		if !ok {
+			continue // missing name label, not an environment group
+		}
+		versionString, ok := secret.Labels[LabelKey_EnvironmentGroupVersion]
+		if !ok {
+			continue // missing version label, not an environment group
+		}
+		version, err := strconv.Atoi(versionString)
+		if err != nil {
+			continue // invalid version label as it should be an int, not an environment group
+		}
+		if _, ok := envGroupSet[secret.Name]; !ok {
+			envGroupSet[secret.Name] = EnvironmentGroup{}
+		}
+		envGroupSet[secret.Name] = EnvironmentGroup{
+			Name:            name,
+			Version:         version,
+			SecretVariables: secret.Data,
+			Variables:       envGroupSet[secret.Name].Variables,
+			CreatedAtUTC:    secret.CreationTimestamp.Time.UTC(),
+		}
+	}
+
+	var envGroups []EnvironmentGroup
+	for _, envGroup := range envGroupSet {
+		envGroups = append(envGroups, envGroup)
+	}
+
+	return envGroups, nil
+}
+
+// LinkedPorterApplication represents an application which was linked to an environment group
+type LinkedPorterApplication struct {
+	Name      string
+	Namespace string
+}
+
+// LinkedApplications lists all applications that are linked to a given environment group. Since there can be multiple linked environment groups we must check by the presence of a label on the deployment and job
+func LinkedApplications(ctx context.Context, a *kubernetes.Agent, environmentGroupName string) ([]LinkedPorterApplication, error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-linked-applications")
+	defer span.End()
+
+	if environmentGroupName == "" {
+		return nil, telemetry.Error(ctx, span, nil, "environment group cannot be empty")
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "environment-group-name", Value: environmentGroupName})
+
+	deployListResp, err := a.Clientset.AppsV1().Deployments(metav1.NamespaceAll).List(ctx,
+		metav1.ListOptions{
+			LabelSelector: LabelKey_LinkedEnvironmentGroup,
+		})
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "unable to list linked deployment applications")
+	}
+
+	var apps []LinkedPorterApplication
+	for _, d := range deployListResp.Items {
+		applicationsLinkedEnvironmentGroups := strings.Split(d.Labels[LabelKey_LinkedEnvironmentGroup], ".")
+
+		for _, linkedEnvironmentGroup := range applicationsLinkedEnvironmentGroups {
+			if linkedEnvironmentGroup == environmentGroupName {
+				apps = append(apps, LinkedPorterApplication{
+					Name:      d.Name,
+					Namespace: d.Namespace,
+				})
+			}
+		}
+	}
+
+	cronListResp, err := a.Clientset.BatchV1().CronJobs(metav1.NamespaceAll).List(ctx,
+		metav1.ListOptions{
+			LabelSelector: LabelKey_LinkedEnvironmentGroup,
+		})
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "unable to list linked cronjob applications")
+	}
+
+	for _, d := range cronListResp.Items {
+		applicationsLinkedEnvironmentGroups := strings.Split(d.Labels[LabelKey_LinkedEnvironmentGroup], ".")
+		for _, linkedEnvironmentGroup := range applicationsLinkedEnvironmentGroups {
+			if linkedEnvironmentGroup == environmentGroupName {
+				apps = append(apps, LinkedPorterApplication{
+					Name:      d.Name,
+					Namespace: d.Namespace,
+				})
+			}
+		}
+	}
+
+	return apps, nil
+}

+ 78 - 0
internal/kubernetes/environment_groups/sync.go

@@ -0,0 +1,78 @@
+package environment_groups
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// SyncLatestVersionToNamespaceInput contains all information required to sync an environment group from the porter-env-group namespace
+// to the given target namespace
+type SyncLatestVersionToNamespaceInput struct {
+	BaseEnvironmentGroupName string
+	TargetNamespace          string
+}
+
+// SyncLatestVersionToNamespaceOutput returns the literal configmap name (as opposed to the environment group name) which can be used in kubernetes manifests
+// for loading the configmap (or secret) into a deployment, or job
+type SyncLatestVersionToNamespaceOutput struct {
+	// EnvironmentGroupVersionedName is the name of the secret and configmap which should be able to be used in kubernetes manifests. This must already exist as both a configmap and a secret in the given namespace before being returned.
+	// EnvironmentGroupVersionedName will be of the format "<environment-group-name>.<version>"
+	EnvironmentGroupVersionedName string
+}
+
+// SyncLatestVersionToNamespace gets the latest version of a given environment group, and makes a copy of it in the target
+// namespace. If the versions match, no changes will be made. In either case, the name of an environment group in the target namespace will be returned
+// unless an error has occurred.
+func SyncLatestVersionToNamespace(ctx context.Context, a *kubernetes.Agent, inp SyncLatestVersionToNamespaceInput) (SyncLatestVersionToNamespaceOutput, error) {
+	ctx, span := telemetry.NewSpan(ctx, "sync-env-group-version-to-namespace")
+	defer span.End()
+
+	var output SyncLatestVersionToNamespaceOutput
+
+	if inp.BaseEnvironmentGroupName == "" {
+		return output, nil
+	}
+	if inp.TargetNamespace == "" {
+		return output, telemetry.Error(ctx, span, nil, "must provide a target environment group namespace to sync to")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "environment-group-name", Value: inp.BaseEnvironmentGroupName},
+		telemetry.AttributeKV{Key: "target-environment-namespace", Value: inp.TargetNamespace},
+	)
+
+	baseEnvironmentGroup, err := LatestBaseEnvironmentGroup(ctx, a, inp.BaseEnvironmentGroupName)
+	if err != nil {
+		return output, telemetry.Error(ctx, span, err, "unable to find latest environment group version")
+	}
+
+	envGroupInp := EnvironmentGroupInTargetNamespaceInput{
+		Name:      baseEnvironmentGroup.Name,
+		Version:   baseEnvironmentGroup.Version,
+		Namespace: inp.TargetNamespace,
+	}
+	targetEnvironmentGroup, err := EnvironmentGroupInTargetNamespace(ctx, a, envGroupInp)
+	if err != nil {
+		return output, telemetry.Error(ctx, span, err, "unable to get environement group in target namespace")
+	}
+
+	if targetEnvironmentGroup.Name == baseEnvironmentGroup.Name && targetEnvironmentGroup.Version == baseEnvironmentGroup.Version {
+		return SyncLatestVersionToNamespaceOutput{
+			EnvironmentGroupVersionedName: fmt.Sprintf("%s.%d", baseEnvironmentGroup.Name, baseEnvironmentGroup.Version),
+		}, nil
+	}
+
+	targetConfigmapName, err := createEnvironmentGroupInTargetNamespace(ctx, a, inp.TargetNamespace, baseEnvironmentGroup)
+	if err != nil {
+		return output, telemetry.Error(ctx, span, err, "unable to create environment group in target namespace")
+	}
+
+	output = SyncLatestVersionToNamespaceOutput{
+		EnvironmentGroupVersionedName: targetConfigmapName,
+	}
+
+	return output, nil
+}

+ 5 - 2
zarf/helm/.serverenv

@@ -33,8 +33,11 @@ GITHUB_APP_SECRET_PATH=<path_to_secret>
 
 # Optional parameters
 
-HELM_APP_REPO_URL=https://charts.getporter.dev
-HELM_ADD_ON_REPO_URL=https://charts.getporter.dev
+# HELM_APP_REPO_URL=http://chartmuseum:8080 can be used to test charts using porter-charts with Tilt
+
+HELM_APP_REPO_URL=http://chartmuseum:8080
+#HELM_APP_REPO_URL=https://charts.getporter.dev
+#HELM_ADD_ON_REPO_URL=https://charts.getporter.dev
 
 # SERVER_URL must be set to your ngrok url, If you are using ngrok for your github app setup on the backend