Explorar el Código

implement rds endpoints

jnfrati hace 4 años
padre
commit
57862aedfc
Se han modificado 100 ficheros con 6630 adiciones y 1169 borrados
  1. 21 0
      api/client/k8s.go
  2. 47 0
      api/server/handlers/database/list.go
  3. 72 6
      api/server/handlers/infra/delete.go
  4. 195 0
      api/server/handlers/infra/retry.go
  5. 2 2
      api/server/handlers/infra/stream_logs.go
  6. 23 33
      api/server/handlers/namespace/add_env_group_app.go
  7. 79 0
      api/server/handlers/namespace/clone_env_group.go
  8. 0 102
      api/server/handlers/namespace/create_configmap.go
  9. 369 0
      api/server/handlers/namespace/create_env_group.go
  10. 0 66
      api/server/handlers/namespace/delete_configmap.go
  11. 89 0
      api/server/handlers/namespace/delete_env_group.go
  12. 19 13
      api/server/handlers/namespace/get_env_group.go
  13. 82 0
      api/server/handlers/namespace/get_env_group_all_versions.go
  14. 0 48
      api/server/handlers/namespace/list_configmaps.go
  15. 93 0
      api/server/handlers/namespace/list_env_groups.go
  16. 89 0
      api/server/handlers/namespace/remove_env_group_app.go
  17. 32 3
      api/server/handlers/namespace/update_configmap.go
  18. 1 0
      api/server/handlers/provision/helpers.go
  19. 371 0
      api/server/handlers/provision/provision_rds.go
  20. 29 0
      api/server/router/cluster.go
  21. 28 0
      api/server/router/infra.go
  22. 158 36
      api/server/router/namespace.go
  23. 2 1
      api/server/shared/config/env/envconfs.go
  24. 21 0
      api/types/database.go
  25. 2 0
      api/types/infra.go
  26. 53 0
      api/types/namespace.go
  27. 5 4
      api/types/project.go
  28. 156 2
      api/types/provision.go
  29. 33 0
      api/types/provision_test.go
  30. 2 2
      cli/cmd/deploy/create.go
  31. 135 7
      cli/cmd/deploy/deploy.go
  32. 3 3
      cmd/app/main.go
  33. 1 1
      dashboard/babel.config.json
  34. 26 0
      dashboard/package-lock.json
  35. 3 0
      dashboard/package.json
  36. 84 38
      dashboard/src/components/DocsHelper.tsx
  37. 1 0
      dashboard/src/components/ProvisionerStatus.tsx
  38. 2 2
      dashboard/src/components/Selector.tsx
  39. 24 10
      dashboard/src/components/TabSelector.tsx
  40. 5 1
      dashboard/src/components/Table.tsx
  41. 17 1
      dashboard/src/components/TitleSection.tsx
  42. 4 6
      dashboard/src/components/form-components/Helper.tsx
  43. 3 1
      dashboard/src/components/form-components/SelectRow.tsx
  44. 1 0
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  45. 335 22
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  46. 14 1
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  47. 21 0
      dashboard/src/components/porter-form/types.ts
  48. 29 0
      dashboard/src/components/porter-form/utils.ts
  49. 1 0
      dashboard/src/main/home/Home.tsx
  50. 11 0
      dashboard/src/main/home/ModalHandler.tsx
  51. 10 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  52. 20 12
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  53. 302 0
      dashboard/src/main/home/cluster-dashboard/databases/CreateDatabaseForm.tsx
  54. 75 0
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesHome.tsx
  55. 351 0
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  56. 25 0
      dashboard/src/main/home/cluster-dashboard/databases/mock_data.ts
  57. 37 0
      dashboard/src/main/home/cluster-dashboard/databases/routes.tsx
  58. 114 0
      dashboard/src/main/home/cluster-dashboard/databases/static_data.ts
  59. 2 1
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  60. 9 7
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  61. 143 228
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  62. 1 2
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  63. 6 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  64. 520 334
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  65. 47 0
      dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts
  66. 112 11
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  67. 27 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  68. 84 32
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  69. 54 0
      dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx
  70. 141 41
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  71. 1 1
      dashboard/src/main/home/modals/Modal.tsx
  72. 6 1
      dashboard/src/main/home/navbar/Help.tsx
  73. 1 0
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  74. 56 6
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx
  75. 21 7
      dashboard/src/main/home/sidebar/Sidebar.tsx
  76. 138 3
      dashboard/src/shared/api.tsx
  77. 10 0
      dashboard/src/shared/array_utils.ts
  78. 5 0
      dashboard/src/shared/common.tsx
  79. 3 1
      dashboard/src/shared/routing.tsx
  80. 7 1
      dashboard/src/shared/types.tsx
  81. 5 1
      dashboard/webpack.config.js
  82. 0 2
      ee/docker/ee.Dockerfile
  83. 24 6
      ee/integrations/httpbackend/types.go
  84. 22 27
      internal/helm/agent.go
  85. 304 7
      internal/helm/postrenderer.go
  86. 415 1
      internal/kubernetes/agent.go
  87. 232 0
      internal/kubernetes/envgroup/create.go
  88. 15 0
      internal/kubernetes/envgroup/delete.go
  89. 33 0
      internal/kubernetes/envgroup/get.go
  90. 112 0
      internal/kubernetes/provisioner/aws/rds/rds.go
  91. 31 2
      internal/kubernetes/provisioner/provisioner.go
  92. 36 0
      internal/models/database.go
  93. 15 0
      internal/models/infra.go
  94. 10 5
      internal/models/project.go
  95. 154 1
      internal/redis_stream/global_stream.go
  96. 1 1
      internal/redis_stream/resource_stream.go
  97. 14 0
      internal/repository/database.go
  98. 79 0
      internal/repository/gorm/database.go
  99. 1 0
      internal/repository/gorm/migrate.go
  100. 6 0
      internal/repository/gorm/repository.go

+ 21 - 0
api/client/k8s.go

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

+ 47 - 0
api/server/handlers/database/list.go

@@ -0,0 +1,47 @@
+package database
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DatabaseListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewDatabaseListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *DatabaseListHandler {
+	return &DatabaseListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *DatabaseListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project from context
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	// read all clusters for this project
+	dbs, err := p.Repo().Database().ListDatabases(proj.ID, cluster.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListDatabaseResponse, len(dbs))
+
+	for i, db := range dbs {
+		res[i] = db.ToDatabaseType()
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 72 - 6
api/server/handlers/infra/delete.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/rds"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
@@ -37,12 +38,6 @@ func NewInfraDeleteHandler(
 func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
 
-	request := &types.DeleteInfraRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
 	if infra.Kind == types.InfraDOKS || infra.Kind == types.InfraGKE || infra.Kind == types.InfraEKS {
 		c.Config().AnalyticsClient.Track(analytics.ClusterDestroyingStartTrack(
 			&analytics.ClusterDestroyingStartTrackOpts{
@@ -72,6 +67,8 @@ func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		err = destroyDOKS(c.Config(), infra)
 	case types.InfraGKE:
 		err = destroyGKE(c.Config(), infra)
+	case types.InfraRDS:
+		err = destroyRDS(c.Config(), infra)
 	}
 
 	if err != nil {
@@ -161,6 +158,75 @@ func destroyEKS(conf *config.Config, infra *models.Infra) error {
 	return err
 }
 
+func destroyRDS(conf *config.Config, infra *models.Infra) error {
+	// find the database and mark as deleting
+	database, err := conf.Repo.Database().ReadDatabaseByInfraID(infra.ProjectID, infra.ID)
+
+	if err != nil {
+		return err
+	}
+
+	database.Status = "destroying"
+
+	database, err = conf.Repo.Database().UpdateDatabase(database)
+
+	if err != nil {
+		return err
+	}
+
+	lastAppliedRDS := &types.RDSInfraLastApplied{}
+
+	// parse infra last applied into EKS config
+	if err := json.Unmarshal(infra.LastApplied, lastAppliedRDS); err != nil {
+		return err
+	}
+
+	awsInt, err := conf.Repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
+
+	if err != nil {
+		return err
+	}
+
+	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
+
+	vaultToken := ""
+
+	if conf.CredentialBackend != nil {
+		vaultToken, err = conf.CredentialBackend.CreateAWSToken(awsInt)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+
+	opts.RDS = &rds.Conf{
+		AWSRegion:             awsInt.AWSRegion,
+		DBName:                lastAppliedRDS.DBName,
+		MachineType:           lastAppliedRDS.MachineType,
+		DBEngineVersion:       lastAppliedRDS.DBEngineVersion,
+		DBFamily:              lastAppliedRDS.DBFamily,
+		DBMajorEngineVersion:  lastAppliedRDS.DBMajorEngineVersion,
+		DBAllocatedStorage:    lastAppliedRDS.DBStorage,
+		DBMaxAllocatedStorage: lastAppliedRDS.DBMaxStorage,
+		DBStorageEncrypted:    lastAppliedRDS.DBStorageEncrypted,
+		Username:              lastAppliedRDS.Username,
+		Password:              lastAppliedRDS.Password,
+		VPCID:                 lastAppliedRDS.VPCID,
+		Subnet1:               lastAppliedRDS.Subnet1,
+		Subnet2:               lastAppliedRDS.Subnet2,
+		Subnet3:               lastAppliedRDS.Subnet3,
+		DeletionProtection:    lastAppliedRDS.DeletionProtection,
+	}
+
+	opts.OperationKind = provisioner.Destroy
+
+	err = conf.ProvisionerAgent.Provision(opts)
+
+	return err
+}
+
 func destroyDOCR(conf *config.Config, infra *models.Infra) error {
 	lastAppliedDOCR := &types.CreateDOCRInfraRequest{}
 

+ 195 - 0
api/server/handlers/infra/retry.go

@@ -0,0 +1,195 @@
+package infra
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/provision"
+	"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/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gcr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type InfraRetryHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraRetryHandler(config *config.Config, writer shared.ResultWriter) *InfraRetryHandler {
+	return &InfraRetryHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraRetryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infraModel, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	if infraModel.Status != types.StatusError {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+			fmt.Errorf("only errored infras maybe retried")))
+		return
+	}
+
+	opts, err := c.getProvisioningOpts(infraModel)
+	if err != nil {
+		c.HandleAPIError(w, r, err)
+		return
+	}
+
+	opts.OperationKind = provisioner.Apply
+
+	provisionerErr := c.Config().ProvisionerAgent.Provision(opts)
+	if provisionerErr != nil {
+		infraModel.Status = types.StatusError
+		c.Repo().Infra().UpdateInfra(infraModel)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(provisionerErr))
+		return
+	}
+
+	infraModel.Status = types.StatusCreating
+	infraModel, _ = c.Repo().Infra().UpdateInfra(infraModel)
+
+	c.WriteResult(w, r, infraModel.ToInfraType())
+}
+
+func (c *InfraRetryHandler) getProvisioningOpts(infraModel *models.Infra) (*provisioner.ProvisionOpts, apierrors.RequestError) {
+	var vaultToken string
+	var opts *provisioner.ProvisionOpts
+
+	infra := infraModel.ToInfraType()
+
+	switch infra.Kind {
+	// ==================== Infrastructure Google Cloud ======================
+	case types.InfraGKE, types.InfraGCR:
+		integration, err := c.Repo().GCPIntegration().ReadGCPIntegration(infra.ProjectID, infra.GCPIntegrationID)
+		if err != nil {
+			return nil, c._qualifyGormError(err)
+		}
+
+		opts, err = c.getOptions(infraModel)
+		if err != nil {
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		if c.Config().CredentialBackend != nil {
+			vaultToken, err = c.Config().CredentialBackend.CreateGCPToken(integration)
+			if err != nil {
+				return nil, apierrors.NewErrInternal(err)
+			}
+		}
+
+		if infra.Kind == types.InfraGKE {
+			opts.GKE = &gke.Conf{
+				GCPProjectID: integration.GCPProjectID,
+				GCPRegion:    integration.GCPRegion,
+				ClusterName:  infra.LastApplied["gke_name"],
+			}
+		} else {
+			opts.GCR = &gcr.Conf{
+				GCPProjectID: integration.GCPProjectID,
+			}
+		}
+
+	// ========================== Infrastructure AWS ============================
+	case types.InfraEKS, types.InfraECR:
+		integration, err := c.Repo().AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
+		if err != nil {
+			return nil, c._qualifyGormError(err)
+		}
+
+		opts, err = c.getOptions(infraModel)
+		if err != nil {
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		if c.Config().CredentialBackend != nil {
+			vaultToken, err = c.Config().CredentialBackend.CreateAWSToken(integration)
+			if err != nil {
+				return nil, apierrors.NewErrInternal(err)
+			}
+		}
+
+		if infra.Kind == types.InfraEKS {
+			opts.EKS = &eks.Conf{
+				AWSRegion:   integration.AWSRegion,
+				ClusterName: infra.LastApplied["eks_name"],
+				MachineType: infra.LastApplied["machine_type"],
+				IssuerEmail: infra.LastApplied["issuer_email"],
+			}
+		} else {
+			opts.ECR = &ecr.Conf{
+				AWSRegion: integration.AWSRegion,
+				ECRName:   infra.LastApplied["ecr_name"],
+			}
+		}
+
+	// ========================== Infrastructure Digital Ocean ============================
+	case types.InfraDOKS, types.InfraDOCR:
+		integration, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+		if err != nil {
+			return nil, c._qualifyGormError(err)
+		}
+
+		opts, err = c.getOptions(infraModel)
+		if err != nil {
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		if c.Config().CredentialBackend != nil {
+			vaultToken, err = c.Config().CredentialBackend.CreateOAuthToken(integration)
+			if err != nil {
+				return nil, apierrors.NewErrInternal(err)
+			}
+		}
+
+		if infra.Kind == types.InfraDOKS {
+			opts.DOKS = &doks.Conf{
+				DORegion:        infra.LastApplied["do_region"],
+				DOKSClusterName: infra.LastApplied["doks_name"],
+				IssuerEmail:     infra.LastApplied["issuer_email"],
+			}
+		} else {
+			opts.DOCR = &docr.Conf{
+				DOCRName:             infra.LastApplied["docr_name"],
+				DOCRSubscriptionTier: infra.LastApplied["docr_subscription_tier"],
+			}
+		}
+
+	default:
+		// infra == InfraTest
+		panic("not implemented!")
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+	opts.OperationKind = provisioner.Apply
+
+	return opts, nil
+}
+
+func (c *InfraRetryHandler) _qualifyGormError(err error) apierrors.RequestError {
+	if err == gorm.ErrRecordNotFound {
+		return apierrors.NewErrForbidden(err)
+	} else {
+		return apierrors.NewErrInternal(err)
+	}
+}
+
+func (c *InfraRetryHandler) getOptions(infraModel *models.Infra) (*provisioner.ProvisionOpts, error) {
+	// get provisioner options
+	opts, err := provision.GetSharedProvisionerOpts(c.Config(), infraModel)
+	if err != nil {
+		return nil, apierrors.NewErrInternal(err)
+	}
+
+	return opts, nil
+}

+ 2 - 2
api/server/handlers/infra/stream_logs.go

@@ -10,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/redis_stream"
 )
 
 type InfraStreamLogsHandler struct {
@@ -38,7 +38,7 @@ func (c *InfraStreamLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	err = provisioner.ResourceStream(client, infra.GetUniqueName(), safeRW)
+	err = redis_stream.ResourceStream(client, infra.GetUniqueName(), safeRW)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 23 - 33
api/server/handlers/namespace/rename_configmap.go → api/server/handlers/namespace/add_env_group_app.go

@@ -1,6 +1,8 @@
 package namespace
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -9,37 +11,34 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type RenameConfigMapHandler struct {
+type AddEnvGroupAppHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-func NewRenameConfigMapHandler(
+func NewAddEnvGroupAppHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *RenameConfigMapHandler {
-	return &RenameConfigMapHandler{
+) *AddEnvGroupAppHandler {
+	return &AddEnvGroupAppHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
-func (c *RenameConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	request := &types.RenameConfigMapRequest{}
+func (c *AddEnvGroupAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.AddEnvGroupApplicationRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
 
-	if request.NewName == request.Name {
-		w.WriteHeader(http.StatusBadRequest)
-		return
-	}
-
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
@@ -50,44 +49,35 @@ func (c *RenameConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	configMap, err := agent.GetConfigMap(request.Name, namespace)
+	// read the attached configmap
+	cm, _, err := agent.GetLatestVersionedConfigMap(request.Name, namespace)
 
-	if err != nil {
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	secret, err := agent.GetSecret(configMap.Name, configMap.Namespace)
+	// TODO: verify that application exists
+
+	cm, err = agent.AddApplicationToVersionedConfigMap(cm, request.ApplicationName)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	var decodedSecretData = make(map[string]string)
-	for k, v := range secret.Data {
-		decodedSecretData[k] = string(v)
-	}
+	res, err := envgroup.ToEnvGroup(cm)
 
-	newConfigMap, err := createConfigMap(agent, types.ConfigMapInput{
-		Name:            request.NewName,
-		Namespace:       namespace,
-		Variables:       configMap.Data,
-		SecretVariables: decodedSecretData,
-	})
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	if err := deleteConfigMap(agent, configMap.Name, configMap.Namespace); err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	res := types.RenameConfigMapResponse{
-		ConfigMap: newConfigMap,
-	}
-
 	c.WriteResult(w, r, res)
 }

+ 79 - 0
api/server/handlers/namespace/clone_env_group.go

@@ -0,0 +1,79 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CloneEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCloneEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CloneEnvGroupHandler {
+	return &CloneEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.CloneEnvGroupRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, request.Namespace, request.Version)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if request.CloneName == "" {
+		request.CloneName = request.Name
+	}
+
+	configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
+		Name:      request.CloneName,
+		Namespace: namespace,
+		Variables: envGroup.Variables,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err = envgroup.ToEnvGroup(configMap)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, envGroup)
+}

+ 0 - 102
api/server/handlers/namespace/create_configmap.go

@@ -1,102 +0,0 @@
-package namespace
-
-import (
-	"fmt"
-	"net/http"
-
-	v1 "k8s.io/api/core/v1"
-
-	"github.com/porter-dev/porter/api/server/authz"
-	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
-	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-type CreateConfigMapHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewCreateConfigMapHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *CreateConfigMapHandler {
-	return &CreateConfigMapHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
-	}
-}
-
-func (c *CreateConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	request := &types.CreateConfigMapRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	namespace := r.Context().Value(types.NamespaceScope).(string)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	configMap, err := createConfigMap(agent, types.ConfigMapInput{
-		Name:            request.Name,
-		Namespace:       namespace,
-		Variables:       request.Variables,
-		SecretVariables: request.SecretVariables,
-	})
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	var res = types.CreateConfigMapResponse{
-		ConfigMap: configMap,
-	}
-
-	c.WriteResult(w, r, res)
-}
-
-func createConfigMap(agent *kubernetes.Agent, input types.ConfigMapInput) (*v1.ConfigMap, error) {
-	secretData := encodeSecrets(input.SecretVariables)
-
-	// create secret first
-	if _, err := agent.CreateLinkedSecret(input.Name, input.Namespace, input.Name, secretData); err != nil {
-		return nil, err
-	}
-
-	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
-	for key := range input.SecretVariables {
-		input.Variables[key] = fmt.Sprintf("PORTERSECRET_%s", input.Name)
-	}
-
-	return agent.CreateConfigMap(input.Name, input.Namespace, input.Variables)
-}
-
-func encodeSecrets(data map[string]string) map[string][]byte {
-	res := make(map[string][]byte)
-
-	for key, rawValue := range data {
-		// encodedValue := base64.StdEncoding.EncodeToString([]byte(rawValue))
-
-		// if err != nil {
-		// 	app.handleErrorInternal(err, w)
-		// 	return
-		// }
-
-		res[key] = []byte(rawValue)
-	}
-
-	return res
-}

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

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

+ 0 - 66
api/server/handlers/namespace/delete_configmap.go

@@ -1,66 +0,0 @@
-package namespace
-
-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"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-type DeleteConfigMapHandler struct {
-	handlers.PorterHandlerReader
-	authz.KubernetesAgentGetter
-}
-
-func NewDeleteConfigMapHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) *DeleteConfigMapHandler {
-	return &DeleteConfigMapHandler{
-		PorterHandlerReader:   handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
-		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
-	}
-}
-
-func (c *DeleteConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	request := &types.DeleteConfigMapRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	namespace := r.Context().Value(types.NamespaceScope).(string)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	if err := deleteConfigMap(agent, request.Name, namespace); err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	w.WriteHeader(http.StatusOK)
-}
-
-func deleteConfigMap(agent *kubernetes.Agent, name, namespace string) error {
-	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
-		return err
-	}
-
-	if err := agent.DeleteConfigMap(name, namespace); err != nil {
-		return err
-	}
-
-	return nil
-}

+ 89 - 0
api/server/handlers/namespace/delete_env_group.go

@@ -0,0 +1,89 @@
+package namespace
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DeleteEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteEnvGroupHandler {
+	return &DeleteEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeleteEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.DeleteEnvGroupRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get the env group: if it's MetaVersion=2, return an error
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, 0)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if envGroup != nil && envGroup.MetaVersion == 1 {
+		if err := deleteV1ConfigMap(agent, request.Name, namespace); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if envGroup != nil && envGroup.MetaVersion == 2 {
+		if len(envGroup.Applications) != 0 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("env group must not have any connected applications"),
+				http.StatusNotFound,
+			))
+
+			return
+		} else if err = envgroup.DeleteEnvGroup(agent, request.Name, namespace); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}
+
+func deleteV1ConfigMap(agent *kubernetes.Agent, name, namespace string) error {
+	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
+		return err
+	}
+
+	if err := agent.DeleteConfigMap(name, namespace); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 19 - 13
api/server/handlers/namespace/get_configmap.go → api/server/handlers/namespace/get_env_group.go

@@ -1,6 +1,8 @@
 package namespace
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -9,27 +11,29 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type GetConfigMapHandler struct {
+type GetEnvGroupHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-func NewGetConfigMapHandler(
+func NewGetEnvGroupHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *GetConfigMapHandler {
-	return &GetConfigMapHandler{
+) *GetEnvGroupHandler {
+	return &GetEnvGroupHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
-func (c *GetConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	request := &types.GetConfigMapRequest{}
+func (c *GetEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetEnvGroupRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
@@ -45,16 +49,18 @@ func (c *GetConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	configMap, err := agent.GetConfigMap(request.Name, namespace)
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, request.Version)
 
-	if err != nil {
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	var res = types.GetConfigMapResponse{
-		ConfigMap: configMap,
-	}
-
-	c.WriteResult(w, r, res)
+	c.WriteResult(w, r, envGroup)
 }

+ 82 - 0
api/server/handlers/namespace/get_env_group_all_versions.go

@@ -0,0 +1,82 @@
+package namespace
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetEnvGroupAllVersionsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetEnvGroupAllVersionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetEnvGroupAllVersionsHandler {
+	return &GetEnvGroupAllVersionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetEnvGroupAllVersionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetEnvGroupAllRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMaps, err := agent.ListVersionedConfigMaps(request.Name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListEnvGroupsResponse, 0)
+
+	for _, cm := range configMaps {
+		eg, err := envgroup.ToEnvGroup(&cm)
+
+		if err != nil {
+			continue
+		}
+
+		res = append(res, &types.EnvGroupMeta{
+			Name:      eg.Name,
+			Namespace: eg.Namespace,
+			Version:   eg.Version,
+		})
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 0 - 48
api/server/handlers/namespace/list_configmaps.go

@@ -1,48 +0,0 @@
-package namespace
-
-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/models"
-)
-
-type ListConfigMapsHandler struct {
-	handlers.PorterHandlerWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewListConfigMapsHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) *ListConfigMapsHandler {
-	return &ListConfigMapsHandler{
-		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
-		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
-	}
-}
-
-func (c *ListConfigMapsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace := r.Context().Value(types.NamespaceScope).(string)
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	configMaps, err := agent.ListConfigMaps(namespace)
-
-	var res = types.ListConfigMapsResponse{
-		ConfigMapList: configMaps,
-	}
-
-	c.WriteResult(w, r, res)
-}

+ 93 - 0
api/server/handlers/namespace/list_env_groups.go

@@ -0,0 +1,93 @@
+package namespace
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListEnvGroupsHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListEnvGroupsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListEnvGroupsHandler {
+	return &ListEnvGroupsHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListEnvGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get all versioned config maps
+	configMaps, err := agent.ListAllVersionedConfigMaps(namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make(types.ListEnvGroupsResponse, 0)
+
+	for _, cm := range configMaps {
+		eg, err := envgroup.ToEnvGroup(&cm)
+
+		if err != nil {
+			continue
+		}
+
+		res = append(res, &types.EnvGroupMeta{
+			MetaVersion: eg.MetaVersion,
+			CreatedAt:   eg.CreatedAt,
+			Name:        eg.Name,
+			Namespace:   eg.Namespace,
+			Version:     eg.Version,
+		})
+	}
+
+	// get all meta-version 1 configmaps
+	configMapList, err := agent.ListConfigMaps(namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, v1CM := range configMapList.Items {
+		eg, err := envgroup.ToEnvGroup(&v1CM)
+
+		if err != nil {
+			continue
+		}
+
+		res = append(res, &types.EnvGroupMeta{
+			MetaVersion: eg.MetaVersion,
+			CreatedAt:   eg.CreatedAt,
+			Name:        eg.Name,
+			Namespace:   eg.Namespace,
+			Version:     eg.Version,
+		})
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 89 - 0
api/server/handlers/namespace/remove_env_group_app.go

@@ -0,0 +1,89 @@
+package namespace
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RemoveEnvGroupAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRemoveEnvGroupAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RemoveEnvGroupAppHandler {
+	return &RemoveEnvGroupAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RemoveEnvGroupAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.AddEnvGroupApplicationRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the attached configmap
+	cm, _, err := agent.GetLatestVersionedConfigMap(request.Name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// TODO: verify that application exists
+
+	cm, err = agent.RemoveApplicationFromVersionedConfigMap(cm, request.ApplicationName)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res, err := envgroup.ToEnvGroup(cm)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 32 - 3
api/server/handlers/namespace/update_configmap.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -46,7 +47,20 @@ func (c *UpdateConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	secretData := encodeSecrets(request.SecretVariables)
+	// get the env group: if it's MetaVersion=2, return an error
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, 0)
+
+	// if the environment group exists and has MetaVersion=2, throw an error
+	if envGroup != nil && envGroup.MetaVersion == 2 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("unsupported operation for versioned env groups"),
+			http.StatusNotFound,
+		))
+
+		return
+	}
+
+	secretData := envgroup.EncodeSecrets(request.SecretVariables)
 
 	// create secret first
 	err = agent.UpdateLinkedSecret(request.Name, namespace, request.Name, secretData)
@@ -68,8 +82,23 @@ func (c *UpdateConfigMapHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	configMap, err := agent.UpdateConfigMap(request.Name, namespace, request.Variables)
 
-	res := types.UpdateConfigMapResponse{
-		ConfigMap: configMap,
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMap, err = envgroup.ConvertV1ToV2EnvGroup(agent, request.Name, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res, err := envgroup.ToEnvGroup(configMap)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
 	c.WriteResult(w, r, res)

+ 1 - 0
api/server/handlers/provision/helpers.go

@@ -60,6 +60,7 @@ func GetSharedProvisionerOpts(conf *config.Config, infra *models.Infra) (*provis
 		ProvJobNamespace:    conf.ServerConf.ProvisionerJobNamespace,
 		ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
 		TFHTTPBackendURL:    conf.ServerConf.ProvisionerBackendURL,
+		ProvisionerTest:     conf.ServerConf.ProvisionerTest,
 		CredentialExchange: &provisioner.ProvisionCredentialExchange{
 			CredExchangeEndpoint: fmt.Sprintf("%s/api/internal/credentials", conf.ServerConf.ProvisionerCredExchangeURL),
 			CredExchangeToken:    rawToken,

+ 371 - 0
api/server/handlers/provision/provision_rds.go

@@ -0,0 +1,371 @@
+package provision
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/mitchellh/mapstructure"
+
+	"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/ee/integrations/httpbackend"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/rds"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type ProvisionRDSHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewProvisionRDSHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ProvisionRDSHandler {
+	return &ProvisionRDSHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ProvisionRDSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreateRDSInfraRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// validate db version and family
+	if v, ok := types.DBVersionMapping[types.Family(request.DBFamily)]; !ok {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			errors.New("DB family does not exist"), http.StatusBadRequest))
+
+		return
+	} else {
+		if !v.VersionExists(types.EngineVersion(request.DBEngineVersion)) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				errors.New("DB version not available for the given family"), http.StatusBadRequest))
+
+			return
+		}
+	}
+
+	dbVersion := types.EngineVersion(request.DBEngineVersion)
+
+	clusterInfra, err := c.Repo().Infra().ReadInfra(proj.ID, cluster.InfraID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("empty cluster infra, projectID: %d, infraID: %d", proj.ID, cluster.InfraID),
+			http.StatusNotFound,
+		))
+
+		return
+	}
+
+	// get the tfstate from the HTTP backend using the infra ID
+
+	client := httpbackend.NewClient(c.Config().ServerConf.ProvisionerBackendURL)
+
+	// get the unique infra name and query from the TF HTTP backend
+	currentState, err := client.GetCurrentState(clusterInfra.GetUniqueName())
+	if err != nil && errors.Is(err, httpbackend.ErrNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			err,
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var vpc, region string
+	var subnets []string
+
+	var opts *provisioner.ProvisionOpts
+	vaultToken := ""
+
+	vpc, subnets, err = c.ExtractVPCFromEKSTFState(currentState, "aws_eks_cluster.cluster")
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			err,
+			http.StatusInternalServerError,
+		))
+
+		return
+	}
+
+	suffix, err := repository.GenerateRandomBytes(6)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	dbInfra := &models.Infra{
+		ProjectID:       proj.ID,
+		Status:          types.StatusCreating,
+		Suffix:          suffix,
+		CreatedByUserID: user.ID,
+	}
+
+	switch clusterInfra.Kind {
+	case types.InfraGKE:
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			errors.New("not implemented"),
+			http.StatusNotImplemented,
+		))
+
+		return
+
+		// dbInfra.Kind = types.InfraRDS // this will change to Google Cloud SQL once supported
+		// dbInfra.GCPIntegrationID = clusterInfra.GCPIntegrationID
+
+		// integration, err := c.Repo().GCPIntegration().ReadGCPIntegration(clusterInfra.ProjectID, clusterInfra.GCPIntegrationID)
+		// if err != nil {
+		// 	if err == gorm.ErrRecordNotFound {
+		// 		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		// 	} else {
+		// 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		// 	}
+
+		// 	return
+		// }
+
+		// region = integration.GCPRegion
+
+		// if c.Config().CredentialBackend != nil {
+		// 	vaultToken, err = c.Config().CredentialBackend.CreateGCPToken(integration)
+		// 	if err != nil {
+		// 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		// 	}
+		// }
+
+		// vpc, err = c.ExtractVPCFromGKETFState(currentState, "google_compute_network.vpc")
+		// subnets = []string{}
+		// if err != nil {
+		// 	c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+		// 		err,
+		// 		http.StatusInternalServerError,
+		// 	))
+
+		// 	return
+		// }
+
+	case types.InfraEKS:
+		dbInfra.Kind = types.InfraRDS
+		dbInfra.AWSIntegrationID = clusterInfra.AWSIntegrationID
+
+		integration, err := c.Repo().AWSIntegration().ReadAWSIntegration(clusterInfra.ProjectID, clusterInfra.AWSIntegrationID)
+		if err != nil {
+			if err == gorm.ErrRecordNotFound {
+				c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+			} else {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			}
+
+			return
+		}
+
+		region = integration.AWSRegion
+
+		if c.Config().CredentialBackend != nil {
+			vaultToken, err = c.Config().CredentialBackend.CreateAWSToken(integration)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			}
+		}
+
+		vpc, subnets, err = c.ExtractVPCFromEKSTFState(currentState, "aws_eks_cluster.cluster")
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				err,
+				http.StatusInternalServerError,
+			))
+
+			return
+		}
+
+	case types.InfraDOKS:
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			errors.New("not implemented"),
+			http.StatusNotImplemented,
+		))
+
+		return
+	}
+
+	if len(subnets) != 3 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			errors.New("Length of subnets is not 3: not a valid VPC"),
+			http.StatusNotImplemented,
+		))
+
+		return
+	}
+
+	lastAppliedData := &types.RDSInfraLastApplied{
+		CreateRDSInfraRequest: request,
+		ClusterID:             cluster.ID,
+		Namespace:             namespace,
+		AWSRegion:             region,
+		DBMajorEngineVersion:  dbVersion.MajorVersion(),
+		DBStorageEncrypted:    strconv.FormatBool(request.DBEncryption),
+		DeletionProtection:    strconv.FormatBool(true),
+		VPCID:                 vpc,
+		Subnet1:               subnets[0],
+		Subnet2:               subnets[1],
+		Subnet3:               subnets[2],
+	}
+
+	lastApplied, err := json.Marshal(lastAppliedData)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	dbInfra.LastApplied = lastApplied
+
+	// handle write to the database
+	infra, err := c.Repo().Infra().CreateInfra(dbInfra)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	opts, err = GetSharedProvisionerOpts(c.Config(), infra)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	opts.CredentialExchange.VaultToken = vaultToken
+
+	opts.RDS = &rds.Conf{
+		AWSRegion:             region,
+		DBName:                request.DBName,
+		MachineType:           request.MachineType,
+		DBEngineVersion:       request.DBEngineVersion,
+		DBFamily:              request.DBFamily,
+		DBMajorEngineVersion:  dbVersion.MajorVersion(),
+		DBAllocatedStorage:    request.DBStorage,
+		DBMaxAllocatedStorage: request.DBMaxStorage,
+		DBStorageEncrypted:    strconv.FormatBool(request.DBEncryption),
+		Username:              request.Username,
+		Password:              request.Password,
+		VPCID:                 vpc,
+		DeletionProtection:    strconv.FormatBool(true),
+		Subnet1:               subnets[0],
+		Subnet2:               subnets[1],
+		Subnet3:               subnets[2],
+	}
+
+	opts.OperationKind = provisioner.Apply
+
+	err = c.Config().ProvisionerAgent.Provision(opts)
+	if err != nil {
+		infra.Status = types.StatusError
+		infra, _ = c.Repo().Infra().UpdateInfra(infra)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, infra.ToInfraType())
+}
+
+func (c *ProvisionRDSHandler) ExtractVPCFromEKSTFState(tfState *httpbackend.TFState, resourceIdentifier string) (string, []string, error) {
+	for _, resource := range tfState.Resources {
+		if resourceIdentifier == resource.Type+"."+resource.Name {
+			for _, instance := range resource.Instances {
+				vpcConfig, ok := instance.Attributes["vpc_config"]
+				if !ok {
+					return "", []string{}, errors.New("name not found for the requested resource name-type")
+				}
+
+				awsVPCConfigIface, ok := vpcConfig.([]interface{})
+				if !ok {
+					fmt.Printf("%#v\n", vpcConfig)
+					return "", []string{}, errors.New("cannot cast returned value to vpc config")
+				}
+
+				if len(awsVPCConfigIface) == 0 {
+					return "", []string{}, errors.New("empty vpc config")
+				}
+
+				awsVPCConfigMap, ok := awsVPCConfigIface[0].(map[string]interface{})
+				if !ok {
+					return "", []string{}, errors.New("cannot cast returned value to vpc config map")
+				}
+
+				var awsVPCConfig httpbackend.AWSVPCConfig
+
+				err := mapstructure.Decode(awsVPCConfigMap, &awsVPCConfig)
+				if err != nil {
+					return "", []string{}, errors.New("cannot cast returned value to vpc config")
+				}
+
+				return awsVPCConfig.VPCID, awsVPCConfig.SubNetIDs, nil
+			}
+
+			return "", []string{}, errors.New("name not found for the requested resource name-type")
+			// return c._extractVPCFromResourceInstance(resource, "id")
+		}
+	}
+
+	return "", []string{}, errors.New("name not found for the requested resource name-type")
+}
+
+func (c *ProvisionRDSHandler) ExtractVPCFromGKETFState(tfState *httpbackend.TFState, resourceIdentifier string) (string, error) {
+	for _, resource := range tfState.Resources {
+		// fmt.Printf("%s.%s\n", resource.Type, resource.Name)
+
+		if resourceIdentifier == resource.Type+"."+resource.Name {
+			return c._extractVPCFromResourceInstance(resource, "name")
+		}
+	}
+
+	return "", errors.New("name not found for the requested resource name-type")
+}
+
+func (c *ProvisionRDSHandler) _extractVPCFromResourceInstance(resource httpbackend.TFStateResource, attributeName string) (string, error) {
+	for _, instance := range resource.Instances {
+		vpc, ok := instance.Attributes[attributeName]
+		if !ok {
+			return "", errors.New("name not found for the requested resource name-type")
+		}
+
+		vpcName, ok := vpc.(string)
+		if !ok {
+			return "", errors.New("cannot cast returned value to string")
+		}
+
+		return vpcName, nil
+	}
+
+	return "", errors.New("name not found for the requested resource name-type")
+}
+
+func (c *ProvisionRDSHandler) _qualifyGormError(err error) apierrors.RequestError {
+	if err == gorm.ErrRecordNotFound {
+		return apierrors.NewErrForbidden(err)
+	} else {
+		return apierrors.NewErrInternal(err)
+	}
+}

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

@@ -5,6 +5,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"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/kube_events"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -259,6 +260,34 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/databases -> database.NewDatabaseListHandler
+	listDatabaseEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/databases",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listDatabaseHandler := database.NewDatabaseListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listDatabaseEndpoint,
+		Handler:  listDatabaseHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
 	listEnvEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 28 - 0
api/server/router/infra.go

@@ -107,6 +107,34 @@ func getInfraRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/infras/{infra_id}/retry -> infra.NewInfraRetryHandler
+	retryProvisionEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/retry",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	retryProvisionHandler := infra.NewInfraRetryHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: retryProvisionEndpoint,
+		Handler:  retryProvisionHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/infras/{infra_id}/logs -> infra.NewInfraStreamLogsHandler
 	streamLogsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 158 - 36
api/server/router/namespace.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/handlers/job"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
+	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -56,14 +57,44 @@ func getNamespaceRoutes(
 
 	routes := make([]*Route, 0)
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap/list -> namespace.NewListConfigMapsHandler
-	listConfigMapsEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/provision/rds/ -> provision.NewProvisionRDSHandler
+	provisionRDSEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/provision/rds",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	provisionRDSHandler := provision.NewProvisionRDSHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: provisionRDSEndpoint,
+		Handler:  provisionRDSHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroups/list -> namespace.NewListEnvGroupsHandler
+	listEnvGroupsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/configmap/list",
+				RelativePath: relPath + "/envgroups/list",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -74,25 +105,25 @@ func getNamespaceRoutes(
 		},
 	)
 
-	listConfigMapsHandler := namespace.NewListConfigMapsHandler(
+	listEnvGroupsHandler := namespace.NewListEnvGroupsHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: listConfigMapsEndpoint,
-		Handler:  listConfigMapsHandler,
+		Endpoint: listEnvGroupsEndpoint,
+		Handler:  listEnvGroupsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap -> namespace.NewGetConfigMapHandler
-	getConfigMapEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroups/clone -> namespace.NewCloneEnvGroupHandler
+	cloneEnvGroupEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/configmap",
+				RelativePath: relPath + "/envgroups/clone",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -103,26 +134,86 @@ func getNamespaceRoutes(
 		},
 	)
 
-	getConfigMapHandler := namespace.NewGetConfigMapHandler(
+	cloneEnvGroupHandler := namespace.NewCloneEnvGroupHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: getConfigMapEndpoint,
-		Handler:  getConfigMapHandler,
+		Endpoint: cloneEnvGroupEndpoint,
+		Handler:  cloneEnvGroupHandler,
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap/create -> namespace.NewCreateConfigMapHandler
-	createConfigMapEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup -> namespace.NewGetEnvGroupHandler
+	getEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/envgroup",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getEnvGroupHandler := namespace.NewGetEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getEnvGroupEndpoint,
+		Handler:  getEnvGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup/all_versions -> namespace.NewGetEnvGroupAllVersionsHandler
+	getEnvGroupAllVersionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/envgroup/all_versions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getEnvGroupAllVersionsHandler := namespace.NewGetEnvGroupAllVersionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getEnvGroupAllVersionsEndpoint,
+		Handler:  getEnvGroupAllVersionsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup/create -> namespace.NewCreateEnvGroupHandler
+	createEnvGroupEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/configmap/create",
+				RelativePath: relPath + "/envgroup/create",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -133,26 +224,26 @@ func getNamespaceRoutes(
 		},
 	)
 
-	createConfigMapHandler := namespace.NewCreateConfigMapHandler(
+	createEnvGroupHandler := namespace.NewCreateEnvGroupHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: createConfigMapEndpoint,
-		Handler:  createConfigMapHandler,
+		Endpoint: createEnvGroupEndpoint,
+		Handler:  createEnvGroupHandler,
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap/update -> namespace.NewUpdateConfigMapHandler
-	updateConfigMapEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup/add_application -> namespace.NewAddEnvGroupAppHandler
+	updateEnvGroupAppsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/configmap/update",
+				RelativePath: relPath + "/envgroup/add_application",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -163,26 +254,26 @@ func getNamespaceRoutes(
 		},
 	)
 
-	updateConfigMapHandler := namespace.NewUpdateConfigMapHandler(
+	updateEnvGroupAppsHandler := namespace.NewAddEnvGroupAppHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: updateConfigMapEndpoint,
-		Handler:  updateConfigMapHandler,
+		Endpoint: updateEnvGroupAppsEndpoint,
+		Handler:  updateEnvGroupAppsHandler,
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap/rename -> namespace.NewRenameConfigMapHandler
-	renameConfigMapEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup/remove_application -> namespace.NewRemoveEnvGroupAppHandler
+	removeEnvGroupAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/configmap/rename",
+				RelativePath: relPath + "/envgroup/remove_application",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -193,26 +284,56 @@ func getNamespaceRoutes(
 		},
 	)
 
-	renameConfigMapHandler := namespace.NewRenameConfigMapHandler(
+	removeEnvGroupAppHandler := namespace.NewRemoveEnvGroupAppHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: renameConfigMapEndpoint,
-		Handler:  renameConfigMapHandler,
+		Endpoint: removeEnvGroupAppEndpoint,
+		Handler:  removeEnvGroupAppHandler,
 		Router:   r,
 	})
 
-	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap/delete -> namespace.NewDeleteConfigMapHandler
-	deleteConfigMapEndpoint := factory.NewAPIEndpoint(
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroup -> namespace.NewDeleteEnvGroupHandler
+	deleteEnvGroupEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Method: types.HTTPVerbDelete,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/configmap/delete",
+				RelativePath: relPath + "/envgroup",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	deleteEnvGroupHandler := namespace.NewDeleteEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: deleteEnvGroupEndpoint,
+		Handler:  deleteEnvGroupHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/configmap/update -> namespace.NewUpdateConfigMapHandler
+	updateConfigMapEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/configmap/update",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -223,14 +344,15 @@ func getNamespaceRoutes(
 		},
 	)
 
-	deleteConfigMapHandler := namespace.NewDeleteConfigMapHandler(
+	updateConfigMapHandler := namespace.NewUpdateConfigMapHandler(
 		config,
 		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: deleteConfigMapEndpoint,
-		Handler:  deleteConfigMapHandler,
+		Endpoint: updateConfigMapEndpoint,
+		Handler:  updateConfigMapHandler,
 		Router:   r,
 	})
 

+ 2 - 1
api/server/shared/config/env/envconfs.go

@@ -96,7 +96,8 @@ type ServerConf struct {
 	RetoolToken string `env:"RETOOL_TOKEN"`
 
 	// Enable pprof profiling endpoints
-	PprofEnabled bool `env:"PPROF_ENABLED,default=false"`
+	PprofEnabled    bool `env:"PPROF_ENABLED,default=false"`
+	ProvisionerTest bool `env:"PROVISIONER_TEST,default=false"`
 
 	// Disable filtering for project creation
 	DisableAllowlist bool `env:"DISABLE_ALLOWLIST,default=false"`

+ 21 - 0
api/types/database.go

@@ -0,0 +1,21 @@
+package types
+
+type Database struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The infra id, if cluster was provisioned with Porter
+	InfraID uint `json:"infra_id"`
+
+	ClusterID uint `json:"cluster_id"`
+
+	InstanceID       string `json:"instance_id"`
+	InstanceEndpoint string `json:"instance_endpoint"`
+	InstanceName     string `json:"instance_name"`
+
+	Status string `json:"status"`
+}
+
+type ListDatabaseResponse []*Database

+ 2 - 0
api/types/infra.go

@@ -26,6 +26,8 @@ const (
 	InfraGKE  InfraKind = "gke"
 	InfraDOCR InfraKind = "docr"
 	InfraDOKS InfraKind = "doks"
+
+	InfraRDS InfraKind = "rds"
 )
 
 type Infra struct {

+ 53 - 0
api/types/namespace.go

@@ -1,6 +1,8 @@
 package types
 
 import (
+	"time"
+
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/release"
 	v1 "k8s.io/api/core/v1"
@@ -85,6 +87,57 @@ type CreateConfigMapRequest struct {
 	SecretVariables map[string]string `json:"secret_variables,required"`
 }
 
+type EnvGroup struct {
+	MetaVersion  uint              `json:"meta_version"`
+	CreatedAt    time.Time         `json:"created_at"`
+	Version      uint              `json:"version"`
+	Name         string            `json:"name"`
+	Namespace    string            `json:"namespace"`
+	Applications []string          `json:"applications"`
+	Variables    map[string]string `json:"variables"`
+}
+
+type EnvGroupMeta struct {
+	MetaVersion uint      `json:"meta_version"`
+	CreatedAt   time.Time `json:"created_at"`
+	Version     uint      `json:"version"`
+	Name        string    `json:"name"`
+	Namespace   string    `json:"namespace"`
+}
+
+type GetEnvGroupRequest struct {
+	Name    string `schema:"name,required"`
+	Version uint   `schema:"version"`
+}
+
+type CloneEnvGroupRequest struct {
+	Namespace string `json:"namespace" form:"required"`
+	Name      string `json:"name" form:"required"`
+	CloneName string `json:"clone_name"`
+	Version   uint   `json:"version"`
+}
+
+type GetEnvGroupAllRequest struct {
+	Name string `schema:"name,required"`
+}
+
+type DeleteEnvGroupRequest struct {
+	Name string `json:"name,required"`
+}
+
+type AddEnvGroupApplicationRequest struct {
+	Name            string `json:"name" form:"required"`
+	ApplicationName string `json:"app_name" form:"required"`
+}
+
+type ListEnvGroupsResponse []*EnvGroupMeta
+
+type CreateEnvGroupRequest struct {
+	Name            string            `json:"name,required"`
+	Variables       map[string]string `json:"variables,required"`
+	SecretVariables map[string]string `json:"secret_variables,required"`
+}
+
 type CreateConfigMapResponse struct {
 	*v1.ConfigMap
 }

+ 5 - 4
api/types/project.go

@@ -1,10 +1,11 @@
 package types
 
 type Project struct {
-	ID                 uint    `json:"id"`
-	Name               string  `json:"name"`
-	Roles              []*Role `json:"roles"`
-	PreviewEnvsEnabled bool    `json:"preview_envs_enabled"`
+	ID                  uint    `json:"id"`
+	Name                string  `json:"name"`
+	Roles               []*Role `json:"roles"`
+	PreviewEnvsEnabled  bool    `json:"preview_envs_enabled"`
+	RDSDatabasesEnabled bool    `json:"enable_rds_databases"`
 }
 
 type CreateProjectRequest struct {

+ 156 - 2
api/types/provision.go

@@ -1,5 +1,7 @@
 package types
 
+import "strings"
+
 type CreateECRInfraRequest struct {
 	ECRName          string `json:"ecr_name" form:"required"`
 	ProjectID        uint   `json:"-" form:"required"`
@@ -42,6 +44,158 @@ type CreateDOKSInfraRequest struct {
 	DOIntegrationID uint   `json:"do_integration_id" form:"required"`
 }
 
-type DeleteInfraRequest struct {
-	Name string `json:"name" form:"required"`
+type CreateRDSInfraRequest struct {
+	// version of the postgres engine
+	DBEngineVersion string `json:"db_engine_version"`
+	// db type - postgress / mysql
+	DBFamily string `json:"db_family"`
+
+	// Deprecated, use DBEngineVersion instead
+	// PGVersion string `json:"pg_version"`
+
+	// db instance credentials specifications
+	DBName   string `json:"db_name"`
+	Username string `json:"username"`
+	Password string `json:"password"`
+
+	MachineType  string `json:"machine_type"`
+	DBStorage    string `json:"db_allocated_storage"`
+	DBMaxStorage string `json:"db_max_allocated_storage"`
+	DBEncryption bool   `json:"db_storage_encrypted"`
+}
+
+type RDSInfraLastApplied struct {
+	*CreateRDSInfraRequest
+
+	ClusterID uint   `json:"cluster_id"`
+	Namespace string `json:"namespace"`
+
+	AWSRegion            string
+	DBMajorEngineVersion string
+	DBStorageEncrypted   string
+	DeletionProtection   string
+	VPCID                string
+	Subnet1              string
+	Subnet2              string
+	Subnet3              string
+}
+
+type Family string
+
+type EngineVersion string
+
+func (e EngineVersion) MajorVersion() string {
+	semver := strings.Split(string(e), ".")
+
+	return strings.Join(semver[:len(semver)-1], ".")
+}
+
+type EngineVersions []EngineVersion
+
+func (e EngineVersions) VersionExists(version EngineVersion) bool {
+	for _, v := range e {
+		if version == v {
+			return true
+		}
+	}
+
+	return false
+}
+
+const (
+	FamilyPG9   Family = "postgres9"
+	FamilyPG10  Family = "postgres10"
+	FamilyPG11  Family = "postgres11"
+	FamilyPG12  Family = "postgres12"
+	FamilyPG13  Family = "postgres13"
+	FamilyMysql Family = "mysql"
+)
+
+var availablePG9Versions EngineVersions = EngineVersions{
+	"9.6.1",
+	"9.6.2",
+	"9.6.3",
+	"9.6.4",
+	"9.6.5",
+	"9.6.6",
+	"9.6.7",
+	"9.6.8",
+	"9.6.9",
+	"9.6.10",
+	"9.6.11",
+	"9.6.12",
+	"9.6.13",
+	"9.6.14",
+	"9.6.15",
+	"9.6.16",
+	"9.6.17",
+	"9.6.18",
+	"9.6.19",
+	"9.6.20",
+	"9.6.21",
+	"9.6.22",
+	"9.6.23",
+}
+
+var availablePG10Versions EngineVersions = EngineVersions{
+	"10.1",
+	"10.2",
+	"10.3",
+	"10.4",
+	"10.5",
+	"10.6",
+	"10.7",
+	"10.8",
+	"10.9",
+	"10.10",
+	"10.11",
+	"10.12",
+	"10.13",
+	"10.14",
+	"10.15",
+	"10.16",
+	"10.17",
+	"10.18",
+}
+
+var availablePG11Versions EngineVersions = EngineVersions{
+	"11.1",
+	"11.2",
+	"11.3",
+	"11.4",
+	"11.5",
+	"11.6",
+	"11.7",
+	"11.8",
+	"11.9",
+	"11.10",
+	"11.11",
+	"11.12",
+	"11.13",
+}
+
+var availablePG12Versions EngineVersions = EngineVersions{
+	"12.2",
+	"12.3",
+	"12.4",
+	"12.5",
+	"12.6",
+	"12.7",
+	"12.8",
+}
+
+var availablePG13Versions EngineVersions = EngineVersions{
+	"13.1",
+	"13.2",
+	"13.3",
+	"13.4",
+}
+
+var DBVersionMapping = map[Family]EngineVersions{
+	FamilyPG9:   availablePG9Versions,
+	FamilyPG10:  availablePG10Versions,
+	FamilyPG11:  availablePG11Versions,
+	FamilyPG12:  availablePG12Versions,
+	FamilyPG13:  availablePG13Versions,
+	FamilyMysql: {},
 }

+ 33 - 0
api/types/provision_test.go

@@ -0,0 +1,33 @@
+package types
+
+import (
+	"testing"
+)
+
+func TestAvailableVersion(t *testing.T) {
+	if _, ok := DBVersionMapping[Family("mongo")]; ok {
+		t.Fatalf("mong engine availability should fail")
+	}
+
+	v, ok := DBVersionMapping[Family(FamilyPG10)]
+	if !ok {
+		t.Fatalf("postgres engine not available in engine mapping")
+	}
+
+	// test for a particular version
+	if !v.VersionExists(EngineVersion("9.6.23")) {
+		t.Errorf("postgres 9.6.23 not available")
+	}
+
+	if v.VersionExists(EngineVersion("10.6.23")) {
+		t.Errorf("postgres 10.6.23 should not available")
+	}
+
+	if EngineVersion("9.6.23").MajorVersion() != "9.6" {
+		t.Errorf("wrong major version for postgres")
+	}
+
+	if EngineVersion("11.13").MajorVersion() != "11" {
+		t.Errorf("wrong major version for postgres")
+	}
+}

+ 2 - 2
cli/cmd/deploy/create.go

@@ -265,14 +265,14 @@ func (c *CreateAgent) CreateFromDocker(
 		"tag":        imageTag,
 	}
 
-	// create docker agen
+	// create docker agent
 	agent, err := docker.NewAgentWithAuthGetter(c.Client, opts.ProjectID)
 
 	if err != nil {
 		return "", err
 	}
 
-	env, err := GetEnvFromConfig(mergedValues)
+	env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
 
 	if err != nil {
 		env = map[string]string{}

+ 135 - 7
cli/cmd/deploy/deploy.go

@@ -155,7 +155,7 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 		}
 	}
 
-	env, err := GetEnvFromConfig(conf)
+	env, err := GetEnvForRelease(d.client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
 
 	if err != nil {
 		return nil, err
@@ -369,9 +369,23 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 	)
 }
 
-// GetEnvFromConfig gets the env vars for a standard Porter template config. These env
+type SyncedEnvSection struct {
+	Name    string                `json:"name" yaml:"name"`
+	Version uint                  `json:"version" yaml:"version"`
+	Keys    []SyncedEnvSectionKey `json:"keys" yaml:"keys"`
+}
+
+type SyncedEnvSectionKey struct {
+	Name   string `json:"name" yaml:"name"`
+	Secret bool   `json:"secret" yaml:"secret"`
+}
+
+// GetEnvForRelease gets the env vars for a standard Porter template config. These env
 // vars are found at `container.env.normal`.
-func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error) {
+func GetEnvForRelease(client *client.Client, config map[string]interface{}, projID, clusterID uint, namespace string) (map[string]string, error) {
+	res := make(map[string]string)
+
+	// first, get the env vars from "container.env.normal"
 	envConfig, err := getNestedMap(config, "container", "env", "normal")
 
 	// if the field is not found, set envConfig to an empty map; this release has no env set
@@ -379,8 +393,6 @@ func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error)
 		envConfig = make(map[string]interface{})
 	}
 
-	mapEnvConfig := make(map[string]string)
-
 	for key, val := range envConfig {
 		valStr, ok := val.(string)
 
@@ -391,11 +403,127 @@ func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error)
 		// if the value contains PORTERSECRET, this is a "dummy" env that gets injected during
 		// run-time, so we ignore it
 		if !strings.Contains(valStr, "PORTERSECRET") {
-			mapEnvConfig[key] = valStr
+			res[key] = valStr
 		}
 	}
 
-	return mapEnvConfig, nil
+	// next, get the env vars specified by "container.env.synced"
+	// look for container.env.synced
+	envConf, err := getNestedMap(config, "container", "env")
+
+	// if error, just return the env detected from above
+	if err != nil {
+		return res, nil
+	}
+
+	syncedEnvInter, syncedEnvExists := envConf["synced"]
+
+	if !syncedEnvExists {
+		return res, nil
+	} else {
+		syncedArr := make([]*SyncedEnvSection, 0)
+		syncedArrInter, ok := syncedEnvInter.([]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("could not convert to synced env section: not an array")
+		}
+
+		for _, syncedArrInterObj := range syncedArrInter {
+			syncedArrObj := &SyncedEnvSection{}
+			syncedArrInterObjMap, ok := syncedArrInterObj.(map[string]interface{})
+
+			if !ok {
+				continue
+			}
+
+			if nameField, nameFieldExists := syncedArrInterObjMap["name"]; nameFieldExists {
+				syncedArrObj.Name, ok = nameField.(string)
+
+				if !ok {
+					continue
+				}
+			}
+
+			if versionField, versionFieldExists := syncedArrInterObjMap["version"]; versionFieldExists {
+				versionFloat, ok := versionField.(float64)
+
+				if !ok {
+					continue
+				}
+
+				syncedArrObj.Version = uint(versionFloat)
+			}
+
+			if keyField, keyFieldExists := syncedArrInterObjMap["keys"]; keyFieldExists {
+				keyFieldInterArr, ok := keyField.([]interface{})
+
+				if !ok {
+					continue
+				}
+
+				keyFieldMapArr := make([]map[string]interface{}, 0)
+
+				for _, keyFieldInter := range keyFieldInterArr {
+					mapConv, ok := keyFieldInter.(map[string]interface{})
+
+					if !ok {
+						continue
+					}
+
+					keyFieldMapArr = append(keyFieldMapArr, mapConv)
+				}
+
+				keyFieldRes := make([]SyncedEnvSectionKey, 0)
+
+				for _, keyFieldMap := range keyFieldMapArr {
+					toAdd := SyncedEnvSectionKey{}
+
+					if nameField, nameFieldExists := keyFieldMap["name"]; nameFieldExists {
+						toAdd.Name, ok = nameField.(string)
+
+						if !ok {
+							continue
+						}
+					}
+
+					if secretField, secretFieldExists := keyFieldMap["secret"]; secretFieldExists {
+						toAdd.Secret, ok = secretField.(bool)
+
+						if !ok {
+							continue
+						}
+					}
+
+					keyFieldRes = append(keyFieldRes, toAdd)
+				}
+
+				syncedArrObj.Keys = keyFieldRes
+			}
+
+			syncedArr = append(syncedArr, syncedArrObj)
+		}
+
+		for _, syncedEG := range syncedArr {
+			// for each synced environment group, get the environment group from the client
+			eg, err := client.GetEnvGroup(context.Background(), projID, clusterID, namespace,
+				&types.GetEnvGroupRequest{
+					Name: syncedEG.Name,
+				},
+			)
+
+			if err != nil {
+				continue
+			}
+
+			for key, val := range eg.Variables {
+				if !strings.Contains(val, "PORTERSECRET") {
+					res[key] = val
+				}
+			}
+		}
+	}
+
+	return res, nil
 }
 
 func (d *DeployAgent) getReleaseImage() (string, error) {

+ 3 - 3
cmd/app/main.go

@@ -10,7 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
 	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/redis_stream"
 )
 
 // Version will be linked by an ldflag during build
@@ -43,11 +43,11 @@ func main() {
 			return
 		}
 
-		provisioner.InitGlobalStream(redis)
+		redis_stream.InitGlobalStream(redis)
 
 		errorChan := make(chan error)
 
-		go provisioner.GlobalStreamListener(redis, config.Repo, config.AnalyticsClient, errorChan)
+		go redis_stream.GlobalStreamListener(redis, config, config.Repo, config.AnalyticsClient, errorChan)
 	}
 
 	appRouter := router.NewAPIRouter(config)

+ 1 - 1
dashboard/babel.config.json

@@ -1,5 +1,5 @@
 {
-  "plugins": ["lodash", "babel-plugin-styled-components"],
+  "plugins": ["lodash", "babel-plugin-styled-components", "@babel/plugin-syntax-dynamic-import"],
   "presets": [
     "@babel/preset-env",
     "@babel/preset-react",

+ 26 - 0
dashboard/package-lock.json

@@ -1269,6 +1269,23 @@
         "@types/yargs": "^13.0.0"
       }
     },
+    "@loadable/component": {
+      "version": "5.15.2",
+      "resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz",
+      "integrity": "sha512-ryFAZOX5P2vFkUdzaAtTG88IGnr9qxSdvLRvJySXcUA4B4xVWurUNADu3AnKPksxOZajljqTrDEDcYjeL4lvLw==",
+      "requires": {
+        "@babel/runtime": "^7.7.7",
+        "hoist-non-react-statics": "^3.3.1",
+        "react-is": "^16.12.0"
+      },
+      "dependencies": {
+        "react-is": {
+          "version": "16.13.1",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+          "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+        }
+      }
+    },
     "@material-ui/core": {
       "version": "4.12.3",
       "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.3.tgz",
@@ -1830,6 +1847,15 @@
       "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==",
       "dev": true
     },
+    "@types/loadable__component": {
+      "version": "5.13.4",
+      "resolved": "https://registry.npmjs.org/@types/loadable__component/-/loadable__component-5.13.4.tgz",
+      "integrity": "sha512-YhoCCxyuvP2XeZNbHbi8Wb9EMaUJuA2VGHxJffcQYrJKIKSkymJrhbzsf9y4zpTmr5pExAAEh5hbF628PAZ8Dg==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/lodash": {
       "version": "4.14.177",
       "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.177.tgz",

+ 3 - 0
dashboard/package.json

@@ -4,6 +4,7 @@
   "private": true,
   "dependencies": {
     "@ironplans/react": "^0.4.0",
+    "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
@@ -58,6 +59,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.15.0",
+    "@babel/plugin-syntax-dynamic-import": "^7.8.3",
     "@babel/preset-env": "^7.15.0",
     "@babel/preset-react": "^7.14.5",
     "@babel/preset-typescript": "^7.15.0",
@@ -70,6 +72,7 @@
     "@types/jest": "^24.0.0",
     "@types/js-base64": "^3.0.0",
     "@types/js-yaml": "^4.0.1",
+    "@types/loadable__component": "^5.13.4",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/material-ui": "^0.21.8",

+ 84 - 38
dashboard/src/components/DocsHelper.tsx

@@ -1,15 +1,21 @@
-import React, { Component, useState } from "react";
-import styled, { createGlobalStyle } from "styled-components";
-import Button from "@material-ui/core/Button";
-import Tooltip from "@material-ui/core/Tooltip";
-import { ClickAwayListener, TooltipProps } from "@material-ui/core";
+import React from "react";
+import styled from "styled-components";
+
+import { ClickAwayListener } from "@material-ui/core";
 
 type Props = {
   tooltipText: string;
   link: string;
+  placement?: TooltipPlacement;
+  disableMargin?: boolean;
 };
 
-const DocsHelper: React.FC<Props> = ({ tooltipText, link }) => {
+const DocsHelper: React.FC<Props> = ({
+  tooltipText,
+  link,
+  placement,
+  disableMargin,
+}) => {
   const [open, setOpen] = React.useState(false);
 
   const handleTooltipClose = () => {
@@ -25,46 +31,87 @@ const DocsHelper: React.FC<Props> = ({ tooltipText, link }) => {
   };
 
   return (
-    <DocsHelperContainer>
+    <DocsHelperContainer disableMargin={disableMargin}>
       <ClickAwayListener
         onClickAway={() => {
           handleTooltipClose();
         }}
       >
         <div>
-          <Tooltip
-            PopperProps={{
-              disablePortal: true,
-              placement: "top-end",
-            }}
-            onClose={handleTooltipClose}
-            open={open}
-            interactive
-            disableFocusListener
-            disableHoverListener
-            disableTouchListener
-            title={
+          <HelperButton onClick={handleTooltipToggle}>
+            <i className="material-icons">help_outline</i>
+          </HelperButton>
+          {open && (
+            <Tooltip placement={placement}>
               <StyledContent onClick={handleTooltipOpen}>
                 {tooltipText}
                 <A target="_blank" href={link}>
                   Documentation {">"}
                 </A>
               </StyledContent>
-            }
-          >
-            <HelperButton onClick={handleTooltipToggle}>
-              <i className="material-icons">help_outline</i>
-            </HelperButton>
-          </Tooltip>
+            </Tooltip>
+          )}
         </div>
       </ClickAwayListener>
-      <TooltipStyle />
     </DocsHelperContainer>
   );
 };
 
 export default DocsHelper;
 
+type TooltipPlacement = "top-end" | "bottom-end" | "top-start" | "bottom-start";
+
+const Tooltip = styled.div<{ placement: TooltipPlacement }>`
+  position: absolute;
+  ${({ placement }) => {
+    switch (placement) {
+      case "top-start":
+        return `
+          bottom: 25px;
+          left: 0px;
+        `;
+      case "bottom-end":
+        return `
+          top: 25px;
+          right: 0px;
+        `;
+      case "bottom-start":
+        return `
+          top: 25px;
+          left: 0px;
+        `;
+      case "top-end":
+      default:
+        return `
+          bottom: 25px;
+          right: 0px;
+        `;
+    }
+  }}
+  word-wrap: break-word;
+  min-height: 18px;
+  width: fit-content;
+  padding: 5px 7px;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
 const StyledContent = styled.div`
   font-family: "Work Sans", sans-serif;
   font-size: 12px;
@@ -72,7 +119,8 @@ const StyledContent = styled.div`
   padding: 12px 14px;
   line-height: 1.5em;
   user-select: text;
-  width: calc(100% + 14px);
+  width: max-content;
+  max-width: 300px;
   height: calc(100% + 10px);
   margin-left: -7px;
   height: 100%;
@@ -96,26 +144,24 @@ const HelperButton = styled.div`
   }
 `;
 
-const TooltipStyle = createGlobalStyle`
-  .MuiTooltip-tooltip {
-    background-color: #00000000 !important;
-    font-size: 12px !important;
-    padding: 0px;
-    max-width: 300px !important;    
-  }
-`;
-
 const A = styled.a`
   display: inline-block;
   height: 20px;
   color: #8590ff;
   text-decoration: underline;
   cursor: pointer;
+  margin-top: 10px;
   width: 100%;
   text-align: right;
   user-select: none;
 `;
 
-const DocsHelperContainer = styled.div`
-  margin-left: auto;
+const DocsHelperContainer = styled.div<{ disableMargin: boolean }>`
+  ${(props) => {
+    if (props.disableMargin) {
+      return "";
+    }
+    return `margin-left: auto;`;
+  }}
+  position: relative;
 `;

+ 1 - 0
dashboard/src/components/ProvisionerStatus.tsx

@@ -40,6 +40,7 @@ const nameMap: { [key: string]: string } = {
   docr: "DigitalOcean Container Registry (DOCR)",
   gke: "Google Kubernetes Engine (GKE)",
   gcr: "Google Container Registry (GCR)",
+  rds: "Amazon Relational Database (RDS)",
 };
 
 const ProvisionerStatus: React.FC<Props> = ({ modules }) => {

+ 2 - 2
dashboard/src/components/Selector.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 
-type PropsType = {
+export type SelectorPropsType = {
   activeValue: string;
   refreshOptions?: () => void;
   options: { value: string; label: string; icon?: any }[];
@@ -21,7 +21,7 @@ type PropsType = {
 
 type StateType = {};
 
-export default class Selector extends Component<PropsType, StateType> {
+export default class Selector extends Component<SelectorPropsType, StateType> {
   state = {
     expanded: false,
     showTooltip: false,

+ 24 - 10
dashboard/src/components/TabSelector.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 export interface selectOption {
   value: string;
   label: string;
+  component?: any;
 }
 
 type PropsType = {
@@ -18,6 +19,16 @@ type PropsType = {
 type StateType = {};
 
 export default class TabSelector extends Component<PropsType, StateType> {
+  getCurrentComponent() {
+    const currentOption = this.props.options.find(
+      (option) => option.value === this.props.currentTab
+    );
+    if (currentOption?.component) {
+      return currentOption.component;
+    }
+    return null;
+  }
+
   handleTabClick = (value: string) => {
     this.props.setCurrentTab(value);
   };
@@ -42,16 +53,19 @@ export default class TabSelector extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledTabSelector>
-        <TabWrapper>
-          <Line />
-          {this.renderTabList()}
-          <Tab lastItem={true} highlight={null}>
-            {this.props.noBuffer ? null : <Buffer />}
-          </Tab>
-        </TabWrapper>
-        {this.props.addendum}
-      </StyledTabSelector>
+      <>
+        <StyledTabSelector>
+          <TabWrapper>
+            <Line />
+            {this.renderTabList()}
+            <Tab lastItem={true} highlight={null}>
+              {this.props.noBuffer ? null : <Buffer />}
+            </Tab>
+          </TabWrapper>
+          {this.props.addendum}
+        </StyledTabSelector>
+        {this.getCurrentComponent()}
+      </>
     );
   }
 }

+ 5 - 1
dashboard/src/components/Table.tsx

@@ -88,8 +88,12 @@ const Table: React.FC<TableProps> = ({
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
             >
+              {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
               {row.cells.map((cell) => (
-                <StyledTd {...cell.getCellProps()}>
+                <StyledTd
+                  {...cell.getCellProps()}
+                  width={cell.column.totalWidth}
+                >
                   {cell.render("Cell")}
                 </StyledTd>
               ))}

+ 17 - 1
dashboard/src/components/TitleSection.tsx

@@ -7,6 +7,7 @@ interface Props {
   iconWidth?: string;
   capitalize?: boolean;
   className?: string;
+  materialIconClass?: string;
   handleNavBack?: () => void;
 }
 
@@ -17,6 +18,7 @@ const TitleSection: React.FC<Props> = ({
   capitalize,
   handleNavBack,
   className,
+  materialIconClass,
 }) => {
   return (
     <StyledTitleSection className={className}>
@@ -27,7 +29,16 @@ const TitleSection: React.FC<Props> = ({
           </i>
         </BackButton>
       )}
-      {icon && <Icon width={iconWidth} src={icon} />}
+
+      {icon &&
+        (materialIconClass?.length ? (
+          <MaterialIcon width={iconWidth} className={materialIconClass}>
+            {icon}
+          </MaterialIcon>
+        ) : (
+          <Icon width={iconWidth} src={icon} />
+        ))}
+
       <StyledTitle capitalize={capitalize}>{children}</StyledTitle>
     </StyledTitleSection>
   );
@@ -61,6 +72,11 @@ const Icon = styled.img<{ width: string }>`
   margin-right: 16px;
 `;
 
+const MaterialIcon = styled.span<{ width: string }>`
+  width: ${(props) => props.width || "28px"};
+  margin-right: 16px;
+`;
+
 const StyledTitle = styled.div<{ capitalize: boolean }>`
   font-size: 24px;
   font-weight: 600;

+ 4 - 6
dashboard/src/components/form-components/Helper.tsx

@@ -1,14 +1,12 @@
 import React from "react";
 import styled from "styled-components";
 
-export default function Helper(props: { children: any }) {
-  return <StyledHelper>{props.children}</StyledHelper>;
-}
-
-const StyledHelper = styled.div`
-  color: #aaaabb;
+export const Helper = styled.div<{ color?: string }>`
+  color: ${({ color }) => (color ? color : "#aaaabb")};
   line-height: 1.6em;
   font-size: 13px;
   margin-bottom: 15px;
   margin-top: 20px;
 `;
+
+export default Helper;

+ 3 - 1
dashboard/src/components/form-components/SelectRow.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import Selector from "../Selector";
+import Selector, { SelectorPropsType } from "../Selector";
 
 type PropsType = {
   label: string;
@@ -13,6 +13,7 @@ type PropsType = {
   dropdownMaxHeight?: string;
   scrollBuffer?: boolean;
   doc?: string;
+  selectorProps?: Partial<SelectorPropsType>;
 };
 
 type StateType = {};
@@ -39,6 +40,7 @@ export default class SelectRow extends Component<PropsType, StateType> {
             width={this.props.width || "270px"}
             dropdownWidth={this.props.width}
             dropdownMaxHeight={this.props.dropdownMaxHeight}
+            {...(this.props.selectorProps || {})}
           />
         </SelectWrapper>
       </StyledSelectRow>

+ 1 - 0
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -289,6 +289,7 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
                       envLoader: true,
                       fileUpload: true,
                       settings: {
+                        ...(field.settings || {}),
                         type: "env",
                       },
                     };

+ 335 - 22
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -1,18 +1,24 @@
-import React from "react";
+import React, { useContext, useEffect, useState } from "react";
 import {
   GetFinalVariablesFunction,
   KeyValueArrayField,
   KeyValueArrayFieldState,
+  PopulatedEnvGroup,
 } from "../types";
 import sliders from "../../../assets/sliders.svg";
 import upload from "../../../assets/upload.svg";
-import styled from "styled-components";
+import styled, { keyframes } from "styled-components";
 import useFormField from "../hooks/useFormField";
 import Modal from "../../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
 import { hasSetValue } from "../utils";
-import _ from "lodash";
+import _, { omit } from "lodash";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import Loading from "components/Loading";
+import api from "shared/api";
+import { Context } from "shared/Context";
 
 interface Props extends KeyValueArrayField {
   id: string;
@@ -22,20 +28,78 @@ const KeyValueArray: React.FC<Props> = (props) => {
   const { state, setState, variables } = useFormField<KeyValueArrayFieldState>(
     props.id,
     {
-      initState: {
-        values: hasSetValue(props)
-          ? (Object.entries(props.value[0])?.map(([k, v]) => {
-              return { key: k, value: v };
-            }) as any[])
-          : [],
-        showEnvModal: false,
-        showEditorModal: false,
+      initState: () => {
+        let values = props.value[0];
+        const normalValues = Object.entries(values?.normal || {});
+        values = omit(values, ["normal", "synced"]);
+        return {
+          values: hasSetValue(props)
+            ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
+                return { key: k, value: v };
+              }) as any[])
+            : [],
+          showEnvModal: false,
+          showEditorModal: false,
+          synced_env_groups: props.settings?.options?.enable_synced_env_groups
+            ? null
+            : [],
+        };
       },
     }
   );
 
+  const { currentProject } = useContext(Context);
+
+  // If the variable includes normal it means that the form corresponds to an old job template version
+  // The "normal" keyword doesn't exist for applications as well as the enable_synced_env_groups setting.
+  // This is why we have to check if the form corresponds to a job or not.
+  const enableSyncedEnvGroups = props.variable.includes("normal")
+    ? !!props.settings?.options?.enable_synced_env_groups
+    : true;
+
+  useEffect(() => {
+    if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
+      const values = props.value[0];
+      console.log(values);
+      const envGroups = values?.synced || [];
+      const promises = Promise.all(
+        envGroups.map(async (envGroup: any) => {
+          const res = await api.getEnvGroup(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              cluster_id: variables.clusterId,
+              namespace: variables.namespace,
+              name: envGroup?.name,
+              version: envGroup.version,
+            }
+          );
+          return res.data;
+        })
+      );
+
+      promises.then((populatedEnvGroups) => {
+        setState(() => ({
+          synced_env_groups: Array.isArray(populatedEnvGroups)
+            ? populatedEnvGroups
+            : [],
+        }));
+      });
+    }
+  }, [
+    props.value[0],
+    variables?.clusterId,
+    variables?.namespace,
+    currentProject?.id,
+  ]);
+
   if (state == undefined) return <></>;
 
+  if (!Array.isArray(state.synced_env_groups) && enableSyncedEnvGroups) {
+    return <Loading />;
+  }
+
   const parseEnv = (src: any, options: any) => {
     const debug = Boolean(options && options.debug);
     const obj = {} as Record<string, string>;
@@ -151,11 +215,13 @@ const KeyValueArray: React.FC<Props> = (props) => {
               return { showEnvModal: false };
             })
           }
-          width="765px"
+          width="800px"
           height="542px"
         >
           <LoadEnvGroupModal
             existingValues={getProcessedValues(state.values)}
+            enableSyncedEnvGroups={enableSyncedEnvGroups}
+            syncedEnvGroups={state.synced_env_groups}
             namespace={variables.namespace}
             clusterId={variables.clusterId}
             closeModal={() =>
@@ -165,6 +231,13 @@ const KeyValueArray: React.FC<Props> = (props) => {
                 };
               })
             }
+            setSyncedEnvGroups={(value) => {
+              setState((prev) => {
+                return {
+                  synced_env_groups: [...(prev.synced_env_groups || []), value],
+                };
+              });
+            }}
             setValues={(values) => {
               setState((prev) => {
                 // Transform array to object similar on what we receive from setValues
@@ -227,6 +300,24 @@ const KeyValueArray: React.FC<Props> = (props) => {
     }
   };
 
+  const checkOverridedKey = (key: string) => {
+    const env_group = state.synced_env_groups.find(
+      (env) => env?.variables && env?.variables[key]
+    );
+
+    if (env_group) {
+      return (
+        <Wrapper>
+          <Helper color="#f5cb42" style={{ marginLeft: "10px" }}>
+            Overridden by the env group "{env_group?.name}"
+          </Helper>
+        </Wrapper>
+      );
+    }
+
+    return null;
+  };
+
   const renderInputList = () => {
     return (
       <>
@@ -263,6 +354,9 @@ const KeyValueArray: React.FC<Props> = (props) => {
                 }}
                 disabled={props.isReadOnly || value.includes("PORTERSECRET")}
                 spellCheck={false}
+                borderColor={
+                  checkOverridedKey(entry.key) ? "#f5cb42" : undefined
+                }
               />
               <Spacer />
               <Input
@@ -291,6 +385,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
               />
               {renderDeleteButton(i)}
               {renderHiddenOption(value.includes("PORTERSECRET"), i)}
+              {checkOverridedKey(entry.key)}
             </InputWrapper>
           );
         })}
@@ -347,6 +442,31 @@ const KeyValueArray: React.FC<Props> = (props) => {
             )}
           </InputWrapper>
         )}
+        {enableSyncedEnvGroups && !!state.synced_env_groups?.length && (
+          <>
+            <Heading>Synced Environment Groups</Heading>
+            <Br />
+            {state.synced_env_groups?.map((envGroup: any) => {
+              return (
+                <ExpandableEnvGroup
+                  key={envGroup?.name}
+                  envGroup={envGroup}
+                  onDelete={() => {
+                    setState((prev) => {
+                      const synced = prev.synced_env_groups?.filter(
+                        (env) => env.name !== envGroup.name
+                      );
+                      return {
+                        ...prev,
+                        synced_env_groups: synced,
+                      };
+                    });
+                  }}
+                />
+              );
+            })}
+          </>
+        )}
       </StyledInputArray>
       {renderEnvModal()}
       {renderEditorModal()}
@@ -365,7 +485,9 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   }
 
-  let obj = {} as any;
+  let obj = {
+    normal: {},
+  } as any;
   const rg = /(?:^|[^\\])(\\n)/g;
   const fixNewlines = (s: string) => {
     while (rg.test(s)) {
@@ -382,18 +504,114 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
   };
   state.values.forEach((entry: any, i: number) => {
     if (isNumber(entry.value)) {
-      obj[entry.key] = entry.value;
+      obj.normal[entry.key] = entry.value;
     } else {
-      obj[entry.key] = fixNewlines(entry.value);
+      obj.normal[entry.key] = fixNewlines(entry.value);
     }
   });
+
+  if (state.synced_env_groups?.length) {
+    obj.synced = state.synced_env_groups.map((envGroup) => ({
+      name: envGroup?.name,
+      version: envGroup?.version,
+      keys: Object.entries(envGroup?.variables || {}).map(([key, val]) => ({
+        name: key,
+        secret: val.includes("PORTERSECRET"),
+      })),
+    }));
+  }
+
+  const variableContent = props.variable.split(".");
+  let variable = props.variable;
+
+  if (variable.includes("normal")) {
+    variable = `${variableContent[0]}.${variableContent[1]}`;
+  }
+
   return {
-    [props.variable]: obj,
+    [variable]: obj,
   };
 };
 
 export default KeyValueArray;
 
+const ExpandableEnvGroup: React.FC<{
+  envGroup: PopulatedEnvGroup;
+  onDelete: () => void;
+}> = ({ envGroup, onDelete }) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+  return (
+    <>
+      <StyledCard>
+        <Flex>
+          <ContentContainer>
+            <EventInformation>
+              <EventName>{envGroup.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            <ActionButton onClick={() => onDelete()}>
+              <span className="material-icons">delete</span>
+            </ActionButton>
+            <ActionButton onClick={() => setIsExpanded((prev) => !prev)}>
+              <i className="material-icons">
+                {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
+              </i>
+            </ActionButton>
+          </ActionContainer>
+        </Flex>
+        {isExpanded && (
+          <>
+            <Buffer />
+            {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);
+                }
+
+                return (
+                  <InputWrapper key={i}>
+                    <Input
+                      placeholder="ex: key"
+                      width="270px"
+                      value={key}
+                      disabled
+                    />
+                    <Spacer />
+                    <Input
+                      placeholder="ex: value"
+                      width="270px"
+                      value={value}
+                      disabled
+                      type={
+                        value.includes("PORTERSECRET") ? "password" : "text"
+                      }
+                    />
+                  </InputWrapper>
+                );
+              }
+            )}
+            <Br />
+          </>
+        )}
+      </StyledCard>
+    </>
+  );
+};
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
 const Spacer = styled.div`
   width: 10px;
   height: 20px;
@@ -497,24 +715,37 @@ const HideButton = styled(DeleteButton)`
   }
 `;
 
+const Wrapper = styled.div`
+  margin-left: 5px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  margin-top: -7px;
+`;
+
 const InputWrapper = styled.div`
   display: flex;
   align-items: center;
   margin-top: 5px;
 `;
 
-const Input = styled.input`
+type InputProps = {
+  disabled?: boolean;
+  width: string;
+  borderColor?: string;
+};
+
+const Input = styled.input<InputProps>`
   outline: none;
   border: none;
   margin-bottom: 5px;
   font-size: 13px;
   background: #ffffff11;
-  border: 1px solid #ffffff55;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#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;
 `;
@@ -528,3 +759,85 @@ const StyledInputArray = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
 `;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+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`
+  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;
+  }
+`;

+ 14 - 1
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -21,7 +21,7 @@ interface FormFieldData<T> {
 }
 
 interface Options<T> {
-  initState?: T;
+  initState?: T | (() => T);
   initValidation?: Partial<PorterFormFieldValidationState>;
   initVars?: PorterFormVariableList;
 }
@@ -33,6 +33,19 @@ const useFormField = <T extends PorterFormFieldFieldState>(
   const { dispatchAction, formState } = useContext(PorterFormContext);
 
   useEffect(() => {
+    if (typeof initState === "function") {
+      dispatchAction({
+        type: "init-field",
+        id: fieldId,
+        initValue: initState() || {},
+        initValidation: initValidation || {
+          validated: false,
+        },
+        initVars: initVars || {},
+      });
+      return;
+    }
+
     dispatchAction({
       type: "init-field",
       id: fieldId,

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

@@ -83,6 +83,9 @@ export interface KeyValueArrayField extends GenericInputField {
   envLoader?: boolean;
   fileUpload?: boolean;
   settings?: {
+    options?: {
+      enable_synced_env_groups: boolean;
+    },
     type: "env" | "normal";
   };
 }
@@ -173,6 +176,23 @@ export interface PorterFormValidationInfo {
 // internal field state interfaces
 export interface StringInputFieldState {}
 export interface CheckboxFieldState {}
+
+export type PartialEnvGroup = {
+  name: string;
+  namespace: string;
+  version: number;
+};
+
+export type PopulatedEnvGroup = {
+  name: string;
+  namespace: string;
+  version: number;
+  variables: {
+    [key: string]: string;
+  };
+  applications: any[];
+  meta_version: number;
+};
 export interface KeyValueArrayFieldState {
   values: {
     key: string;
@@ -180,6 +200,7 @@ export interface KeyValueArrayFieldState {
   }[];
   showEnvModal: boolean;
   showEditorModal: boolean;
+  synced_env_groups: PopulatedEnvGroup[];
 }
 export interface ArrayInputFieldState {}
 export interface SelectFieldState {}

+ 29 - 0
dashboard/src/components/porter-form/utils.ts

@@ -1,5 +1,34 @@
+import { merge, unionBy } from "lodash";
+import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { GenericInputField } from "./types";
 
 export const hasSetValue = (field: GenericInputField) => {
   return field.value && field.value.length != 0 && field.value[0] != null;
 };
+
+export const fillWithDeletedVariables = (
+  originalValues: {
+    key: string;
+    value: string;
+  }[],
+  newValues: {
+    key: string;
+    value: string;
+  }[]
+) => {
+  const filledArray = originalValues.map((originalVal) => {
+    const foundNewValue = newValues.find(
+      (newValue) => newValue.key === originalVal.key
+    );
+    if (!foundNewValue) {
+      return {
+        key: originalVal.key,
+        value: null,
+      };
+    } else {
+      return foundNewValue;
+    }
+  });
+
+  return unionBy(filledArray, newValues, "key");
+};

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

@@ -455,6 +455,7 @@ class Home extends Component<PropsType, StateType> {
                 "/applications",
                 "/jobs",
                 "/env-groups",
+                "/databases",
               ]}
               render={() => {
                 let { currentCluster } = this.context;

+ 11 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -17,6 +17,7 @@ import UsageWarningModal from "./modals/UsageWarningModal";
 import api from "shared/api";
 import { AxiosError } from "axios";
 import SkipOnboardingModal from "./modals/SkipProvisioningModal";
+import ConnectToDatabaseInstructionsModal from "./modals/ConnectToDatabaseInstructionsModal";
 
 const ModalHandler: React.FC<{
   setRefreshClusters: (x: boolean) => void;
@@ -211,6 +212,16 @@ const ModalHandler: React.FC<{
           <SkipOnboardingModal />
         </Modal>
       )}
+      {modal === "ConnectToDatabaseInstructionsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="600px"
+          height="350px"
+          title="Connecting to the Database"
+        >
+          <ConnectToDatabaseInstructionsModal />
+        </Modal>
+      )}
     </>
   );
 };

+ 10 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -26,6 +26,13 @@ import DashboardRoutes from "./dashboard/Routes";
 import GuardedRoute from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import LastRunStatusSelector from "./LastRunStatusSelector";
+import loadable from "@loadable/component";
+import Loading from "components/Loading";
+
+// @ts-ignore
+const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
+  fallback: <Loading />,
+});
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -231,6 +238,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         >
           {this.renderContents()}
         </GuardedRoute>
+        <Route path={"/databases"}>
+          <LazyDatabasesRoutes />
+        </Route>
         <Route path={["/cluster-dashboard"]}>
           <DashboardRoutes />
         </Route>

+ 20 - 12
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -8,7 +8,9 @@ import TitleSection from "components/TitleSection";
 type PropsType = {
   image: any;
   title: string;
-  description: string;
+  description?: string;
+  materialIconClass?: string;
+  disableLineBreak?: boolean;
 };
 
 type StateType = {};
@@ -17,22 +19,28 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
   render() {
     return (
       <>
-        <TitleSection capitalize={true} icon={this.props.image}>
+        <TitleSection
+          capitalize={true}
+          icon={this.props.image}
+          materialIconClass={this.props.materialIconClass}
+        >
           {this.props.title}
         </TitleSection>
 
         <Br />
 
-        <InfoSection>
-          <TopRow>
-            <InfoLabel>
-              <i className="material-icons">info</i> Info
-            </InfoLabel>
-          </TopRow>
-          <Description>{this.props.description}</Description>
-        </InfoSection>
-
-        <LineBreak />
+        {this.props.description && (
+          <InfoSection>
+            <TopRow>
+              <InfoLabel>
+                <i className="material-icons">info</i> Info
+              </InfoLabel>
+            </TopRow>
+            <Description>{this.props.description}</Description>
+          </InfoSection>
+        )}
+
+        {!this.props.disableLineBreak && <LineBreak />}
       </>
     );
   }

+ 302 - 0
dashboard/src/main/home/cluster-dashboard/databases/CreateDatabaseForm.tsx

@@ -0,0 +1,302 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import React, { useContext, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import api from "shared/api";
+import useAuth from "shared/auth/useAuth";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import DashboardHeader from "../DashboardHeader";
+import {
+  DATABASE_INSTANCE_TYPES,
+  DEFAULT_DATABASE_INSTANCE_TYPE,
+  FORM_DEFAULT_VALUES,
+  LAST_POSTGRES_ENGINE_VERSION,
+  POSTGRES_DB_FAMILIES,
+  POSTGRES_ENGINE_VERSIONS,
+  DEFAULT_DB_FAMILY,
+} from "./static_data";
+
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
+
+const CreateDatabaseForm = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [databaseName, setDatabaseName] = useState(
+    () => `${currentProject.name}-database`
+  );
+  const [masterUser, setMasterUser] = useState("");
+  const [masterPassword, setMasterPassword] = useState("");
+  const [dbFamily, setDbFamily] = useState(DEFAULT_DB_FAMILY);
+  const [engineVersion, setEngineVersion] = useState(
+    LAST_POSTGRES_ENGINE_VERSION
+  );
+  const [instanceType, setInstanceType] = useState(
+    DEFAULT_DATABASE_INSTANCE_TYPE
+  );
+  const [submitStatus, setSubmitStatus] = useState("");
+  const [availableNamespaces, setAvailableNamespaces] = useState([]);
+  const [selectedNamespace, setSelectedNamespace] = useState("default");
+  const [isAuthorized] = useAuth();
+
+  const { pushFiltered } = useRouting();
+
+  const validateForm = (): ValidationError => {
+    if (!databaseName.length) {
+      return {
+        hasError: true,
+        description: "Database name cannot be empty",
+      };
+    }
+
+    if (!masterUser.length) {
+      return {
+        hasError: true,
+        description: "Master user cannot be empty",
+      };
+    }
+
+    if (!masterPassword.length) {
+      return {
+        hasError: true,
+        description: "Master password cannot be empty",
+      };
+    }
+
+    return {
+      hasError: false,
+    };
+  };
+
+  const handleSubmit = async () => {
+    const validation = validateForm();
+    if (validation.hasError) {
+      setSubmitStatus(validation.description);
+      return;
+    }
+
+    try {
+      await api.provisionDatabase(
+        "<token>",
+        {
+          ...FORM_DEFAULT_VALUES,
+          db_family: dbFamily,
+          db_name: databaseName,
+          username: masterUser,
+          password: masterPassword,
+          db_engine_version: engineVersion,
+          machine_type: instanceType,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: selectedNamespace,
+        }
+      );
+      setSubmitStatus("successful");
+      pushFiltered("/databases", []);
+    } catch (error) {
+      console.error(error);
+      setSubmitStatus("We couldn't process your request, please try again.");
+    }
+  };
+
+  const updateNamespaces = async () => {
+    try {
+      const res = await api.getNamespaces(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      if (res.data) {
+        const availableNamespaces = res.data.items.filter((namespace: any) => {
+          return namespace.status.phase !== "Terminating";
+        });
+        const namespaceOptions: {
+          label: string;
+          value: string;
+        }[] = availableNamespaces.map((x: { metadata: { name: string } }) => {
+          return { label: x.metadata.name, value: x.metadata.name };
+        });
+
+        if (availableNamespaces.length > 0) {
+          setAvailableNamespaces(namespaceOptions);
+        }
+      }
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  useEffect(() => {
+    updateNamespaces();
+  }, []);
+
+  useEffect(() => {
+    setEngineVersion(
+      POSTGRES_ENGINE_VERSIONS[dbFamily][
+        POSTGRES_ENGINE_VERSIONS[dbFamily].length - 1
+      ].value
+    );
+  }, [dbFamily]);
+
+  return (
+    <>
+      <DashboardHeader
+        image="storage"
+        title="New database"
+        materialIconClass="material-icons-outlined"
+      />
+      <ControlRow>
+        <BackButton to="/databases">
+          <i className="material-icons">close</i>
+        </BackButton>
+      </ControlRow>
+
+      <FormWrapper>
+        <SelectRow
+          label="Namespace"
+          selectorProps={{
+            refreshOptions: () => {
+              updateNamespaces();
+            },
+            addButton: isAuthorized("namespace", "", ["get", "create"]),
+            dropdownWidth: "335px",
+            closeOverlay: true,
+          }}
+          value={selectedNamespace}
+          setActiveValue={setSelectedNamespace}
+          options={availableNamespaces}
+          width="100%"
+        />
+        <InputRow
+          type="string"
+          label="Database name"
+          isRequired
+          value={databaseName}
+          setValue={(value: string) => {
+            setDatabaseName(value);
+          }}
+          width="100%"
+        />
+        <InputRow
+          type="string"
+          label="Master user"
+          isRequired
+          value={masterUser}
+          setValue={(value: string) => {
+            setMasterUser(value);
+          }}
+          width="100%"
+        />
+        <InputRow
+          type="password"
+          label="Master password"
+          isRequired
+          value={masterPassword}
+          setValue={(value: string) => {
+            setMasterPassword(value);
+          }}
+          width="100%"
+        />
+        <SelectRow
+          label="DB Family"
+          options={POSTGRES_DB_FAMILIES}
+          setActiveValue={(value) => {
+            setDbFamily(value);
+          }}
+          value={dbFamily}
+          width="100%"
+        />
+        <SelectRow
+          label="Engine version"
+          options={POSTGRES_ENGINE_VERSIONS[dbFamily]}
+          setActiveValue={(value) => {
+            setEngineVersion(value);
+          }}
+          value={engineVersion}
+          width="100%"
+        />
+        <SelectRow
+          label="Instance type"
+          options={DATABASE_INSTANCE_TYPES}
+          setActiveValue={(value) => {
+            setInstanceType(value);
+          }}
+          value={instanceType}
+          width="100%"
+        />
+        <Helper>
+          Please remember that this feature is still on development, this means
+          that if you update the values provided here from your AWS Console
+          porter <b>WILL NOT</b> be able to track those changes. In case is
+          mandatory to change anything please contact the Porter team.
+        </Helper>
+
+        <SubmitButton
+          clearPosition
+          text="Create database"
+          onClick={() => {
+            handleSubmit();
+          }}
+          statusPosition="right"
+          status={submitStatus}
+        />
+      </FormWrapper>
+    </>
+  );
+};
+
+export default CreateDatabaseForm;
+
+const BackButton = styled(Link)`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  text-decoration: none;
+  color: white;
+
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const FormWrapper = styled.div`
+  max-width: 600px;
+  margin: auto;
+`;
+
+const SubmitButton = styled(SaveButton)`
+  margin-top: 20px;
+`;

+ 75 - 0
dashboard/src/main/home/cluster-dashboard/databases/DatabasesHome.tsx

@@ -0,0 +1,75 @@
+import React, { useContext, useEffect, useState } from "react";
+import TabSelector from "components/TabSelector";
+import DashboardHeader from "../DashboardHeader";
+import DatabasesList from "./DatabasesList";
+import { StatusPage } from "main/home/onboarding/steps/ProvisionResources/forms/StatusPage";
+import { Context } from "shared/Context";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
+import { getQueryParam, useRouting } from "shared/routing";
+
+const AvailableTabs = ["databases-list", "provisioner-status"] as const;
+
+type AvailableTabsType = typeof AvailableTabs[number];
+
+const DatabasesHome = () => {
+  const { currentProject } = useContext(Context);
+  const [currentTab, setCurrentTab] = useState<AvailableTabsType>(
+    "databases-list"
+  );
+  const { pushQueryParams } = useRouting();
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    const current_tab = getQueryParam(
+      { location },
+      "current_tab"
+    ) as AvailableTabsType;
+
+    if (!AvailableTabs.includes(current_tab)) {
+      return;
+    }
+
+    if (current_tab !== currentTab) {
+      setCurrentTab(current_tab);
+    }
+  }, [location.search, history]);
+
+  return (
+    <div>
+      <DashboardHeader
+        image="storage"
+        title="Databases"
+        description="List of databases created and linked to this cluster."
+        materialIconClass="material-icons-outlined"
+        disableLineBreak
+      />
+      <TabSelector
+        currentTab={currentTab}
+        options={[
+          {
+            label: "Databases",
+            value: "databases-list",
+            component: <DatabasesList />,
+          },
+          {
+            label: "Provisioner status",
+            value: "provisioner-status",
+            component: (
+              <StatusPage
+                project_id={currentProject.id}
+                filter={["rds"]}
+                setInfraStatus={() => {}}
+              />
+            ),
+          },
+        ]}
+        setCurrentTab={(newTab: AvailableTabsType) => {
+          pushQueryParams({ current_tab: newTab });
+        }}
+      />
+    </div>
+  );
+};
+
+export default DatabasesHome;

+ 351 - 0
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -0,0 +1,351 @@
+import CopyToClipboard from "components/CopyToClipboard";
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useRouteMatch } from "react-router";
+import { Link } from "react-router-dom";
+import { Column, Row } from "react-table";
+import api from "shared/api";
+import useAuth from "shared/auth/useAuth";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import { mock_database_list } from "./mock_data";
+
+export type DatabaseObject = {
+  cluster_id: number;
+  project_id: number;
+  infra_id: number;
+  instance_id: string;
+  instance_name: string;
+  status: string;
+  instance_endpoint: string;
+};
+
+const DatabasesList = () => {
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentError,
+    setCurrentModal,
+    setCurrentOverlay,
+    user,
+  } = useContext(Context);
+  const { url } = useRouteMatch();
+  const [isLoading, setIsLoading] = useState(true);
+  const [databases, setDatabases] = useState<DatabaseObject[]>([]);
+  const [isAuth] = useAuth();
+  const { pushQueryParams } = useRouting();
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getDatabases(
+        "<token>",
+        {},
+        { project_id: currentProject.id, cluster_id: currentCluster.id }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setDatabases(res.data);
+          setIsLoading(false);
+        }
+      })
+      .catch((error) => {
+        console.error(error);
+        setCurrentError(error);
+      });
+
+    // if (isSubscribed) {
+    //   setDatabases(mock_database_list);
+    //   setIsLoading(false);
+    // }
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const handleDeleteDatabase = async (project_id: number, infra_id: number) => {
+    try {
+      await api.destroyInfra(
+        "<token>",
+        {},
+        {
+          project_id,
+          infra_id,
+        }
+      );
+
+      setCurrentOverlay(null);
+      pushQueryParams({ current_tab: "provisioner-status" });
+    } catch (error) {
+      console.error(error);
+      setCurrentError("We couldn't delete the infra, please try again.");
+    }
+  };
+
+  const columns = useMemo<Column<DatabaseObject>[]>(() => {
+    let columns: Column<DatabaseObject>[] = [
+      {
+        Header: "Instance id",
+        accessor: "instance_id",
+      },
+      {
+        Header: "Name",
+        accessor: "instance_name",
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+        Cell: ({ cell }) => {
+          const status: "running" | "destroying" = cell.value as any;
+          return <Status status={status}>{status}</Status>;
+        },
+      },
+      {
+        Header: "Endpoint",
+        accessor: "instance_endpoint",
+        Cell: ({ row }) => {
+          return (
+            <>
+              <CopyToClipboard as={Url} text={row.original.instance_endpoint}>
+                <span>{row.original.instance_endpoint}</span>
+                <i className="material-icons-outlined">content_copy</i>
+              </CopyToClipboard>
+            </>
+          );
+        },
+      },
+      {
+        id: "connect_button",
+        Cell: ({ row }: any) => {
+          return (
+            <>
+              <ConnectButton
+                onClick={() =>
+                  setCurrentModal("ConnectToDatabaseInstructionsModal", {
+                    endpoint: row.original.instance_endpoint,
+                    name: row.original.instance_name,
+                  })
+                }
+              >
+                Connect
+              </ConnectButton>
+            </>
+          );
+        },
+        width: 50,
+      },
+    ];
+
+    if (isAuth("cluster", "", ["get", "delete"])) {
+      columns.push({
+        id: "delete_button",
+        Cell: ({ row }: { row: Row<DatabaseObject> }) => {
+          return (
+            <>
+              <DeleteButton
+                onClick={() =>
+                  setCurrentOverlay({
+                    message: `Are you sure you want to delete ${row.original.instance_name}?`,
+                    onYes: () =>
+                      handleDeleteDatabase(
+                        row.original.project_id,
+                        row.original.infra_id
+                      ),
+                    onNo: () => setCurrentOverlay(null),
+                  })
+                }
+              >
+                <i className="material-icons">delete</i>
+              </DeleteButton>
+            </>
+          );
+        },
+        width: 50,
+      });
+    } else {
+      columns = columns.filter((col) => col.id !== "delete_button");
+    }
+
+    return columns;
+  }, [user]);
+
+  const data = useMemo<Array<DatabaseObject>>(() => {
+    return databases;
+  }, [databases]);
+
+  return (
+    <DatabasesListWrapper>
+      <ControlRow>
+        <Button to={`${url}/provision-database`}>
+          <i className="material-icons">add</i>
+          Create database
+        </Button>
+      </ControlRow>
+      <StyledTableWrapper>
+        <Table columns={columns} data={data} isLoading={isLoading} />
+      </StyledTableWrapper>
+    </DatabasesListWrapper>
+  );
+};
+
+export default DatabasesList;
+
+const Status = styled.div<{ status: "running" | "destroying" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "running") return "#38a88a";
+    if (props.status === "destroying") return "#cc3d42";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const DatabasesListWrapper = styled.div`
+  margin-top: 35px;
+`;
+
+const StyledTableWrapper = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  :hover {
+    cursor: pointer;
+  }
+`;
+
+const Button = styled(Link)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const ConnectButton = styled.button<{}>`
+  height: 25px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: #5561c0;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: pointer;
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: brightness(120%);
+  }
+`;

+ 25 - 0
dashboard/src/main/home/cluster-dashboard/databases/mock_data.ts

@@ -0,0 +1,25 @@
+import { DatabaseObject } from "./DatabasesList";
+
+export const mock_database_list: DatabaseObject[] = [
+  {
+    cluster_id: 1,
+    instance_endpoint: "some/some",
+    instance_id: "my-id",
+    instance_name: "instance-name",
+    project_id: 3,
+  },
+  {
+    cluster_id: 1,
+    instance_endpoint: "some/some",
+    instance_id: "my-id",
+    instance_name: "instance-name",
+    project_id: 3,
+  },
+  {
+    cluster_id: 1,
+    instance_endpoint: "some/some",
+    instance_id: "my-id",
+    instance_name: "instance-name",
+    project_id: 3,
+  },
+];

+ 37 - 0
dashboard/src/main/home/cluster-dashboard/databases/routes.tsx

@@ -0,0 +1,37 @@
+import React, { useContext, useEffect, useLayoutEffect } from "react";
+import { Route, Switch, useRouteMatch } from "react-router";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import CreateDatabaseForm from "./CreateDatabaseForm";
+import DatabasesHome from "./DatabasesHome";
+
+const DatabasesRoutes = () => {
+  const { url } = useRouteMatch();
+  const { currentCluster, currentProject } = useContext(Context);
+  const { pushFiltered } = useRouting();
+
+  useLayoutEffect(() => {
+    if (
+      currentCluster.service !== "eks" ||
+      currentCluster.infra_id <= 0 ||
+      !currentProject.enable_rds_databases
+    ) {
+      pushFiltered("/cluster-dashboard", []);
+    }
+  }, [currentCluster]);
+
+  return (
+    <>
+      <Switch>
+        <Route path={`${url}/provision-database`}>
+          <CreateDatabaseForm />
+        </Route>
+        <Route path={`${url}/`}>
+          <DatabasesHome />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default DatabasesRoutes;

+ 114 - 0
dashboard/src/main/home/cluster-dashboard/databases/static_data.ts

@@ -0,0 +1,114 @@
+export const POSTGRES_ENGINE_VERSIONS: {
+  [key: string]: { label: string; value: string }[];
+} = {
+  postgres9: [
+    { label: "v9.6.1", value: "9.6.1" },
+    { label: "v9.6.2", value: "9.6.2" },
+    { label: "v9.6.3", value: "9.6.3" },
+    { label: "v9.6.4", value: "9.6.4" },
+    { label: "v9.6.5", value: "9.6.5" },
+    { label: "v9.6.6", value: "9.6.6" },
+    { label: "v9.6.7", value: "9.6.7" },
+    { label: "v9.6.8", value: "9.6.8" },
+    { label: "v9.6.9", value: "9.6.9" },
+    { label: "v9.6.10", value: "9.6.10" },
+    { label: "v9.6.11", value: "9.6.11" },
+    { label: "v9.6.12", value: "9.6.12" },
+    { label: "v9.6.13", value: "9.6.13" },
+    { label: "v9.6.14", value: "9.6.14" },
+    { label: "v9.6.15", value: "9.6.15" },
+    { label: "v9.6.16", value: "9.6.16" },
+    { label: "v9.6.17", value: "9.6.17" },
+    { label: "v9.6.18", value: "9.6.18" },
+    { label: "v9.6.19", value: "9.6.19" },
+    { label: "v9.6.20", value: "9.6.20" },
+    { label: "v9.6.21", value: "9.6.21" },
+    { label: "v9.6.22", value: "9.6.22" },
+    { label: "v9.6.23", value: "9.6.23" },
+  ],
+  postgres10: [
+    { label: "v10.1", value: "10.1" },
+    { label: "v10.2", value: "10.2" },
+    { label: "v10.3", value: "10.3" },
+    { label: "v10.4", value: "10.4" },
+    { label: "v10.5", value: "10.5" },
+    { label: "v10.6", value: "10.6" },
+    { label: "v10.7", value: "10.7" },
+    { label: "v10.8", value: "10.8" },
+    { label: "v10.9", value: "10.9" },
+    { label: "v10.10", value: "10.10" },
+    { label: "v10.11", value: "10.11" },
+    { label: "v10.12", value: "10.12" },
+    { label: "v10.13", value: "10.13" },
+    { label: "v10.14", value: "10.14" },
+    { label: "v10.15", value: "10.15" },
+    { label: "v10.16", value: "10.16" },
+    { label: "v10.17", value: "10.17" },
+    { label: "v10.18", value: "10.18" },
+  ],
+  postgres11: [
+    { label: "v11.1", value: "11.1" },
+    { label: "v11.2", value: "11.2" },
+    { label: "v11.3", value: "11.3" },
+    { label: "v11.4", value: "11.4" },
+    { label: "v11.5", value: "11.5" },
+    { label: "v11.6", value: "11.6" },
+    { label: "v11.7", value: "11.7" },
+    { label: "v11.8", value: "11.8" },
+    { label: "v11.9", value: "11.9" },
+    { label: "v11.10", value: "11.10" },
+    { label: "v11.11", value: "11.11" },
+    { label: "v11.12", value: "11.12" },
+    { label: "v11.13", value: "11.13" },
+  ],
+  postgres12: [
+    { label: "v12.2", value: "12.2" },
+    { label: "v12.3", value: "12.3" },
+    { label: "v12.4", value: "12.4" },
+    { label: "v12.5", value: "12.5" },
+    { label: "v12.6", value: "12.6" },
+    { label: "v12.7", value: "12.7" },
+    { label: "v12.8", value: "12.8" },
+  ],
+  postgres13: [
+    { label: "v13.1", value: "13.1" },
+    { label: "v13.2", value: "13.2" },
+    { label: "v13.3", value: "13.3" },
+    { label: "v13.4", value: "13.4" },
+  ],
+};
+
+export const POSTGRES_DB_FAMILIES = (() => {
+  const dbFamilies = Object.keys(POSTGRES_ENGINE_VERSIONS);
+  return dbFamilies.map((family) => {
+    return {
+      label: family,
+      value: family,
+    };
+  });
+})();
+
+export const DEFAULT_DB_FAMILY =
+  POSTGRES_DB_FAMILIES[POSTGRES_DB_FAMILIES.length - 1].value;
+
+export const LAST_POSTGRES_ENGINE_VERSION =
+  POSTGRES_ENGINE_VERSIONS.postgres13[
+    POSTGRES_ENGINE_VERSIONS.postgres13.length - 1
+  ].value;
+
+export const FORM_DEFAULT_VALUES = {
+  db_allocated_storage: "10",
+  db_max_allocated_storage: "20",
+  db_storage_encrypted: false,
+};
+
+export const DATABASE_INSTANCE_TYPES = [
+  { value: "db.t2.medium", label: "db.t2.medium" },
+  { value: "db.t2.xlarge", label: "db.t2.xlarge" },
+  { value: "db.t2.2xlarge", label: "db.t2.2xlarge" },
+  { value: "db.t3.medium", label: "db.t3.medium" },
+  { value: "db.t3.xlarge", label: "db.t3.xlarge" },
+  { value: "db.t3.2xlarge", label: "db.t3.2xlarge" },
+];
+
+export const DEFAULT_DATABASE_INSTANCE_TYPE = DATABASE_INSTANCE_TYPES[0].value;

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

@@ -86,7 +86,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       });
 
     api
-      .createConfigMap(
+      .createEnvGroup(
         "<token>",
         {
           name: this.state.envGroupName,
@@ -101,6 +101,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         this.setState({ submitStatus: "successful" });
+        console.log(res);
         this.props.goBack();
       })
       .catch((err) => {

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

@@ -6,8 +6,10 @@ import key from "assets/key.svg";
 import { Context } from "shared/Context";
 
 export type EnvGroupData = {
-  data: Record<string, string>;
-  metadata: any;
+  name: string;
+  namespace: string;
+  created_at?: string;
+  version: number;
 };
 
 type PropsType = {
@@ -38,10 +40,10 @@ export default class EnvGroup extends Component<PropsType, StateType> {
 
   render() {
     let { envGroup, setExpanded } = this.props;
-    let name = envGroup?.metadata?.name;
-    let timestamp = envGroup?.metadata?.creationTimestamp;
-    let namespace = envGroup?.metadata?.namespace;
-    let varCount = Object.values(envGroup?.data || {}).length;
+    let name = envGroup?.name;
+    let timestamp = envGroup?.created_at;
+    let namespace = envGroup?.namespace;
+    let version = envGroup?.version;
 
     return (
       <StyledEnvGroup
@@ -70,7 +72,7 @@ export default class EnvGroup extends Component<PropsType, StateType> {
           </TagWrapper>
         </BottomWrapper>
 
-        <Version>{varCount} variables</Version>
+        <Version>v{version}</Version>
       </StyledEnvGroup>
     );
   }

+ 143 - 228
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -1,10 +1,10 @@
-import React, { Component } from "react";
+import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import Modal from "main/home/modals/Modal";
 import EnvEditorModal from "main/home/modals/EnvEditorModal";
 
-import sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
+import { parseStringToEnvObject } from "./utils";
 
 export type KeyValueType = {
   key: string;
@@ -18,193 +18,41 @@ type PropsType = {
   label?: string;
   values: KeyValueType[];
   setValues: (x: KeyValueType[]) => void;
-  width?: string;
   disabled?: boolean;
-  namespace?: string;
-  clusterId?: number;
-  envLoader?: boolean;
   fileUpload?: boolean;
   secretOption?: boolean;
 };
 
 type StateType = {
-  showEnvModal: boolean;
   showEditorModal: boolean;
 };
 
-export default class EnvGroupArray extends Component<PropsType, StateType> {
-  state = {
-    showEnvModal: false,
-    showEditorModal: false,
-  };
-
-  componentDidMount() {
-    if (!this.props.values) {
-      let _values = [] as KeyValueType[];
-      this.props.setValues(_values);
-    }
-  }
-
-  renderDeleteButton = (i: number) => {
-    if (!this.props.disabled) {
-      return (
-        <DeleteButton
-          onClick={() => {
-            let _values = this.props.values;
-            _values[i].deleted = true;
-            this.props.setValues(_values);
-          }}
-        >
-          <i className="material-icons">cancel</i>
-        </DeleteButton>
-      );
-    }
-  };
-
-  renderHiddenOption = (hidden: boolean, locked: boolean, i: number) => {
-    if (this.props.secretOption) {
-      let icon = <i className="material-icons">lock_open</i>;
-
-      if (hidden) {
-        icon = <i className="material-icons">lock</i>;
-      }
-
-      return (
-        <HideButton
-          onClick={() => {
-            if (!locked) {
-              let _values = this.props.values;
-              _values[i].hidden = !_values[i].hidden;
-              this.props.setValues(_values);
-            }
-          }}
-          disabled={locked}
-        >
-          {icon}
-        </HideButton>
-      );
-    }
-  };
-
-  renderInputList = () => {
-    return (
-      <>
-        {this.props.values.map((entry: KeyValueType, i: number) => {
-          if (!entry.deleted) {
-            return (
-              <InputWrapper key={i}>
-                <Input
-                  placeholder="ex: key"
-                  width="270px"
-                  value={entry.key}
-                  onChange={(e: any) => {
-                    let _values = this.props.values;
-                    _values[i].key = e.target.value;
-                    this.props.setValues(_values);
-                  }}
-                  disabled={this.props.disabled || entry.locked}
-                  spellCheck={false}
-                />
-                <Spacer />
-                <Input
-                  placeholder="ex: value"
-                  width="270px"
-                  value={entry.value}
-                  onChange={(e: any) => {
-                    let _values = this.props.values;
-                    _values[i].value = e.target.value;
-                    this.props.setValues(_values);
-                  }}
-                  disabled={this.props.disabled || entry.locked}
-                  type={entry.hidden ? "password" : "text"}
-                  spellCheck={false}
-                />
-                {this.renderHiddenOption(entry.hidden, entry.locked, i)}
-                {this.renderDeleteButton(i)}
-              </InputWrapper>
-            );
-          }
-        })}
-      </>
-    );
-  };
-
-  renderEditorModal = () => {
-    if (this.state.showEditorModal) {
-      return (
-        <Modal
-          onRequestClose={() => this.setState({ showEditorModal: false })}
-          width="60%"
-          height="80%"
-        >
-          <EnvEditorModal
-            closeModal={() => this.setState({ showEditorModal: false })}
-            setEnvVariables={(envFile: string) => this.readFile(envFile)}
-          />
-        </Modal>
-      );
+const EnvGroupArray = ({
+  label,
+  values,
+  setValues,
+  disabled,
+  fileUpload,
+  secretOption,
+}: PropsType) => {
+  const [showEditorModal, setShowEditorModal] = useState(false);
+
+  useEffect(() => {
+    if (!values) {
+      setValues([]);
     }
-  };
-
-  // Parses src into an Object
-  parseEnv = (src: any, options: any) => {
-    const debug = Boolean(options && options.debug);
-    const obj = {} as Record<string, string>;
-    const NEWLINE = "\n";
-    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
-    const RE_NEWLINES = /\\n/g;
-    const NEWLINES_MATCH = /\n|\r|\r\n/;
-
-    // convert Buffers before splitting into lines and processing
-    src
-      .toString()
-      .split(NEWLINES_MATCH)
-      .forEach(function (line: any, idx: any) {
-        // matching "KEY' and 'VAL' in 'KEY=VAL'
-        const keyValueArr = line.match(RE_INI_KEY_VAL);
-        // matched?
-        if (keyValueArr != null) {
-          const key = keyValueArr[1];
-          // default undefined or missing values to empty string
-          let val = keyValueArr[2] || "";
-          const end = val.length - 1;
-          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
-          const isSingleQuoted = val[0] === "'" && val[end] === "'";
-
-          // if single or double quoted, remove quotes
-          if (isSingleQuoted || isDoubleQuoted) {
-            val = val.substring(1, end);
+  }, [values]);
 
-            // if double quoted, expand newlines
-            if (isDoubleQuoted) {
-              val = val.replace(RE_NEWLINES, NEWLINE);
-            }
-          } else {
-            // remove surrounding whitespace
-            val = val.trim();
-          }
-
-          obj[key] = val;
-        } else if (debug) {
-          console.log(
-            `did not match key and value when parsing line ${idx + 1}: ${line}`
-          );
-        }
-      });
-
-    return obj;
-  };
-
-  readFile = (env: string) => {
-    const envObj = this.parseEnv(env, null);
-    const _values = this.props.values;
+  const readFile = (env: string) => {
+    const envObj = parseStringToEnvObject(env, null);
+    const _values = values;
 
     for (const key in envObj) {
       let push = true;
 
-      for (let i = 0; i < this.props.values.length; i++) {
-        const existingKey = this.props.values[i]["key"];
-        const isExistingKeyDeleted = this.props.values[i]["deleted"];
+      for (let i = 0; i < values.length; i++) {
+        const existingKey = values[i]["key"];
+        const isExistingKeyDeleted = values[i]["deleted"];
         if (key === existingKey && !isExistingKeyDeleted) {
           _values[i]["value"] = envObj[key];
           push = false;
@@ -222,65 +70,132 @@ export default class EnvGroupArray extends Component<PropsType, StateType> {
       }
     }
 
-    this.props.setValues(_values);
+    setValues(_values);
   };
 
-  render() {
-    if (this.props.values) {
-      return (
-        <>
-          <StyledInputArray>
-            <Label>{this.props.label}</Label>
-            {this.props.values.length === 0 ? <></> : this.renderInputList()}
-            {this.props.disabled ? (
-              <></>
-            ) : (
-              <InputWrapper>
-                <AddRowButton
-                  onClick={() => {
-                    let _values = this.props.values;
-                    _values.push({
-                      key: "",
-                      value: "",
-                      hidden: false,
-                      locked: false,
-                      deleted: false,
-                    });
-                    this.props.setValues(_values);
-                  }}
-                >
-                  <i className="material-icons">add</i> Add Row
-                </AddRowButton>
-                <Spacer />
-                {this.props.namespace && this.props.envLoader && (
-                  <LoadButton
-                    onClick={() =>
-                      this.setState({ showEnvModal: !this.state.showEnvModal })
-                    }
-                  >
-                    <img src={sliders} /> Load from Env Group
-                  </LoadButton>
-                )}
-                {this.props.fileUpload && (
-                  <UploadButton
-                    onClick={() => {
-                      this.setState({ showEditorModal: true });
+  if (!values) {
+    return null;
+  }
+
+  return (
+    <>
+      <StyledInputArray>
+        <Label>{label}</Label>
+        {!!values?.length &&
+          values.map((entry: KeyValueType, i: number) => {
+            if (!entry.deleted) {
+              return (
+                <InputWrapper key={i}>
+                  <Input
+                    placeholder="ex: key"
+                    width="270px"
+                    value={entry.key}
+                    onChange={(e: any) => {
+                      let _values = values;
+                      _values[i].key = e.target.value;
+                      setValues(_values);
                     }}
-                  >
-                    <img src={upload} /> Copy from File
-                  </UploadButton>
-                )}
-              </InputWrapper>
+                    disabled={disabled || entry.locked}
+                    spellCheck={false}
+                  />
+                  <Spacer />
+                  <Input
+                    placeholder="ex: value"
+                    width="270px"
+                    value={entry.value}
+                    onChange={(e: any) => {
+                      let _values = values;
+                      _values[i].value = e.target.value;
+                      setValues(_values);
+                    }}
+                    disabled={disabled || entry.locked}
+                    type={entry.hidden ? "password" : "text"}
+                    spellCheck={false}
+                  />
+
+                  {secretOption && (
+                    <HideButton
+                      onClick={() => {
+                        if (!entry.locked) {
+                          let _values = values;
+                          _values[i].hidden = !_values[i].hidden;
+                          setValues(_values);
+                        }
+                      }}
+                      disabled={entry.locked}
+                    >
+                      {entry.hidden ? (
+                        <i className="material-icons">lock</i>
+                      ) : (
+                        <i className="material-icons">lock_open</i>
+                      )}
+                    </HideButton>
+                  )}
+
+                  {!disabled && (
+                    <DeleteButton
+                      onClick={() => {
+                        let _values = values;
+                        _values = _values.filter(
+                          (val) => val.key !== entry.key
+                        );
+                        setValues(_values);
+                      }}
+                    >
+                      <i className="material-icons">cancel</i>
+                    </DeleteButton>
+                  )}
+                </InputWrapper>
+              );
+            }
+          })}
+        {!disabled && (
+          <InputWrapper>
+            <AddRowButton
+              onClick={() => {
+                let _values = values;
+                _values.push({
+                  key: "",
+                  value: "",
+                  hidden: false,
+                  locked: false,
+                  deleted: false,
+                });
+                setValues(_values);
+              }}
+            >
+              <i className="material-icons">add</i> Add Row
+            </AddRowButton>
+            <Spacer />
+            {fileUpload && (
+              <UploadButton
+                onClick={() => {
+                  setShowEditorModal(true);
+                }}
+              >
+                <img src={upload} /> Copy from File
+              </UploadButton>
             )}
-          </StyledInputArray>
-          {this.renderEditorModal()}
-        </>
-      );
-    }
+          </InputWrapper>
+        )}
+      </StyledInputArray>
+      {showEditorModal && (
+        <Modal
+          onRequestClose={() => setShowEditorModal(false)}
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() => setShowEditorModal(false)}
+            setEnvVariables={(envFile: string) => readFile(envFile)}
+          />
+        </Modal>
+      )}
+    </>
+  );
+};
 
-    return null;
-  }
-}
+export default EnvGroupArray;
 
 const Spacer = styled.div`
   width: 10px;

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

@@ -113,8 +113,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
       return (
         <ExpandedEnvGroup
           namespace={
-            this.state.expandedEnvGroup?.metadata?.namespace ||
-            this.state.namespace
+            this.state.expandedEnvGroup?.namespace || this.state.namespace
           }
           currentCluster={this.props.currentCluster}
           envGroup={this.state.expandedEnvGroup}

+ 6 - 13
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -36,7 +36,7 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
 
   updateEnvGroups = () => {
     api
-      .listConfigMaps(
+      .listEnvGroups(
         "<token>",
         {},
         {
@@ -46,32 +46,25 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        let sortedGroups = res?.data?.items;
+        let sortedGroups = res?.data;
         switch (this.props.sortType) {
           case "Oldest":
             sortedGroups.sort((a: any, b: any) =>
-              Date.parse(a.metadata.creationTimestamp) >
-              Date.parse(b.metadata.creationTimestamp)
-                ? 1
-                : -1
+              Date.parse(a.created_at) > Date.parse(b.created_at) ? 1 : -1
             );
             break;
           case "Alphabetical":
-            sortedGroups.sort((a: any, b: any) =>
-              a.metadata.name > b.metadata.name ? 1 : -1
-            );
+            sortedGroups.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
             break;
           default:
             sortedGroups.sort((a: any, b: any) =>
-              Date.parse(a.metadata.creationTimestamp) >
-              Date.parse(b.metadata.creationTimestamp)
-                ? -1
-                : 1
+              Date.parse(a.created_at) > Date.parse(b.created_at) ? -1 : 1
             );
         }
         this.setState({ envGroups: sortedGroups, loading: false });
       })
       .catch((err) => {
+        console.log(err);
         this.setState({ loading: false, error: true });
       });
   };

+ 520 - 334
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -1,5 +1,11 @@
-import React, { Component } from "react";
-import styled from "styled-components";
+import React, {
+  Component,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+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";
@@ -17,6 +23,13 @@ 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 { 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";
 
 type PropsType = WithAuthProps & {
   namespace: string;
@@ -37,245 +50,394 @@ type StateType = {
 
 type EnvGroup = {
   name: string;
-  timestamp: string;
+  // timestamp: string;
   variables: KeyValueType[];
+  version: number;
 };
 
-const tabOptions = [
-  { value: "environment", label: "Environment Variables" },
-  { value: "settings", label: "Settings" },
-];
-
-class ExpandedEnvGroup extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    currentTab: "environment",
-    deleting: false,
-    saveValuesStatus: null as string | null,
-    envGroup: {
-      name: null as string,
-      timestamp: null as string,
-      variables: [] as KeyValueType[],
-    },
-    tabOptions: [
-      { value: "environment", label: "Environment Variables" },
-      { value: "settings", label: "Settings" },
-    ],
-    newEnvGroupName: null as string,
-  };
+// export default withAuth(ExpandedEnvGroup);
 
-  populateEnvGroup = (envGroup: any) => {
-    const {
-      metadata: { name, creationTimestamp: timestamp },
-      data,
-    } = envGroup;
-    // parse env group props into values type
-    const variables = [] as KeyValueType[];
-
-    for (const key in data) {
-      variables.push({
-        key: key,
-        value: data[key],
-        hidden: data[key].includes("PORTERSECRET"),
-        locked: data[key].includes("PORTERSECRET"),
-        deleted: false,
-      });
+type EditableEnvGroup = Omit<PopulatedEnvGroup, "variables"> & {
+  variables: KeyValueType[];
+};
+
+export const ExpandedEnvGroupFC = ({
+  envGroup,
+  namespace,
+  closeExpanded,
+}: PropsType) => {
+  const {
+    currentProject,
+    currentCluster,
+    setCurrentOverlay,
+    setCurrentError,
+  } = useContext(Context);
+  const [isAuthorized] = useAuth();
+
+  const [currentTab, setCurrentTab] = useState("variables-editor");
+  const [isDeleting, setIsDeleting] = useState(false);
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const [currentEnvGroup, setCurrentEnvGroup] = useState<EditableEnvGroup>(
+    null
+  );
+  const [originalEnvVars, setOriginalEnvVars] = useState<
+    {
+      key: string;
+      value: string;
+    }[]
+  >();
+
+  const tabOptions = useMemo(() => {
+    if (!isAuthorized("env_group", "", ["get", "delete"])) {
+      return [{ value: "variables-editor", label: "Environment Variables" }];
     }
 
-    this.setState({
-      envGroup: {
-        name,
-        timestamp,
-        variables,
-      },
-      newEnvGroupName: name,
-    });
-  };
+    if (
+      !isAuthorized("env_group", "", ["get", "delete"]) &&
+      currentEnvGroup?.applications?.length
+    ) {
+      return [
+        { value: "variables-editor", label: "Environment Variables" },
+        { value: "applications", label: "Linked Applications" },
+      ];
+    }
 
-  componentDidMount() {
-    this.populateEnvGroup(this.props.envGroup);
-
-    // Filter the settings tab options as for now it only shows the delete button.
-    // In a future this should be removed and return to a constant if we want to show data
-    // inside the settings tab. (This is make to avoid confussion for the user)
-    this.setState((prevState) => {
-      return {
-        ...prevState,
-        tabOptions: prevState.tabOptions.filter((option) => {
-          if (option.value === "settings") {
-            return this.props.isAuthorized("env_group", "", ["get", "delete"]);
+    if (currentEnvGroup?.applications?.length) {
+      return [
+        { value: "variables-editor", label: "Environment Variables" },
+        { value: "applications", label: "Linked Applications" },
+        { value: "settings", label: "Settings" },
+      ];
+    }
+
+    return [
+      { value: "variables-editor", label: "Environment Variables" },
+      { value: "settings", label: "Settings" },
+    ];
+  }, [currentEnvGroup]);
+
+  const populateEnvGroup = async () => {
+    try {
+      const populatedEnvGroup = await api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            name: envGroup.name,
+            id: currentProject.id,
+            namespace: namespace,
+            cluster_id: currentCluster.id,
           }
-          return true;
-        }),
-      };
-    });
-  }
+        )
+        .then((res) => res.data);
+      updateEnvGroup(populatedEnvGroup);
+    } catch (error) {
+      console.log(error);
+    }
+  };
 
-  handleRename = () => {
-    const { namespace } = this.props;
-    const {
-      envGroup: { name },
-      newEnvGroupName: newName,
-    } = this.state;
+  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,
+      }))
+    );
 
-    api
-      .renameConfigMap(
-        "<token>",
-        {
-          name,
-          new_name: newName,
-        },
-        {
-          id: this.context.currentProject.id,
-          cluster_id: this.props.currentCluster.id,
-          namespace,
-        }
-      )
-      .then((res) => {
-        this.populateEnvGroup(res.data);
-      });
+    setCurrentEnvGroup({
+      ...populatedEnvGroup,
+      variables,
+    });
   };
 
-  handleUpdateValues = () => {
-    const { namespace } = this.props;
-    const {
-      envGroup: { name, variables: envVariables },
-    } = this.state;
-
-    const apiEnvVariables: Record<string, string> = {};
-    const secretEnvVariables: Record<string, string> = {};
-
-    envVariables
-      .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
-        // remove any collisions that are marked as deleted and are duplicates, unless they are
-        // all delete collisions
-        const numDeleteCollisions = self.reduce((n, _envVar: KeyValueType) => {
-          return n + (_envVar.key === envVar.key && envVar.deleted ? 1 : 0);
-        }, 0);
-
-        const numCollisions = self.reduce((n, _envVar: KeyValueType) => {
-          return n + (_envVar.key === envVar.key ? 1 : 0);
-        }, 0);
-
-        if (numCollisions == numDeleteCollisions) {
-          // if all collisions are delete collisions, just remove duplicates
-          return (
-            index ===
-            self.findIndex(
-              (_envVar: KeyValueType) => _envVar.key === envVar.key
-            )
-          );
-        } else if (numCollisions == 1) {
-          // if there's just one collision (self), keep the object
-          return true;
-        } else {
-          // if there are more collisions than delete collisions, remove all duplicates that
-          // are deletions
-          return (
-            index ===
-            self.findIndex(
-              (_envVar: KeyValueType) =>
-                _envVar.key === envVar.key && !_envVar.deleted
-            )
-          );
-        }
-      })
-      .forEach((envVar: KeyValueType) => {
-        if (envVar.hidden) {
-          if (envVar.deleted) {
-            secretEnvVariables[envVar.key] = null;
-          } else if (!envVar.value.includes("PORTERSECRET")) {
-            secretEnvVariables[envVar.key] = envVar.value;
-          }
-        } else {
-          if (envVar.deleted) {
-            apiEnvVariables[envVar.key] = null;
-          } else {
-            apiEnvVariables[envVar.key] = envVar.value;
-          }
-        }
-      });
+  const handleDeleteEnvGroup = () => {
+    const { name } = currentEnvGroup;
 
-    this.setState({ saveValuesStatus: "loading" });
+    setIsDeleting(true);
+    setCurrentOverlay(null);
     api
-      .updateConfigMap(
+      .deleteEnvGroup(
         "<token>",
         {
           name,
-          variables: apiEnvVariables,
-          secret_variables: secretEnvVariables,
         },
         {
-          id: this.context.currentProject.id,
-          cluster_id: this.props.currentCluster.id,
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
           namespace,
         }
       )
-      .then((res) => {
-        this.setState({ saveValuesStatus: "successful" });
+      .then(() => {
+        closeExpanded();
+        setIsDeleting(true);
       })
-      .catch((err) => {
-        this.setState({ saveValuesStatus: "error" });
+      .catch(() => {
+        setIsDeleting(true);
       });
   };
 
-  renderTabContents = () => {
-    const { namespace } = this.props;
-    const {
-      envGroup: { name, variables },
-      newEnvGroupName: newName,
-      currentTab,
-    } = this.state;
+  const handleUpdateValues = async () => {
+    setButtonStatus("loading");
+    const name = currentEnvGroup.name;
+    let variables = currentEnvGroup.variables;
+
+    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,
+        }),
+        {}
+      );
+
+      const normalVariables = variables.reduce(
+        (acc, variable) => ({
+          ...acc,
+          [variable.key]: variable.value,
+        }),
+        {}
+      );
+
+      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);
+        setButtonStatus("successful");
+        updateEnvGroup(updatedEnvGroup);
+        setTimeout(() => setButtonStatus(""), 1000);
+      } catch (error) {
+        setButtonStatus("Couldn't update successfully");
+        setCurrentError(error);
+        setTimeout(() => setButtonStatus(""), 1000);
+      }
+    } else {
+      const configMapSecretVariables = fillWithDeletedVariables(
+        originalEnvVars.filter((variable) => {
+          return variable.value.includes("PORTERSECRET");
+        }),
+        variables.filter((variable) => {
+          return variable.value.includes("PORTERSECRET") || variable.hidden;
+        })
+      ).reduce(
+        (acc, variable) => ({
+          ...acc,
+          [variable.key]: variable.value,
+        }),
+        {}
+      );
+
+      const configMapVariables = fillWithDeletedVariables(
+        originalEnvVars,
+        variables.filter(
+          (variable) =>
+            !variable.hidden || !variable.value?.includes("PORTERSECRET")
+        )
+      ).reduce(
+        (acc, variable) => ({
+          ...acc,
+          [variable.key]: variable.value,
+        }),
+        {}
+      );
+
+      try {
+        const updatedEnvGroup = await api
+          .updateConfigMap(
+            "<token>",
+            {
+              name,
+              variables: configMapVariables,
+              secret_variables: configMapSecretVariables,
+            },
+            {
+              id: currentProject.id,
+              cluster_id: currentCluster.id,
+              namespace,
+            }
+          )
+          .then((res) => res.data);
+        setButtonStatus("successful");
+        updateEnvGroup(updatedEnvGroup);
+        setTimeout(() => setButtonStatus(""), 1000);
+      } catch (error) {
+        setButtonStatus("Couldn't update successfully");
+        setCurrentError(error);
+        setTimeout(() => setButtonStatus(""), 1000);
+      }
+    }
+  };
 
-    const isEnvGroupNameValid = isAlphanumeric(newName) && newName !== "";
-    const isEnvGroupNameDifferent = newName !== name;
+  const renderTabContents = () => {
+    const { variables } = currentEnvGroup;
 
     switch (currentTab) {
-      case "environment":
+      case "variables-editor":
         return (
-          <TabWrapper>
-            <InnerWrapper>
-              <Heading>Environment Variables</Heading>
-              <Helper>
-                Set environment variables for your secrets and
-                environment-specific configuration.
-              </Helper>
-              <EnvGroupArray
-                namespace={namespace}
-                values={variables}
-                setValues={(x: any) =>
-                  this.setState((prevState) => ({
-                    envGroup: { ...prevState.envGroup, variables: x },
-                  }))
-                }
-                fileUpload={true}
-                secretOption={true}
-                disabled={
-                  !this.props.isAuthorized("env_group", "", [
-                    "get",
-                    "create",
-                    "delete",
-                    "update",
-                  ])
-                }
-              />
-            </InnerWrapper>
-            {this.props.isAuthorized("env_group", "", ["get", "update"]) && (
-              <SaveButton
-                text="Update"
-                onClick={() => this.handleUpdateValues()}
-                status={this.state.saveValuesStatus}
-                makeFlush={true}
-              />
-            )}
-          </TabWrapper>
+          <EnvGroupVariablesEditor
+            onChange={(x) =>
+              setCurrentEnvGroup((prev) => ({ ...prev, variables: x }))
+            }
+            handleUpdateValues={handleUpdateValues}
+            variables={variables}
+            buttonStatus={buttonStatus}
+          />
         );
+      case "applications":
+        return <ApplicationsList envGroup={currentEnvGroup} />;
       default:
         return (
-          <TabWrapper>
-            {this.props.isAuthorized("env_group", "", ["get", "delete"]) && (
-              <InnerWrapper full={true}>
-                <Heading>Name</Heading>
+          <EnvGroupSettings
+            envGroup={currentEnvGroup}
+            handleDeleteEnvGroup={handleDeleteEnvGroup}
+          />
+        );
+    }
+  };
+
+  useEffect(() => {
+    populateEnvGroup();
+  }, [envGroup]);
+
+  if (!currentEnvGroup) {
+    return null;
+  }
+
+  return (
+    <StyledExpandedChart>
+      <HeaderWrapper>
+        <BackButton onClick={closeExpanded}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection icon={key} iconWidth="33px">
+          {envGroup.name}
+          <TagWrapper>
+            Namespace <NamespaceTag>{namespace}</NamespaceTag>
+          </TagWrapper>
+        </TitleSection>
+      </HeaderWrapper>
+
+      {isDeleting ? (
+        <>
+          <LineBreak />
+          <Placeholder>
+            <TextWrap>
+              <Header>
+                <Spinner src={loading} /> Deleting "{currentEnvGroup.name}"
+              </Header>
+              You will be automatically redirected after deletion is complete.
+            </TextWrap>
+          </Placeholder>
+        </>
+      ) : (
+        <TabRegion
+          currentTab={currentTab}
+          setCurrentTab={(x: string) => setCurrentTab(x)}
+          options={tabOptions}
+          color={null}
+        >
+          {renderTabContents()}
+        </TabRegion>
+      )}
+    </StyledExpandedChart>
+  );
+};
+
+export default ExpandedEnvGroupFC;
+
+const EnvGroupVariablesEditor = ({
+  onChange,
+  handleUpdateValues,
+  variables,
+  buttonStatus,
+}: {
+  variables: KeyValueType[];
+  buttonStatus: any;
+  onChange: (newValues: any) => void;
+  handleUpdateValues: () => void;
+}) => {
+  const [isAuthorized] = useAuth();
+
+  return (
+    <TabWrapper>
+      <InnerWrapper>
+        <Heading>Environment Variables</Heading>
+        <Helper>
+          Set environment variables for your secrets and environment-specific
+          configuration.
+        </Helper>
+        <EnvGroupArray
+          values={variables}
+          setValues={(x: any) => {
+            onChange(x);
+          }}
+          fileUpload={true}
+          secretOption={true}
+          disabled={
+            !isAuthorized("env_group", "", [
+              "get",
+              "create",
+              "delete",
+              "update",
+            ])
+          }
+        />
+      </InnerWrapper>
+      {isAuthorized("env_group", "", ["get", "update"]) && (
+        <SaveButton
+          text="Update"
+          onClick={() => handleUpdateValues()}
+          status={buttonStatus}
+          makeFlush={true}
+        />
+      )}
+    </TabWrapper>
+  );
+};
+
+const EnvGroupSettings = ({
+  envGroup,
+  handleDeleteEnvGroup,
+}: {
+  envGroup: EditableEnvGroup;
+  handleDeleteEnvGroup: () => void;
+}) => {
+  const { setCurrentOverlay } = useContext(Context);
+  const [isAuthorized] = useAuth();
+
+  const canDelete = useMemo(() => {
+    return envGroup?.applications.length === 0;
+  }, [envGroup]);
+
+  return (
+    <TabWrapper>
+      {isAuthorized("env_group", "", ["get", "delete"]) && (
+        <InnerWrapper full={true}>
+          {/* <Heading>Name</Heading>
                 <Subtitle>
                   <Warning makeFlush={true} highlight={!isEnvGroupNameValid}>
                     Lowercase letters, numbers, and "-" only.
@@ -299,131 +461,81 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
                   Rename {name}
                 </Button>
 
-                <DarkMatter />
+                <DarkMatter /> */}
+
+          <Heading>Manage Environment Group</Heading>
+          <Helper>
+            Permanently delete this set of environment variables. This action
+            cannot be undone.
+          </Helper>
+          {!canDelete && (
+            <Helper color="#f5cb42">
+              Looks like you still have applications syncedto this env group.
+              Please remove this env group from those applications to delete
+            </Helper>
+          )}
+          <Button
+            color="#b91133"
+            onClick={() => {
+              setCurrentOverlay({
+                message: `Are you sure you want to delete ${name}?`,
+                onYes: handleDeleteEnvGroup,
+                onNo: () => setCurrentOverlay(null),
+              });
+            }}
+            disabled={!canDelete}
+          >
+            Delete {envGroup.name}
+          </Button>
+        </InnerWrapper>
+      )}
+    </TabWrapper>
+  );
+};
 
-                <Heading>Manage Environment Group</Heading>
-                <Helper>
-                  Permanently delete this set of environment variables. This
-                  action cannot be undone.
-                </Helper>
-                <Button
-                  color="#b91133"
-                  onClick={() => {
-                    this.context.setCurrentOverlay({
-                      message: `Are you sure you want to delete ${this.state.envGroup.name}?`,
-                      onYes: this.handleDeleteEnvGroup,
-                      onNo: () => this.context.setCurrentOverlay(null),
-                    });
-                  }}
+const ApplicationsList = ({ envGroup }: { envGroup: EditableEnvGroup }) => {
+  const { currentCluster } = useContext(Context);
+
+  return (
+    <>
+      <HeadingWrapper>
+        <Heading isAtTop>Linked applications:</Heading>
+        <DocsHelper
+          link="https://docs.porter.run/deploying-applications/environment-groups#syncing-environment-groups-to-applications"
+          tooltipText="When env group sync is enabled, the applications are automatically restarted when the env groups are updated."
+          placement="top-start"
+          disableMargin
+        />
+      </HeadingWrapper>
+      {envGroup.applications.map((appName) => {
+        return (
+          <StyledCard>
+            <Flex>
+              <ContentContainer>
+                <EventInformation>
+                  <EventName>{appName}</EventName>
+                </EventInformation>
+              </ContentContainer>
+              <ActionContainer>
+                <ActionButton
+                  to={`/applications/${currentCluster.name}/${envGroup.namespace}/${appName}`}
+                  target="_blank"
                 >
-                  Delete {name}
-                </Button>
-              </InnerWrapper>
-            )}
-          </TabWrapper>
+                  <span className="material-icons-outlined">open_in_new</span>
+                </ActionButton>
+              </ActionContainer>
+            </Flex>
+          </StyledCard>
         );
-    }
-  };
-
-  readableDate = (s: string) => {
-    const ts = new Date(s);
-    const date = ts.toLocaleDateString();
-    const time = ts.toLocaleTimeString([], {
-      hour: "numeric",
-      minute: "2-digit",
-    });
-    return `${time} on ${date}`;
-  };
-
-  handleDeleteEnvGroup = () => {
-    const { namespace } = this.props;
-    const {
-      envGroup: { name },
-    } = this.state;
-
-    this.setState({ deleting: true });
-    this.context.setCurrentOverlay(null);
-    api
-      .deleteConfigMap(
-        "<token>",
-        {
-          name,
-        },
-        {
-          id: this.context.currentProject.id,
-          cluster_id: this.props.currentCluster.id,
-          namespace,
-        }
-      )
-      .then((res) => {
-        this.props.closeExpanded();
-        this.setState({ deleting: false });
-      })
-      .catch((err) => {
-        this.setState({ deleting: false });
-      });
-  };
-
-  render() {
-    const { namespace, closeExpanded } = this.props;
-    const {
-      envGroup: { name, timestamp },
-    } = this.state;
-
-    return (
-      <>
-        <StyledExpandedChart>
-          <HeaderWrapper>
-            <BackButton onClick={closeExpanded}>
-              <BackButtonImg src={backArrow} />
-            </BackButton>
-            <TitleSection icon={key} iconWidth="33px">
-              {name}
-              <TagWrapper>
-                Namespace <NamespaceTag>{namespace}</NamespaceTag>
-              </TagWrapper>
-            </TitleSection>
-          </HeaderWrapper>
-
-          <InfoWrapper>
-            <LastDeployed>
-              Last updated {this.readableDate(timestamp)}
-            </LastDeployed>
-          </InfoWrapper>
-
-          {this.state.deleting ? (
-            <>
-              <LineBreak />
-              <Placeholder>
-                <TextWrap>
-                  <Header>
-                    <Spinner src={loading} /> Deleting "
-                    {this.state.envGroup.name}"
-                  </Header>
-                  You will be automatically redirected after deletion is
-                  complete.
-                </TextWrap>
-              </Placeholder>
-            </>
-          ) : (
-            <TabRegion
-              currentTab={this.state.currentTab}
-              setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-              options={this.state.tabOptions}
-              color={null}
-            >
-              {this.renderTabContents()}
-            </TabRegion>
-          )}
-        </StyledExpandedChart>
-      </>
-    );
-  }
-}
-
-ExpandedEnvGroup.contextType = Context;
+      })}
+    </>
+  );
+};
 
-export default withAuth(ExpandedEnvGroup);
+const HeadingWrapper = styled.div`
+  display: flex;
+  margin-bottom: 15px;
+`;
 
 const Header = styled.div`
   font-weight: 500;
@@ -605,11 +717,6 @@ const StyledExpandedChart = styled.div`
   }
 `;
 
-const DarkMatter = styled.div<{ antiHeight?: string }>`
-  width: 100%;
-  margin-top: ${(props) => props.antiHeight || "-15px"};
-`;
-
 const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
   color: ${(props) => (props.highlight ? "#f5cb42" : "")};
   margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
@@ -624,3 +731,82 @@ const Subtitle = styled.div`
   display: flex;
   align-items: center;
 `;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border-radius: 8px;
+  padding: 10px 18px;
+  overflow: hidden;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+
+  background: #2b2e36;
+  margin-bottom: 15px;
+  overflow: hidden;
+  border: 1px solid #ffffff0a;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  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(DynamicLink)`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 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;
+  }
+`;

+ 47 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts

@@ -0,0 +1,47 @@
+export const parseStringToEnvObject = (src: any, options: any) => {
+  const debug = Boolean(options && options.debug);
+  const obj = {} as Record<string, string>;
+  const NEWLINE = "\n";
+  const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+  const RE_NEWLINES = /\\n/g;
+  const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+  // convert Buffers before splitting into lines and processing
+  src
+    .toString()
+    .split(NEWLINES_MATCH)
+    .forEach(function (line: any, idx: any) {
+      // matching "KEY' and 'VAL' in 'KEY=VAL'
+      const keyValueArr = line.match(RE_INI_KEY_VAL);
+      // matched?
+      if (keyValueArr != null) {
+        const key = keyValueArr[1];
+        // default undefined or missing values to empty string
+        let val = keyValueArr[2] || "";
+        const end = val.length - 1;
+        const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+        const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+        // if single or double quoted, remove quotes
+        if (isSingleQuoted || isDoubleQuoted) {
+          val = val.substring(1, end);
+
+          // if double quoted, expand newlines
+          if (isDoubleQuoted) {
+            val = val.replace(RE_NEWLINES, NEWLINE);
+          }
+        } else {
+          // remove surrounding whitespace
+          val = val.trim();
+        }
+
+        obj[key] = val;
+      } else if (debug) {
+        console.log(
+          `did not match key and value when parsing line ${idx + 1}: ${line}`
+        );
+      }
+    });
+
+  return obj;
+};

+ 112 - 11
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -11,12 +11,7 @@ import backArrow from "assets/back_arrow.png";
 import _ from "lodash";
 import loadingSrc from "assets/loading.gif";
 
-import {
-  ChartType,
-  ClusterType,
-  ResourceType,
-  StorageType,
-} from "shared/types";
+import { ChartType, ClusterType, ResourceType } from "shared/types";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import StatusIndicator from "components/StatusIndicator";
@@ -32,9 +27,10 @@ import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
-import { integrationList } from "shared/common";
 import DeploymentType from "./DeploymentType";
 import EventsTab from "./events/EventsTab";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import { onlyInLeft } from "shared/array_utils";
 
 type Props = {
   namespace: string;
@@ -126,9 +122,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
     setImageIsPlaceholer(imageIsPlaceholder);
     setNewestImage(newNewestImage);
 
-    setCurrentChart(res.data);
+    const updatedChart = res.data;
 
-    updateComponents(res.data).finally(() => setIsLoadingChartData(false));
+    setCurrentChart(updatedChart);
+
+    updateComponents(updatedChart).finally(() => setIsLoadingChartData(false));
   };
 
   const getControllers = async (chart: ChartType) => {
@@ -226,7 +224,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const onSubmit = async (rawValues: any) => {
     console.log("raw", rawValues);
     // Convert dotted keys to nested objects
-    let values = {};
+    let values: any = {};
 
     // Weave in preexisting values and convert to yaml
     if (props.currentChart.config) {
@@ -246,8 +244,81 @@ const ExpandedChart: React.FC<Props> = (props) => {
       ...values,
     });
 
+    const oldSyncedEnvGroups =
+      props.currentChart.config?.container?.env?.synced || [];
+    const newSyncedEnvGroups = values?.container?.env?.synced || [];
+
+    const deletedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      oldSyncedEnvGroups,
+      newSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      newSyncedEnvGroups,
+      oldSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addApplicationToEnvGroupPromises = addedEnvGroups.map(
+      (envGroup: any) => {
+        return api.addApplicationToEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: currentChart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: currentChart.namespace,
+          }
+        );
+      }
+    );
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't sync the env group to the application, please try again."
+      );
+    }
+
+    const removeApplicationToEnvGroupPromises = deletedEnvGroups.map(
+      (envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: currentChart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: currentChart.namespace,
+          }
+        );
+      }
+    );
+    try {
+      await Promise.all(removeApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't remove the synced env group from the application, please try again."
+      );
+    }
+
     setSaveValueStatus("loading");
-    getChartData(currentChart);
+
     console.log("valuesYaml", valuesYaml);
     try {
       await api.upgradeChartValues(
@@ -263,6 +334,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
         }
       );
 
+      getChartData(currentChart);
+
       setSaveValueStatus("successful");
       setForceRefreshRevisions(true);
 
@@ -286,6 +359,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
         values: valuesYaml,
         error: err,
       });
+
+      return;
     }
   };
 
@@ -580,6 +655,32 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const handleUninstallChart = async () => {
     setDeleting(true);
     setCurrentOverlay(null);
+    const syncedEnvGroups = currentChart.config?.container?.env?.synced || [];
+    const removeApplicationToEnvGroupPromises = syncedEnvGroups.map(
+      (envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: currentChart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: currentChart.namespace,
+          }
+        );
+      }
+    );
+    try {
+      await Promise.all(removeApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't remove the synced env group from the application, please remove it manually before uninstalling the chart, or try again."
+      );
+      return;
+    }
+
     try {
       await api.uninstallTemplate(
         "<token>",

+ 27 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -3,7 +3,11 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import { RouteComponentProps, withRouter } from "react-router";
 
-import { ChartType, StorageType } from "shared/types";
+import {
+  ChartType,
+  ChartTypeWithExtendedConfig,
+  StorageType,
+} from "shared/types";
 import api from "shared/api";
 import { getQueryParam, pushFiltered } from "shared/routing";
 import ExpandedJobChart from "./ExpandedJobChart";
@@ -11,7 +15,10 @@ import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";
 import PageNotFound from "components/PageNotFound";
 
-type PropsType = RouteComponentProps & {
+type PropsType = RouteComponentProps<{
+  baseRoute: string;
+  namespace: string;
+}> & {
   setSidebar: (x: boolean) => void;
   isMetricsInstalled: boolean;
 };
@@ -34,7 +41,7 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
     let { currentProject, currentCluster } = this.context;
     if (currentProject && currentCluster) {
       api
-        .getChart(
+        .getChart<ChartTypeWithExtendedConfig>(
           "<token>",
           {},
           {
@@ -46,10 +53,26 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
           }
         )
         .then((res) => {
+          const chart = res.data;
           this.setState({ currentChart: res.data, loading: false });
+          const isJob = res.data.form?.name?.toLowerCase() === "job";
+          let route = `${isJob ? "/jobs" : "/applications"}/${
+            currentCluster.name
+          }/${chart.namespace}/${chart.name}`;
+
+          if (isJob && this.props.match.params?.baseRoute === "applications") {
+            pushFiltered(this.props, route, ["project_id"]);
+            return;
+          }
+
+          if (!isJob && this.props.match.params?.baseRoute !== "applications") {
+            pushFiltered(this.props, route, ["project_id"]);
+            return;
+          }
         })
         .catch((err) => {
-          console.log("err", err.response.data);
+          console.log(err);
+          console.log("err", err?.response?.data);
           this.setState({ loading: false });
         });
     }

+ 84 - 32
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -91,13 +91,13 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
     };
   };
 
-  const handleSubmitAddon = (wildcard?: any) => {
+  const handleSubmitAddon = async (wildcard?: any) => {
     let { currentCluster, currentProject, setCurrentError } = context;
     setSaveValuesStatus("loading");
 
     const name = templateName || generateRandomName();
 
-    let values = {};
+    let values: any = {};
     for (let key in wildcard) {
       _.set(values, key, wildcard[key]);
     }
@@ -119,16 +119,6 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
         }
       )
       .then((_) => {
-        // props.setCurrentView('cluster-dashboard');
-        setSaveValuesStatus("successful");
-        // redirect to dashboard
-        let dst =
-          props.currentTemplate.name === "job" ? "/jobs" : "/applications";
-        setTimeout(() => {
-          pushFiltered(props, dst, ["project_id"], {
-            cluster: currentCluster.name,
-          });
-        }, 500);
         window.analytics.track("Deployed Add-on", {
           name: props.currentTemplate.name,
           namespace: selectedNamespace,
@@ -149,7 +139,43 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           values: values,
           error: err,
         });
+        return;
       });
+
+    const synced = values?.container?.env?.synced || [];
+
+    const addApplicationToEnvGroupPromises = synced.map((envGroup: any) => {
+      return api.addApplicationToEnvGroup(
+        "<token>",
+        {
+          name: envGroup?.name,
+          app_name: name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: selectedNamespace,
+        }
+      );
+    });
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't sync the env group to the application, please go to your recently deployed application and try again through the environment tab."
+      );
+    }
+
+    // props.setCurrentView('cluster-dashboard');
+    setSaveValuesStatus("successful");
+    // redirect to dashboard
+    let dst = props.currentTemplate.name === "job" ? "/jobs" : "/applications";
+    setTimeout(() => {
+      pushFiltered(props, dst, ["project_id"], {
+        cluster: currentCluster.name,
+      });
+    }, 500);
   };
 
   const handleSubmit = async (rawValues: any) => {
@@ -259,8 +285,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       }
     }
 
-    api
-      .deployTemplate(
+    try {
+      await api.deployTemplate(
         "<token>",
         {
           image_url: url,
@@ -277,25 +303,51 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           namespace: selectedNamespace,
           repo_url: process.env.APPLICATION_CHART_REPO_URL,
         }
-      )
-      .then((res: any) => {
-        // props.setCurrentView('cluster-dashboard');
-        setSaveValuesStatus("successful");
-        // redirect to dashboard with namespace
-        setTimeout(() => {
-          let dst =
-            props.currentTemplate.name === "job" ? "/jobs" : "/applications";
-          pushFiltered(props, dst, ["project_id"], {
-            cluster: currentCluster.name,
-          });
-        }, 1000);
-      })
-      .catch((err: any) => {
-        let parsedErr = err?.response?.data?.error;
-        err = parsedErr || err.message || JSON.stringify(err);
-        setSaveValuesStatus(`Could not deploy template: ${err}`);
-        setCurrentError(err);
+      );
+      // props.setCurrentView('cluster-dashboard');
+    } catch (err) {
+      let parsedErr = err?.response?.data?.error;
+      err = parsedErr || err.message || JSON.stringify(err);
+      setSaveValuesStatus(`Could not deploy template: ${err}`);
+      setCurrentError(err);
+      return;
+    }
+
+    // Save application into synced groups
+    const synced = values?.container?.env?.synced || [];
+
+    const addApplicationToEnvGroupPromises = synced.map((envGroup: any) => {
+      return api.addApplicationToEnvGroup(
+        "<token>",
+        {
+          name: envGroup?.name,
+          app_name: release_name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: selectedNamespace,
+        }
+      );
+    });
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't sync the env group to the application, please go to your recently deployed application and try again through the environment tab."
+      );
+    }
+
+    setSaveValuesStatus("successful");
+    // redirect to dashboard with namespace
+    setTimeout(() => {
+      let dst =
+        props.currentTemplate.name === "job" ? "/jobs" : "/applications";
+      pushFiltered(props, dst, ["project_id"], {
+        cluster: currentCluster.name,
       });
+    }, 1000);
   };
 
   const renderCurrentPage = () => {

+ 54 - 0
dashboard/src/main/home/modals/ConnectToDatabaseInstructionsModal.tsx

@@ -0,0 +1,54 @@
+import Helper from "components/form-components/Helper";
+import React, { useContext } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+const ConnectToDatabaseInstructionsModal = () => {
+  const { currentModalData } = useContext(Context);
+
+  return (
+    <Container>
+      In order to get connection credentials for your RDS Postgres database,
+      select <b>Load from Env Group</b> when launching or updating your
+      application. Then, select the rds-credentials-{currentModalData?.name}{" "}
+      database.
+      <p>
+        This will set the following environment variables in your application:
+      </p>
+      <CodeBlock>
+        <span>- PGHOST</span>
+        <span>- PGPORT</span>
+        <span>- PGUSER</span>
+        <span>- PGPASSWORD</span>
+      </CodeBlock>
+      <Helper>Note: the database automatically listens on port 5432.</Helper>
+    </Container>
+  );
+};
+
+export default ConnectToDatabaseInstructionsModal;
+
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
+  > span {
+    display: block;
+  }
+`;
+
+const Container = styled.div`
+  margin-top: 30px;
+  line-height: 1.3rem;
+`;

+ 141 - 41
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -13,6 +13,13 @@ import {
   EnvGroupData,
   formattedEnvironmentValue,
 } from "../cluster-dashboard/env-groups/EnvGroup";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import {
+  PartialEnvGroup,
+  PopulatedEnvGroup,
+} from "components/porter-form/types";
+import Helper from "components/form-components/Helper";
+import DocsHelper from "components/DocsHelper";
 
 type PropsType = {
   namespace: string;
@@ -20,6 +27,9 @@ type PropsType = {
   closeModal: () => void;
   existingValues: Record<string, string>;
   setValues: (values: Record<string, string>) => void;
+  enableSyncedEnvGroups?: boolean;
+  syncedEnvGroups?: PopulatedEnvGroup[];
+  setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
 };
 
 type StateType = {
@@ -28,6 +38,7 @@ type StateType = {
   error: boolean;
   selectedEnvGroup: EnvGroupData | null;
   buttonStatus: string;
+  shouldSync: boolean;
 };
 
 export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
@@ -35,35 +46,69 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
     envGroups: [] as any[],
     loading: true,
     error: false,
-    selectedEnvGroup: null as EnvGroupData | null,
+    selectedEnvGroup: null as PopulatedEnvGroup | null,
     buttonStatus: "",
+    shouldSync: false,
   };
 
   onSubmit = () => {
-    this.props.setValues(this.state.selectedEnvGroup.data);
+    if (
+      !this.state.shouldSync ||
+      this.state.selectedEnvGroup.meta_version === 1
+    ) {
+      this.props.setValues(this.state.selectedEnvGroup.variables);
+    } else {
+      this.props.setSyncedEnvGroups(this.state.selectedEnvGroup);
+    }
+
     this.props.closeModal();
   };
 
-  updateEnvGroups = () => {
-    api
-      .listConfigMaps(
-        "<token>",
-        {},
-        {
-          id: this.context.currentProject.id,
-          namespace: this.props.namespace,
-          cluster_id: this.props.clusterId || this.context.currentCluster.id,
-        }
-      )
-      .then((res) => {
-        this.setState({
-          envGroups: res?.data?.items as any[],
-          loading: false,
-        });
-      })
-      .catch((err) => {
-        this.setState({ loading: false, error: true });
+  updateEnvGroups = async () => {
+    let envGroups: PartialEnvGroup[] = [];
+    try {
+      envGroups = await api
+        .listEnvGroups<PartialEnvGroup[]>(
+          "<token>",
+          {},
+          {
+            id: this.context.currentProject.id,
+            namespace: this.props.namespace,
+            cluster_id: this.props.clusterId || this.context.currentCluster.id,
+          }
+        )
+        .then((res) => res.data);
+    } catch (error) {
+      this.setState({ loading: false, error: true });
+      return;
+    }
+
+    const populateEnvGroupsPromises = envGroups.map((envGroup) =>
+      api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: this.context.currentProject.id,
+            cluster_id: this.context.currentCluster.id,
+            name: envGroup.name,
+            namespace: envGroup.namespace,
+            version: envGroup.version,
+          }
+        )
+        .then((res) => res.data)
+    );
+
+    try {
+      const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
+
+      this.setState({
+        envGroups: populatedEnvGroups,
+        loading: false,
       });
+    } catch (error) {
+      this.setState({ loading: false, error: true });
+    }
   };
 
   componentDidMount() {
@@ -77,7 +122,7 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           <Loading />
         </LoadingWrapper>
       );
-    } else if (this.state.envGroups.length === 0) {
+    } else if (!this.state.envGroups?.length) {
       return (
         <Placeholder>
           No environment groups found in this namespace ({this.props.namespace}
@@ -85,19 +130,25 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
         </Placeholder>
       );
     } else {
-      return this.state.envGroups.map((envGroup: any, i: number) => {
-        return (
-          <EnvGroupRow
-            key={i}
-            isSelected={this.state.selectedEnvGroup === envGroup}
-            lastItem={i === this.state.envGroups.length - 1}
-            onClick={() => this.setState({ selectedEnvGroup: envGroup })}
-          >
-            <img src={sliders} />
-            {envGroup.metadata.name}
-          </EnvGroupRow>
-        );
-      });
+      return this.state.envGroups
+        .filter((envGroup) => {
+          return !this.props.syncedEnvGroups.find(
+            (syncedEnvGroup) => syncedEnvGroup.name === envGroup.name
+          );
+        })
+        .map((envGroup: any, i: number) => {
+          return (
+            <EnvGroupRow
+              key={i}
+              isSelected={this.state.selectedEnvGroup === envGroup}
+              lastItem={i === this.state.envGroups.length - 1}
+              onClick={() => this.setState({ selectedEnvGroup: envGroup })}
+            >
+              <img src={sliders} />
+              {envGroup.name}
+            </EnvGroupRow>
+          );
+        });
     }
   };
 
@@ -113,7 +164,7 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
       return "No env group selected";
     }
     if (hasClashingKeys) {
-      return "There are variables defined in this group that will override existing variables.";
+      return "";
     }
   }
 
@@ -150,7 +201,7 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
 
   render() {
     const clashingKeys = this.state.selectedEnvGroup
-      ? this.potentiallyOverriddenKeys(this.state.selectedEnvGroup.data)
+      ? this.potentiallyOverriddenKeys(this.state.selectedEnvGroup.variables)
       : [];
     return (
       <StyledLoadEnvGroupModal>
@@ -172,14 +223,14 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           {this.state.selectedEnvGroup && (
             <SidebarSection>
               <GroupEnvPreview>
-                {Object.entries(this.state.selectedEnvGroup.data)
+                {Object.entries(this.state.selectedEnvGroup.variables)
                   .map(
                     ([key, value]) =>
                       `${key}=${formattedEnvironmentValue(value)}`
                   )
                   .join("\n")}
               </GroupEnvPreview>
-              {clashingKeys.length > 0 && (
+              {clashingKeys?.length > 0 && (
                 <>
                   <ClashingKeyRowDivider />
                   {this.renderEnvGroupPreview(clashingKeys)}
@@ -187,6 +238,39 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
               )}
             </SidebarSection>
           )}
+          <AbsoluteWrapper>
+            {this.props.enableSyncedEnvGroups ? (
+              <>
+                {this.state.selectedEnvGroup?.meta_version === 1 ? (
+                  <Helper color="#f5cb42">
+                    Upgrade this env group from the env groups tab to sync.
+                  </Helper>
+                ) : (
+                  <CheckboxRow
+                    checked={this.state.shouldSync}
+                    toggle={() =>
+                      this.setState((prevState) => ({
+                        shouldSync: !prevState.shouldSync,
+                      }))
+                    }
+                    label="Sync environment group"
+                    disabled={this.state.selectedEnvGroup?.meta_version === 1}
+                  />
+                )}
+                <IconWrapper>
+                  <DocsHelper
+                    link="https://docs.porter.run/deploying-applications/environment-groups#syncing-environment-groups-to-applications"
+                    tooltipText="When env group sync is enabled, the applications are automatically restarted when the env groups are updated."
+                    placement="top-start"
+                  />
+                </IconWrapper>
+              </>
+            ) : (
+              <Helper color="#f5cb42">
+                Upgrade the job template to enable sync env groups
+              </Helper>
+            )}
+          </AbsoluteWrapper>
         </GroupModalSections>
 
         <SaveButton
@@ -202,6 +286,19 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
 
 LoadEnvGroupModal.contextType = Context;
 
+const IconWrapper = styled.div`
+  margin-bottom: -10px;
+`;
+
+const AbsoluteWrapper = styled.div`
+  position: absolute;
+  z-index: 999;
+  bottom: 18px;
+  left: 25px;
+  display: flex;
+  align-items: center;
+`;
+
 const SidebarSection = styled.section<{ $expanded?: boolean }>`
   height: 100%;
   overflow-y: auto;
@@ -394,7 +491,10 @@ const StyledLoadEnvGroupModal = styled.div`
   top: 0;
   height: 100%;
   padding: 25px 30px;
-  overflow: hidden;
-  border-radius: 6px;
+  border-radius: 8px;
   background: #202227;
 `;
+
+const Flex = styled.div`
+  display: flex;
+`;

+ 1 - 1
dashboard/src/main/home/modals/Modal.tsx

@@ -92,7 +92,7 @@ const Overlay = styled.div`
   width: 100%;
   height: 100%;
   background-color: rgba(0, 0, 0, 0.6);
-  z-index: 3;
+  z-index: 999;
   display: flex;
   align-items: center;
   justify-content: center;

+ 6 - 1
dashboard/src/main/home/navbar/Help.tsx

@@ -29,7 +29,12 @@ export default class Help extends Component<PropsType, StateType> {
           <Dropdown dropdownWidth="155px" dropdownMaxHeight="300px">
             <Option
               onClick={() => {
-                window.open("https://docs.porter.run", "_blank").focus();
+                window
+                  .open(
+                    "https://porter-docs-demo-22fd462fef4dcd45.onporter.run",
+                    "_blank"
+                  )
+                  .focus();
               }}
             >
               <i className="material-icons-outlined">book</i>

+ 1 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -119,6 +119,7 @@ const ProvisionResources: React.FC<Props> = () => {
               project_id={project?.id}
               filter={getFilterOpts()}
               setInfraStatus={setInfraStatus}
+              enableNewestInfraFilter
             />
             <Br />
             <Helper>Note: Provisioning can take up to 15 minutes.</Helper>

+ 56 - 6
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/StatusPage.tsx

@@ -1,3 +1,4 @@
+import Loading from "components/Loading";
 import ProvisionerStatus, {
   TFModule,
   TFResource,
@@ -6,11 +7,14 @@ import ProvisionerStatus, {
 import React, { useEffect, useMemo, useRef, useState } from "react";
 import api from "shared/api";
 import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import styled from "styled-components";
 
 type Props = {
   setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
   project_id: number;
   filter: string[];
+  notFoundText?: string;
+  enableNewestInfraFilter?: boolean;
 };
 
 type Infra = {
@@ -53,8 +57,11 @@ export const StatusPage = ({
   filter: selectedFilters,
   project_id,
   setInfraStatus,
+  notFoundText = "We couldn't find any infra being provisioned.",
+  enableNewestInfraFilter,
 }: Props) => {
   const isMounted = useRef(false);
+  const [isLoading, setIsLoading] = useState(true);
 
   const {
     newWebsocket,
@@ -121,22 +128,25 @@ export const StatusPage = ({
         {},
         { project_id: project_id }
       );
+      let infras: Infra[] = [];
       // Filter infras based on what we care only, usually on the onboarding we'll want only the ones
       // currently being provisioned
-      const matchedInfras = res.data.filter(filterBySelectedInfras);
+      infras = res.data.filter(filterBySelectedInfras);
 
-      // Get latest infras for each kind of infra on the array.
-      const latestMatchedInfras = getLatestInfras(matchedInfras);
+      if (enableNewestInfraFilter) {
+        // Get latest infras for each kind of infra on the array.
+        infras = getLatestInfras(infras);
+      }
 
       // Check if all infras are created then enable continue button
-      if (latestMatchedInfras.every((infra) => infra.status === "created")) {
+      if (infras.every((infra) => infra.status === "created")) {
         setInfraStatus({
           hasError: false,
         });
       }
 
       // Init tf modules based on matched infras
-      latestMatchedInfras.forEach((infra) => {
+      infras.forEach((infra) => {
         // Init the module for the hook
         initModule(infra);
 
@@ -294,7 +304,9 @@ export const StatusPage = ({
   }, []);
 
   useEffect(() => {
-    getInfras();
+    getInfras().then(() => {
+      setIsLoading(false);
+    });
     return () => {
       closeAllWebsockets();
     };
@@ -346,6 +358,23 @@ export const StatusPage = ({
     b.id < a.id ? -1 : b.id > a.id ? 1 : 0
   );
 
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (!isLoading && !sortedModules.length) {
+    return (
+      <Placeholder>
+        <i className="material-icons">search</i>
+        {notFoundText}
+      </Placeholder>
+    );
+  }
+
   return <ProvisionerStatus modules={sortedModules} />;
 };
 
@@ -666,3 +695,24 @@ const useModuleChecker = (modules: TFModule[]) => {
     moduleStatuses: moduleStatusesArray,
   };
 };
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;

+ 21 - 7
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -99,7 +99,7 @@ class Sidebar extends Component<PropsType, StateType> {
 
   renderClusterContent = () => {
     let { currentView } = this.props;
-    let { currentCluster } = this.context;
+    let { currentCluster, currentProject } = this.context;
 
     if (currentCluster) {
       return (
@@ -200,6 +200,19 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={sliders} />
             Env Groups
           </NavButton>
+          {currentCluster.service === "eks" &&
+            currentCluster.infra_id > 0 &&
+            currentProject.enable_rds_databases && (
+              <NavButton
+                selected={currentView === "databases"}
+                onClick={() => {
+                  pushFiltered(this.props, "/databases", [], {});
+                }}
+              >
+                <Icon className="material-icons-outlined">storage</Icon>
+                Databases
+              </NavButton>
+            )}
         </>
       );
     }
@@ -364,12 +377,13 @@ const Gutter = styled.div`
   overflow: visible;
 `;
 
-const Icon = styled.img`
-  height: 25px;
-  width: 25px;
-  opacity: 30%;
-  margin-left: 7px;
-  margin-right: 5px;
+const Icon = styled.span`
+  padding: 4px;
+  width: 23px;
+  padding-top: 4px;
+  border-radius: 3px;
+  margin-right: 10px;
+  font-size: 18px;
 `;
 
 const ProjectPlaceholder = styled.div`

+ 138 - 3
dashboard/src/shared/api.tsx

@@ -874,9 +874,7 @@ const getReleaseSteps = baseApi<
 });
 
 const destroyInfra = baseApi<
-  {
-    name: string;
-  },
+  {},
   {
     project_id: number;
     infra_id: number;
@@ -1047,6 +1045,17 @@ const upgradeChartValues = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/0/upgrade`;
 });
 
+const listEnvGroups = baseApi<
+  {},
+  {
+    id: number;
+    namespace: string;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroups/list`;
+});
+
 const listConfigMaps = baseApi<
   {},
   {
@@ -1058,6 +1067,23 @@ const listConfigMaps = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/configmap/list`;
 });
 
+const getEnvGroup = baseApi<
+  {},
+  {
+    id: number;
+    namespace: string;
+    cluster_id: number;
+    name: string;
+    version?: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
+});
+
 const getConfigMap = baseApi<
   {
     name: string;
@@ -1071,6 +1097,38 @@ const getConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/configmap`;
 });
 
+const createEnvGroup = baseApi<
+  {
+    name: string;
+    variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
+  },
+  {
+    id: number;
+    cluster_id: number;
+    namespace: string;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroup/create`;
+});
+
+const updateEnvGroup = baseApi<
+  {
+    name: string;
+    variables: { [key: string]: string };
+    secret_variables?: { [key: string]: string };
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+  }
+>(
+  "POST",
+  ({ cluster_id, project_id, namespace }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/envgroup/create`
+);
+
 const createConfigMap = baseApi<
   {
     name: string;
@@ -1116,6 +1174,19 @@ const renameConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/configmap/rename`;
 });
 
+const deleteEnvGroup = baseApi<
+  {
+    name: string;
+  },
+  {
+    id: number;
+    namespace: string;
+    cluster_id: number;
+  }
+>("DELETE", (pathParams) => {
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/namespaces/${pathParams.namespace}/envgroup`;
+});
+
 const deleteConfigMap = baseApi<
   {
     name: string;
@@ -1342,6 +1413,61 @@ const getPreviousLogsForContainer = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
 );
 
+const addApplicationToEnvGroup = baseApi<
+  {
+    name: string; // Env Group name
+    app_name: string;
+  },
+  { project_id: number; cluster_id: number; namespace: string }
+>(
+  "POST",
+  ({ cluster_id, namespace, project_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/envgroup/add_application`
+);
+
+const removeApplicationFromEnvGroup = baseApi<
+  {
+    name: string; // Env Group name
+    app_name: string;
+  },
+  { project_id: number; cluster_id: number; namespace: string }
+>(
+  "POST",
+  ({ cluster_id, namespace, project_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/envgroup/remove_application`
+);
+
+const provisionDatabase = baseApi<
+  {
+    username: string;
+    password: string;
+    machine_type: string;
+    db_storage_encrypted: boolean;
+    db_name: string;
+    db_max_allocated_storage: string;
+    db_family: string;
+    db_engine_version: string;
+    db_allocated_storage: string;
+  },
+  { project_id: number; cluster_id: number; namespace: string }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/provision/rds`
+);
+
+const getDatabases = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/databases`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1475,4 +1601,13 @@ export default {
   getLogBucketLogs,
   getCanCreateProject,
   getPreviousLogsForContainer,
+  createEnvGroup,
+  updateEnvGroup,
+  listEnvGroups,
+  getEnvGroup,
+  deleteEnvGroup,
+  addApplicationToEnvGroup,
+  removeApplicationFromEnvGroup,
+  provisionDatabase,
+  getDatabases,
 };

+ 10 - 0
dashboard/src/shared/array_utils.ts

@@ -0,0 +1,10 @@
+export function onlyInLeft<T>(
+  left: Array<T>,
+  right: Array<T>,
+  compareFunction: (leftValue: T, rightValue: T) => boolean
+): Array<T> {
+  return left.filter(
+    (leftValue) =>
+      !right.some((rightValue) => compareFunction(leftValue, rightValue))
+  );
+}

+ 5 - 0
dashboard/src/shared/common.tsx

@@ -83,6 +83,11 @@ export const integrationList: any = {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
     label: "Gitlab",
   },
+  rds: {
+    icon:
+      "https://cdn2.iconfinder.com/data/icons/amazon-aws-stencils/100/Database_copy_Amazon_RDS-512.png",
+    label: "Amazon Relational Database Service",
+  },
 };
 
 export const isAlphanumeric = (x: string | null) => {

+ 3 - 1
dashboard/src/shared/routing.tsx

@@ -10,7 +10,8 @@ export type PorterUrl =
   | "applications"
   | "env-groups"
   | "jobs"
-  | "onboarding";
+  | "onboarding"
+  | "databases";
 
 export const PorterUrls = [
   "dashboard",
@@ -23,6 +24,7 @@ export const PorterUrls = [
   "env-groups",
   "jobs",
   "onboarding",
+  "databases",
 ];
 
 // TODO: consolidate with pushFiltered

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

@@ -68,7 +68,12 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
     };
     container: {
       command: string;
-      env: any;
+      env: {
+        normal: {
+          [key: string]: string;
+        };
+        synced: any;
+      };
       lifecycle: { postStart: string; preStop: string };
       port: number;
     };
@@ -218,6 +223,7 @@ export interface ProjectType {
   id: number;
   name: string;
   preview_envs_enabled: boolean;
+  enable_rds_databases: boolean;
   roles: {
     id: number;
     kind: string;

+ 5 - 1
dashboard/webpack.config.js

@@ -28,7 +28,11 @@ module.exports = () => {
    * @type {webpack.Configuration}
    */
   const config = {
-    entry: ["./src/index.tsx"],
+    entry: [
+      "core-js/modules/es.promise",
+      "core-js/modules/es.array.iterator",
+      "./src/index.tsx",
+    ],
     target: "web",
     mode: isDevelopment ? "development" : "production",
     devtool: "source-map",

+ 0 - 2
ee/docker/ee.Dockerfile

@@ -47,8 +47,6 @@ RUN npm install -g npm@8.1
 
 RUN npm i --legacy-peer-deps
 
-ENV NODE_ENV=production
-
 RUN npm run build
 
 # Deployment environment

+ 24 - 6
ee/integrations/httpbackend/types.go

@@ -67,10 +67,28 @@ type DiagnosticDetail struct {
 }
 
 type TFState struct {
-	Version          int         `json:"version"`
-	TerraformVersion string      `json:"terraform_version"`
-	Serial           int         `json:"serial"`
-	Lineage          string      `json:"lineage"`
-	Outputs          interface{} `json:"outputs"`
-	Resources        interface{} `json:"resources"`
+	Version          int               `json:"version"`
+	TerraformVersion string            `json:"terraform_version"`
+	Serial           int               `json:"serial"`
+	Lineage          string            `json:"lineage"`
+	Outputs          interface{}       `json:"outputs"`
+	Resources        []TFStateResource `json:"resources"`
+}
+
+type TFStateResource struct {
+	Instances []Instance `json:"instances"`
+	Mode      string     `json:"mode"`
+	Name      string     `json:"name"`
+	Provider  string     `json:"provider"`
+	Type      string     `json:"type"`
+}
+
+type Instance struct {
+	Attributes   map[string]interface{} `json:"attributes"`
+	Dependencies []string               `json:"dependencies"`
+}
+
+type AWSVPCConfig struct {
+	SubNetIDs []string `json:"subnet_ids" mapstructure:"subnet_ids"`
+	VPCID     string   `json:"vpc_id" mapstructure:"vpc_id"`
 }

+ 22 - 27
internal/helm/agent.go

@@ -191,19 +191,17 @@ func (a *Agent) UpgradeReleaseByValues(
 	cmd := action.NewUpgrade(a.ActionConfig)
 	cmd.Namespace = rel.Namespace
 
-	if conf.Cluster != nil && a.K8sAgent != nil && conf.Registries != nil && len(conf.Registries) > 0 {
-		cmd.PostRenderer, err = NewDockerSecretsPostRenderer(
-			conf.Cluster,
-			conf.Repo,
-			a.K8sAgent,
-			rel.Namespace,
-			conf.Registries,
-			doAuth,
-		)
-
-		if err != nil {
-			return nil, err
-		}
+	cmd.PostRenderer, err = NewPorterPostrenderer(
+		conf.Cluster,
+		conf.Repo,
+		a.K8sAgent,
+		rel.Namespace,
+		conf.Registries,
+		doAuth,
+	)
+
+	if err != nil {
+		return nil, err
 	}
 
 	res, err := cmd.Run(conf.Name, ch, conf.Values)
@@ -264,20 +262,17 @@ func (a *Agent) InstallChart(
 
 	var err error
 
-	// only add the postrenderer if required fields exist
-	if conf.Cluster != nil && a.K8sAgent != nil && conf.Registries != nil && len(conf.Registries) > 0 {
-		cmd.PostRenderer, err = NewDockerSecretsPostRenderer(
-			conf.Cluster,
-			conf.Repo,
-			a.K8sAgent,
-			conf.Namespace,
-			conf.Registries,
-			doAuth,
-		)
-
-		if err != nil {
-			return nil, err
-		}
+	cmd.PostRenderer, err = NewPorterPostrenderer(
+		conf.Cluster,
+		conf.Repo,
+		a.K8sAgent,
+		conf.Namespace,
+		conf.Registries,
+		doAuth,
+	)
+
+	if err != nil {
+		return nil, err
 	}
 
 	if req := conf.Chart.Metadata.Dependencies; req != nil {

+ 304 - 7
internal/helm/postrenderer.go

@@ -18,6 +18,58 @@ import (
 	"github.com/docker/distribution/reference"
 )
 
+type PorterPostrenderer struct {
+	DockerSecretsPostRenderer       *DockerSecretsPostRenderer
+	EnvironmentVariablePostrenderer *EnvironmentVariablePostrenderer
+}
+
+func NewPorterPostrenderer(
+	cluster *models.Cluster,
+	repo repository.Repository,
+	agent *kubernetes.Agent,
+	namespace string,
+	regs []*models.Registry,
+	doAuth *oauth2.Config,
+) (postrender.PostRenderer, error) {
+	var dockerSecretsPostrenderer *DockerSecretsPostRenderer
+	var err error
+
+	if cluster != nil && agent != nil && regs != nil && len(regs) > 0 {
+		dockerSecretsPostrenderer, err = NewDockerSecretsPostRenderer(cluster, repo, agent, namespace, regs, doAuth)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	envVarPostrenderer, err := NewEnvironmentVariablePostrenderer()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &PorterPostrenderer{
+		DockerSecretsPostRenderer:       dockerSecretsPostrenderer,
+		EnvironmentVariablePostrenderer: envVarPostrenderer,
+	}, nil
+}
+
+func (p *PorterPostrenderer) Run(
+	renderedManifests *bytes.Buffer,
+) (modifiedManifests *bytes.Buffer, err error) {
+	if p.DockerSecretsPostRenderer != nil {
+		renderedManifests, err = p.DockerSecretsPostRenderer.Run(renderedManifests)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	renderedManifests, err = p.EnvironmentVariablePostrenderer.Run(renderedManifests)
+
+	return renderedManifests, err
+}
+
 // DockerSecretsPostRenderer is a Helm post-renderer that adds image pull secrets to
 // pod specs that would otherwise be unable to pull an image.
 //
@@ -49,7 +101,7 @@ func NewDockerSecretsPostRenderer(
 	namespace string,
 	regs []*models.Registry,
 	doAuth *oauth2.Config,
-) (postrender.PostRenderer, error) {
+) (*DockerSecretsPostRenderer, error) {
 	// Registries is a map of registry URLs to registry ids
 	registries := make(map[string]*models.Registry)
 
@@ -200,7 +252,8 @@ func (d *DockerSecretsPostRenderer) getRegistriesToLink(renderedManifests *bytes
 	// that a secret will be generated for, if it does not exist
 	linkedRegs := make(map[string]*models.Registry)
 
-	err := d.decodeRenderedManifests(renderedManifests)
+	var err error
+	d.resources, err = decodeRenderedManifests(renderedManifests)
 
 	if err != nil {
 		return linkedRegs, err
@@ -242,9 +295,11 @@ func (d *DockerSecretsPostRenderer) getRegistriesToLink(renderedManifests *bytes
 	return linkedRegs, nil
 }
 
-func (d *DockerSecretsPostRenderer) decodeRenderedManifests(
+func decodeRenderedManifests(
 	renderedManifests *bytes.Buffer,
-) error {
+) ([]resource, error) {
+	resArr := make([]resource, 0)
+
 	// use the yaml decoder to parse the multi-document yaml.
 	decoder := yaml.NewDecoder(renderedManifests)
 
@@ -256,13 +311,13 @@ func (d *DockerSecretsPostRenderer) decodeRenderedManifests(
 		}
 
 		if err != nil {
-			return err
+			return resArr, err
 		}
 
-		d.resources = append(d.resources, res)
+		resArr = append(resArr, res)
 	}
 
-	return nil
+	return resArr, nil
 }
 
 func (d *DockerSecretsPostRenderer) getPodSpecs(resources []resource) {
@@ -457,6 +512,248 @@ func (d *DockerSecretsPostRenderer) isRegistryNative(regName string) bool {
 	return isNative
 }
 
+// EnvironmentVariablePostrenderer removes duplicated environment variables, giving preference to synced
+// env vars
+type EnvironmentVariablePostrenderer struct {
+	podSpecs  []resource
+	resources []resource
+}
+
+func NewEnvironmentVariablePostrenderer() (*EnvironmentVariablePostrenderer, error) {
+	return &EnvironmentVariablePostrenderer{
+		podSpecs:  make([]resource, 0),
+		resources: make([]resource, 0),
+	}, nil
+}
+
+func (e *EnvironmentVariablePostrenderer) Run(
+	renderedManifests *bytes.Buffer,
+) (modifiedManifests *bytes.Buffer, err error) {
+	e.resources, err = decodeRenderedManifests(renderedManifests)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Check to see if the resources loaded into the postrenderer contain a configmap
+	// with a manifest that needs env var cleanup as well. If this is the case, create and
+	// run another postrenderer for this specific manifest.
+	for i, res := range e.resources {
+		kindVal, hasKind := res["kind"]
+
+		if !hasKind {
+			continue
+		}
+
+		kind, ok := kindVal.(string)
+
+		if !ok {
+			continue
+		}
+
+		if kind == "ConfigMap" {
+			labelVal := getNestedResource(res, "metadata", "labels")
+
+			if labelVal == nil {
+				continue
+			}
+
+			porterLabelVal, exists := labelVal["getporter.dev/manifest"]
+
+			if !exists {
+				continue
+			}
+
+			if labelValStr, ok := porterLabelVal.(string); ok && labelValStr == "true" {
+				data := getNestedResource(res, "data")
+				manifestData, exists := data["manifest"]
+
+				if !exists {
+					continue
+				}
+
+				manifestDataStr, ok := manifestData.(string)
+
+				if !ok {
+					continue
+				}
+
+				dCopy := &EnvironmentVariablePostrenderer{
+					podSpecs:  make([]resource, 0),
+					resources: make([]resource, 0),
+				}
+
+				newData, err := dCopy.Run(bytes.NewBufferString(manifestDataStr))
+
+				if err != nil {
+					continue
+				}
+
+				data["manifest"] = string(newData.Bytes())
+
+				e.resources[i] = res
+			}
+		}
+	}
+
+	e.getPodSpecs(e.resources)
+	e.updatePodSpecs()
+
+	modifiedManifests = bytes.NewBuffer([]byte{})
+	encoder := yaml.NewEncoder(modifiedManifests)
+	defer encoder.Close()
+
+	for _, resource := range e.resources {
+		err = encoder.Encode(resource)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return modifiedManifests, nil
+}
+
+func (e *EnvironmentVariablePostrenderer) getPodSpecs(resources []resource) {
+	for _, res := range resources {
+		kindVal, hasKind := res["kind"]
+		if !hasKind {
+			continue
+		}
+
+		kind, ok := kindVal.(string)
+		if !ok {
+			continue
+		}
+
+		// manifests of list type will have an items field, items should
+		// be recursively parsed
+		if itemsVal, isList := res["items"]; isList {
+			if items, ok := itemsVal.([]interface{}); ok {
+				// convert items to resource
+				resArr := make([]resource, 0)
+				for _, item := range items {
+					if arrVal, ok := item.(resource); ok {
+						resArr = append(resArr, arrVal)
+					}
+				}
+
+				e.getPodSpecs(resArr)
+			}
+
+			continue
+		}
+
+		// otherwise, get the pod spec based on the type of resource
+		podSpec := getPodSpecFromResource(kind, res)
+
+		if podSpec == nil {
+			continue
+		}
+
+		e.podSpecs = append(e.podSpecs, podSpec)
+	}
+
+	return
+}
+
+func (e *EnvironmentVariablePostrenderer) updatePodSpecs() error {
+	// for each pod spec, remove duplicate env variables
+	for _, podSpec := range e.podSpecs {
+		containersVal, hasContainers := podSpec["containers"]
+
+		if !hasContainers {
+			continue
+		}
+
+		containers, ok := containersVal.([]interface{})
+
+		if !ok {
+			continue
+		}
+
+		newContainers := make([]interface{}, 0)
+
+		for _, container := range containers {
+			envVars := make(map[string]interface{})
+
+			_container, ok := container.(resource)
+
+			if !ok {
+				continue
+			}
+
+			// read container env variables
+			envInter, ok := _container["env"]
+
+			if !ok {
+				newContainers = append(newContainers, _container)
+				continue
+			}
+
+			env, ok := envInter.([]interface{})
+
+			if !ok {
+				newContainers = append(newContainers, _container)
+				continue
+			}
+
+			for _, envVar := range env {
+				envVarMap, ok := envVar.(resource)
+
+				if !ok {
+					continue
+				}
+
+				envVarName, ok := envVarMap["name"]
+
+				if !ok {
+					continue
+				}
+
+				envVarNameStr, ok := envVarName.(string)
+
+				if !ok {
+					continue
+				}
+
+				// check if the env var already exists, if it does perform reconciliation
+				if currVal, exists := envVars[envVarNameStr]; exists {
+					currValMap, ok := currVal.(resource)
+
+					if !ok {
+						continue
+					}
+
+					// if the current value has a valueFrom field, this should override the existing env var
+					if _, currValFromFieldExists := currValMap["valueFrom"]; currValFromFieldExists {
+						continue
+					} else {
+						envVars[envVarNameStr] = envVarMap
+					}
+				} else {
+					envVars[envVarNameStr] = envVarMap
+				}
+			}
+
+			// flatten env var map to array
+			envVarArr := make([]interface{}, 0)
+
+			for _, envVar := range envVars {
+				envVarArr = append(envVarArr, envVar)
+			}
+
+			_container["env"] = envVarArr
+			newContainers = append(newContainers, _container)
+		}
+
+		podSpec["containers"] = newContainers
+	}
+
+	return nil
+}
+
+// HELPERS
 func getPodSpecFromResource(kind string, res resource) resource {
 	switch kind {
 	case "Pod":

+ 415 - 1
internal/kubernetes/agent.go

@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
+	"strconv"
 	"strings"
 	"time"
 
@@ -107,6 +108,118 @@ func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[str
 	)
 }
 
+func (a *Agent) CreateVersionedConfigMap(name, namespace string, version uint, configMap map[string]string, apps ...string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      fmt.Sprintf("%s.v%d", name, version),
+				Namespace: namespace,
+				Labels: map[string]string{
+					"owner":    "porter",
+					"envgroup": name,
+					"version":  fmt.Sprintf("%d", version),
+				},
+				Annotations: map[string]string{
+					PorterAppAnnotationName: strings.Join(apps, ","),
+				},
+			},
+			Data: configMap,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+const PorterAppAnnotationName = "porter.run/apps"
+
+func (a *Agent) AddApplicationToVersionedConfigMap(cm *v1.ConfigMap, appName string) (*v1.ConfigMap, error) {
+	annons := cm.Annotations
+
+	if annons == nil {
+		annons = make(map[string]string)
+	}
+
+	appAnnon, appAnnonExists := annons[PorterAppAnnotationName]
+
+	if !appAnnonExists || appAnnon == "" {
+		annons[PorterAppAnnotationName] = appName
+	} else {
+		appStrArr := strings.Split(appAnnon, ",")
+		foundApp := false
+
+		for _, appStr := range appStrArr {
+			if appStr == appName {
+				foundApp = true
+			}
+		}
+
+		if !foundApp {
+			annons[PorterAppAnnotationName] = fmt.Sprintf("%s,%s", appAnnon, appName)
+		}
+	}
+
+	cm.SetAnnotations(annons)
+
+	return a.Clientset.CoreV1().ConfigMaps(cm.Namespace).Update(
+		context.TODO(),
+		cm,
+		metav1.UpdateOptions{},
+	)
+}
+
+func (a *Agent) RemoveApplicationFromVersionedConfigMap(cm *v1.ConfigMap, appName string) (*v1.ConfigMap, error) {
+	annons := cm.Annotations
+
+	if annons == nil {
+		annons = make(map[string]string)
+	}
+
+	appAnn, appAnnExists := annons[PorterAppAnnotationName]
+
+	if !appAnnExists {
+		return nil, IsNotFoundError
+	}
+
+	appStrArr := strings.Split(appAnn, ",")
+	newStrArr := make([]string, 0)
+
+	for _, appStr := range appStrArr {
+		if appStr != appName {
+			newStrArr = append(newStrArr, appStr)
+		}
+	}
+
+	annons[PorterAppAnnotationName] = strings.Join(newStrArr, ",")
+
+	cm.SetAnnotations(annons)
+
+	return a.Clientset.CoreV1().ConfigMaps(cm.Namespace).Update(
+		context.TODO(),
+		cm,
+		metav1.UpdateOptions{},
+	)
+}
+
+func (a *Agent) CreateLinkedVersionedSecret(name, namespace, cmName string, version uint, data map[string][]byte) (*v1.Secret, error) {
+	return a.Clientset.CoreV1().Secrets(namespace).Create(
+		context.TODO(),
+		&v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      fmt.Sprintf("%s.v%d", name, version),
+				Namespace: namespace,
+				Labels: map[string]string{
+					"owner":     "porter",
+					"envgroup":  name,
+					"version":   fmt.Sprintf("%d", version),
+					"configmap": cmName,
+				},
+			},
+			Data: data,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
 // CreateLinkedSecret creates a secret given the key-value pairs and namespace. Values are
 // base64 encoded
 func (a *Agent) CreateLinkedSecret(name, namespace, cmName string, data map[string][]byte) (*v1.Secret, error) {
@@ -219,6 +332,92 @@ func (a *Agent) DeleteLinkedSecret(name, namespace string) error {
 	)
 }
 
+func (a *Agent) ListVersionedConfigMaps(name string, namespace string) ([]v1.ConfigMap, error) {
+	listResp, err := a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.Background(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup=%s", name),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return listResp.Items, nil
+}
+
+func (a *Agent) DeleteVersionedConfigMap(name string, namespace string) error {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).DeleteCollection(
+		context.Background(),
+		metav1.DeleteOptions{},
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup=%s", name),
+		},
+	)
+}
+
+func (a *Agent) DeleteVersionedSecret(name string, namespace string) error {
+	return a.Clientset.CoreV1().Secrets(namespace).DeleteCollection(
+		context.Background(),
+		metav1.DeleteOptions{},
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup=%s", name),
+		},
+	)
+}
+
+func (a *Agent) ListAllVersionedConfigMaps(namespace string) ([]v1.ConfigMap, error) {
+	listResp, err := a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.Background(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup"),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// only keep the latest version for each configmap
+	latestMap := make(map[string]v1.ConfigMap)
+
+	for _, configmap := range listResp.Items {
+		egName, egNameExists := configmap.Labels["envgroup"]
+
+		if !egNameExists {
+			continue
+		}
+
+		id := fmt.Sprintf("%s/%s", configmap.Namespace, egName)
+
+		if currLatest, exists := latestMap[id]; exists {
+			// get version
+			currVersionStr, currVersionExists := currLatest.Labels["version"]
+			versionStr, versionExists := configmap.Labels["version"]
+
+			if versionExists && currVersionExists {
+				currVersion, currErr := strconv.Atoi(currVersionStr)
+				version, err := strconv.Atoi(versionStr)
+
+				if currErr == nil && err == nil && currVersion < version {
+					latestMap[id] = configmap
+				}
+			}
+		} else {
+			latestMap[id] = configmap
+		}
+	}
+
+	res := make([]v1.ConfigMap, 0)
+
+	for _, cm := range latestMap {
+		res = append(res, cm)
+	}
+
+	return res, nil
+}
+
 // GetConfigMap retrieves the configmap given its name and namespace
 func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).Get(
@@ -228,6 +427,154 @@ func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, erro
 	)
 }
 
+func (a *Agent) GetVersionedConfigMap(name, namespace string, version uint) (*v1.ConfigMap, error) {
+	listResp, err := a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.Background(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup=%s,version=%d", name, version),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if listResp.Items == nil || len(listResp.Items) == 0 {
+		return nil, IsNotFoundError
+	}
+
+	// if the length of the list is greater than 1, return an error -- this shouldn't happen
+	if len(listResp.Items) > 1 {
+		return nil, fmt.Errorf("multiple configmaps found while searching for %s/%s and version %d", namespace, name, version)
+	}
+
+	return &listResp.Items[0], nil
+}
+
+func (a *Agent) GetLatestVersionedConfigMap(name, namespace string) (*v1.ConfigMap, uint, error) {
+	listResp, err := a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.Background(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup=%s", name),
+		},
+	)
+
+	if err != nil {
+		return nil, 0, err
+	}
+
+	if listResp.Items == nil || len(listResp.Items) == 0 {
+		return nil, 0, IsNotFoundError
+	}
+
+	// iterate through the configmaps and get the greatest version
+	var res *v1.ConfigMap
+	var latestVersion uint
+
+	for _, configmap := range listResp.Items {
+		if res == nil {
+			versionStr, versionExists := configmap.Labels["version"]
+
+			if !versionExists {
+				continue
+			}
+
+			version, err := strconv.Atoi(versionStr)
+
+			if err != nil {
+				continue
+			}
+
+			latestV := configmap
+			res = &latestV
+			latestVersion = uint(version)
+		} else {
+			// get version
+			versionStr, versionExists := configmap.Labels["version"]
+			currVersionStr, currVersionExists := res.Labels["version"]
+
+			if versionExists && currVersionExists {
+				currVersion, currErr := strconv.Atoi(currVersionStr)
+				version, err := strconv.Atoi(versionStr)
+				if currErr == nil && err == nil && currVersion < version {
+					latestV := configmap
+					res = &latestV
+					latestVersion = uint(version)
+				}
+			}
+		}
+
+	}
+
+	if res == nil {
+		return nil, 0, IsNotFoundError
+	}
+
+	return res, latestVersion, nil
+}
+
+func (a *Agent) GetLatestVersionedSecret(name, namespace string) (*v1.Secret, uint, error) {
+	listResp, err := a.Clientset.CoreV1().Secrets(namespace).List(
+		context.Background(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("envgroup=%s", name),
+		},
+	)
+
+	if err != nil {
+		return nil, 0, err
+	}
+
+	if listResp.Items == nil || len(listResp.Items) == 0 {
+		return nil, 0, IsNotFoundError
+	}
+
+	// iterate through the configmaps and get the greatest version
+	var res *v1.Secret
+	var latestVersion uint
+
+	for _, secret := range listResp.Items {
+		if res == nil {
+			versionStr, versionExists := secret.Labels["version"]
+
+			if !versionExists {
+				continue
+			}
+
+			version, err := strconv.Atoi(versionStr)
+
+			if err != nil {
+				continue
+			}
+
+			latestV := secret
+			res = &latestV
+			latestVersion = uint(version)
+		} else {
+			// get version
+			versionStr, versionExists := secret.Labels["version"]
+			currVersionStr, currVersionExists := res.Labels["version"]
+
+			if versionExists && currVersionExists {
+				currVersion, currErr := strconv.Atoi(currVersionStr)
+				version, err := strconv.Atoi(versionStr)
+				if currErr == nil && err == nil && currVersion < version {
+					latestV := secret
+					res = &latestV
+					latestVersion = uint(version)
+				}
+			}
+		}
+
+	}
+
+	if res == nil {
+		return nil, 0, IsNotFoundError
+	}
+
+	return res, latestVersion, nil
+}
+
 // GetSecret retrieves the secret given its name and namespace
 func (a *Agent) GetSecret(name string, namespace string) (*v1.Secret, error) {
 	return a.Clientset.CoreV1().Secrets(namespace).Get(
@@ -242,7 +589,7 @@ func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).List(
 		context.TODO(),
 		metav1.ListOptions{
-			LabelSelector: "porter=true",
+			LabelSelector: "porter",
 		},
 	)
 }
@@ -1097,7 +1444,13 @@ func (a *Agent) Provision(
 ) error {
 	// get the provisioner job template
 	job, err := provisioner.GetProvisionerJobTemplate(opts)
+	if err != nil {
+		return err
+	}
 
+	// clearExistingJob with the same name
+	// this is required in case of a job retry
+	err = a.clearExistingJobs(job)
 	if err != nil {
 		return err
 	}
@@ -1112,6 +1465,67 @@ func (a *Agent) Provision(
 	return err
 }
 
+func (a *Agent) clearExistingJobs(j *batchv1.Job) error {
+	// find if existingJob already exists
+	existingJob, err := a.Clientset.BatchV1().Jobs(j.Namespace).Get(
+		context.TODO(),
+		j.Name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil {
+		// job not found, no further action needed
+		return nil
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	w, err := a.Clientset.BatchV1().Jobs(existingJob.Namespace).Watch(
+		context.TODO(),
+		metav1.ListOptions{
+			ResourceVersion: existingJob.ResourceVersion,
+			// ResourceVersionMatch: metav1.ResourceVersionMatchNotOlderThan,
+		},
+	)
+
+	if err != nil {
+		// most probably the job has already been deleted
+		return nil
+	}
+
+	deleteErrorChan := make(chan error)
+
+	go func(errChan chan<- error) {
+		// job exists, delete it and wait for its deletion
+		// delete job if it already exists
+		err = a.Clientset.BatchV1().Jobs(existingJob.Namespace).Delete(
+			context.TODO(),
+			j.Name,
+			metav1.DeleteOptions{},
+		)
+
+		if err != nil {
+			// unable to delete job
+			errChan <- err
+		}
+	}(deleteErrorChan)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("timedout waiting for existing job deletion")
+		case event := <-w.ResultChan():
+			switch event.Type {
+			case watch.Deleted:
+				// job has been successfully delete
+				// return without error
+				return nil
+			}
+		}
+	}
+}
+
 // CreateImagePullSecrets will create the required image pull secrets and
 // return a map from the registry name to the name of the secret.
 func (a *Agent) CreateImagePullSecrets(

+ 232 - 0
internal/kubernetes/envgroup/create.go

@@ -0,0 +1,232 @@
+package envgroup
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"helm.sh/helm/v3/pkg/release"
+	v1 "k8s.io/api/core/v1"
+)
+
+func ConvertV1ToV2EnvGroup(agent *kubernetes.Agent, name, namespace string) (*v1.ConfigMap, error) {
+	cm, err := agent.GetConfigMap(name, namespace)
+
+	if err != nil {
+		return nil, err
+	}
+
+	secret, err := agent.GetSecret(name, namespace)
+
+	if err != nil {
+		return nil, err
+	}
+
+	variables := make(map[string]string)
+	secretVariables := make(map[string]string)
+
+	for key, val := range cm.Data {
+		if strings.Contains(val, "PORTERSECRET") {
+			secretVariables[key] = val
+		} else {
+			variables[key] = val
+		}
+	}
+
+	for key, val := range secret.Data {
+		secretVariables[key] = string(val)
+	}
+
+	envGroup, err := CreateEnvGroup(agent, types.ConfigMapInput{
+		Name:            name,
+		Namespace:       namespace,
+		Variables:       variables,
+		SecretVariables: secretVariables,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// delete the old configmap and secret
+	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
+		return nil, err
+	}
+
+	if err := agent.DeleteConfigMap(name, namespace); err != nil {
+		return nil, err
+	}
+
+	return envGroup, nil
+}
+
+func CreateEnvGroup(agent *kubernetes.Agent, input types.ConfigMapInput) (*v1.ConfigMap, error) {
+	// look for a latest configmap
+	oldCM, latestVersion, err := agent.GetLatestVersionedConfigMap(input.Name, input.Namespace)
+
+	if err != nil && !errors.Is(err, kubernetes.IsNotFoundError) {
+		return nil, err
+	} else if err != nil {
+		latestVersion = 1
+	} else {
+		latestVersion += 1
+	}
+
+	apps := make([]string, 0)
+
+	if oldCM != nil {
+		oldEG, err := ToEnvGroup(oldCM)
+
+		if err == nil {
+			apps = oldEG.Applications
+		}
+	}
+
+	oldSecret, _, err := agent.GetLatestVersionedSecret(input.Name, input.Namespace)
+
+	if err != nil && !errors.Is(err, kubernetes.IsNotFoundError) {
+		return nil, err
+	} else if err == nil && oldSecret != nil {
+		// In this case, we find all old variables referencing a secret value, and add those
+		// values to the new secret variables. The frontend will only send **new** secret values.
+		for key1, val1 := range input.Variables {
+			if strings.Contains(val1, "PORTERSECRET") {
+				// get that value from the secret
+				for key2, val2 := range oldSecret.Data {
+					if key2 == key1 {
+						input.SecretVariables[key1] = string(val2)
+					}
+				}
+			}
+		}
+	}
+
+	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
+	for key := range input.SecretVariables {
+		input.Variables[key] = fmt.Sprintf("PORTERSECRET_%s.v%d", input.Name, latestVersion)
+	}
+
+	cm, err := agent.CreateVersionedConfigMap(input.Name, input.Namespace, latestVersion, input.Variables, apps...)
+
+	if err != nil {
+		return nil, err
+	}
+
+	secretData := EncodeSecrets(input.SecretVariables)
+
+	// create secret first
+	if _, err := agent.CreateLinkedVersionedSecret(input.Name, input.Namespace, cm.ObjectMeta.Name, latestVersion, secretData); err != nil {
+		return nil, err
+	}
+
+	return cm, err
+}
+
+func ToEnvGroup(configMap *v1.ConfigMap) (*types.EnvGroup, error) {
+	res := &types.EnvGroup{
+		CreatedAt: configMap.ObjectMeta.CreationTimestamp.Time,
+		Namespace: configMap.Namespace,
+		Variables: configMap.Data,
+	}
+
+	// if the label "porter"="true" exists, this is a V1 env group
+	porterLabel, porterLabelExists := configMap.Labels["porter"]
+
+	if porterLabelExists && porterLabel == "true" {
+		res.MetaVersion = 1
+		res.Name = configMap.ObjectMeta.Name
+		return res, nil
+	}
+
+	// set the meta version to 2 if porter label is not captured
+	res.MetaVersion = 2
+
+	// get the name
+	name, nameExists := configMap.Labels["envgroup"]
+
+	if !nameExists {
+		return nil, fmt.Errorf("not a valid configmap: envgroup label does not exist")
+	}
+
+	res.Name = name
+
+	// get the version
+	versionLabelStr, versionLabelExists := configMap.Labels["version"]
+
+	if !versionLabelExists {
+		return nil, fmt.Errorf("not a valid configmap: version label does not exist")
+	}
+
+	versionInt, err := strconv.Atoi(versionLabelStr)
+
+	if err != nil {
+		return nil, fmt.Errorf("not a valid configmap, error converting version: %v", err)
+	}
+
+	res.Version = uint(versionInt)
+
+	// get applications, if they exist
+	appStr, appAnnonExists := configMap.Annotations[kubernetes.PorterAppAnnotationName]
+
+	if appAnnonExists && appStr != "" {
+		res.Applications = strings.Split(appStr, ",")
+	} else {
+		res.Applications = []string{}
+	}
+
+	return res, nil
+}
+
+func GetSyncedReleases(helmAgent *helm.Agent, configMap *v1.ConfigMap) ([]*release.Release, error) {
+	res := make([]*release.Release, 0)
+
+	// get applications, if they exist
+	appStr, appAnnonExists := configMap.Annotations[kubernetes.PorterAppAnnotationName]
+
+	if !appAnnonExists || appStr == "" {
+		return res, nil
+	}
+
+	appStrArr := strings.Split(appStr, ",")
+
+	// list all latest helm releases and check them against app string
+	releases, err := helmAgent.ListReleases(configMap.Namespace, &types.ReleaseListFilter{
+		StatusFilter: []string{
+			"deployed",
+			"uninstalled",
+			"pending",
+			"pending-install",
+			"pending-upgrade",
+			"pending-rollback",
+			"failed",
+		},
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, rel := range releases {
+		for _, appName := range appStrArr {
+			if rel.Name == appName {
+				res = append(res, rel)
+			}
+		}
+	}
+
+	return res, nil
+}
+
+func EncodeSecrets(data map[string]string) map[string][]byte {
+	res := make(map[string][]byte)
+
+	for key, rawValue := range data {
+		res[key] = []byte(rawValue)
+	}
+
+	return res
+}

+ 15 - 0
internal/kubernetes/envgroup/delete.go

@@ -0,0 +1,15 @@
+package envgroup
+
+import "github.com/porter-dev/porter/internal/kubernetes"
+
+func DeleteEnvGroup(agent *kubernetes.Agent, name, namespace string) error {
+	if err := agent.DeleteVersionedSecret(name, namespace); err != nil {
+		return err
+	}
+
+	if err := agent.DeleteVersionedConfigMap(name, namespace); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 33 - 0
internal/kubernetes/envgroup/get.go

@@ -0,0 +1,33 @@
+package envgroup
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	v1 "k8s.io/api/core/v1"
+)
+
+func GetEnvGroup(agent *kubernetes.Agent, name, namespace string, version uint) (*types.EnvGroup, error) {
+	var configMap *v1.ConfigMap
+	var err error
+
+	if version == 0 {
+		configMap, _, err = agent.GetLatestVersionedConfigMap(name, namespace)
+	} else {
+		configMap, err = agent.GetVersionedConfigMap(name, namespace, version)
+	}
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		// if the configmap isn't found, search for a v1 configmap
+		configMap, err = agent.GetConfigMap(name, namespace)
+
+		if err != nil {
+			return nil, err
+		}
+	} else if err != nil {
+		return nil, err
+	}
+
+	return ToEnvGroup(configMap)
+}

+ 112 - 0
internal/kubernetes/provisioner/aws/rds/rds.go

@@ -0,0 +1,112 @@
+package rds
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is the RDS config required for the provisioner
+type Conf struct {
+	AWSRegion             string
+	DBName                string
+	MachineType           string
+	DBEngineVersion       string
+	DBFamily              string
+	DBMajorEngineVersion  string
+	DBAllocatedStorage    string
+	DBMaxAllocatedStorage string
+	DBStorageEncrypted    string
+	Subnet1               string
+	Subnet2               string
+	Subnet3               string
+
+	Username           string
+	Password           string
+	VPCID              string
+	DeletionProtection string
+}
+
+// AttachRDSEnv adds the relevant RDS env for the provisioner
+func (conf *Conf) AttachRDSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_REGION",
+		Value: conf.AWSRegion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_NAME",
+		Value: conf.DBName,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_MACHINE_TYPE",
+		Value: conf.MachineType,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_ENGINE_VERSION",
+		Value: conf.DBEngineVersion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_FAMILY",
+		Value: conf.DBFamily,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_MAJOR_ENGINE_VERSION",
+		Value: conf.DBMajorEngineVersion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_ALLOCATED_STORAGE",
+		Value: conf.DBAllocatedStorage,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_MAX_ALLOCATED_STORAGE",
+		Value: conf.DBMaxAllocatedStorage,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PORTER_CLUSTER_SUBNET_1",
+		Value: conf.Subnet1,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PORTER_CLUSTER_SUBNET_2",
+		Value: conf.Subnet2,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PORTER_CLUSTER_SUBNET_3",
+		Value: conf.Subnet3,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_STORAGE_ENCRYPTED",
+		Value: conf.DBStorageEncrypted,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_USER",
+		Value: conf.Username,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_PASSWD",
+		Value: conf.Password,
+	})
+
+	// TODO: change to VPC_ID instead of vpc name
+	env = append(env, v1.EnvVar{
+		Name:  "PORTER_CLUSTER_VPC",
+		Value: conf.VPCID,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DB_DELETION_PROTECTION",
+		Value: conf.DeletionProtection,
+	})
+
+	return env
+}

+ 31 - 2
internal/kubernetes/provisioner/provisioner.go

@@ -6,6 +6,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/rds"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gcr"
@@ -40,6 +41,7 @@ type ProvisionOpts struct {
 	TFHTTPBackendURL    string
 	CredentialExchange  *ProvisionCredentialExchange
 	OperationKind       ProvisionerOperation
+	ProvisionerTest     bool
 
 	// resource-specific opts
 	ECR  *ecr.Conf
@@ -48,6 +50,9 @@ type ProvisionOpts struct {
 	GKE  *gke.Conf
 	DOCR *docr.Conf
 	DOKS *doks.Conf
+
+	// DB instance specific opts
+	RDS *rds.Conf
 }
 
 func GetProvisionerJobTemplate(opts *ProvisionOpts) (*batchv1.Job, error) {
@@ -83,9 +88,11 @@ func GetProvisionerJobTemplate(opts *ProvisionOpts) (*batchv1.Job, error) {
 		env = opts.DOCR.AttachDOCREnv(env)
 	case types.InfraDOKS:
 		env = opts.DOKS.AttachDOKSEnv(env)
+	case types.InfraRDS:
+		env = opts.RDS.AttachRDSEnv(env)
 	}
 
-	return &batchv1.Job{
+	job := &batchv1.Job{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      fmt.Sprintf("%s-%s", string(opts.OperationKind), opts.Infra.GetUniqueName()),
 			Namespace: opts.ProvJobNamespace,
@@ -116,7 +123,29 @@ func GetProvisionerJobTemplate(opts *ProvisionOpts) (*batchv1.Job, error) {
 				},
 			},
 		},
-	}, nil
+	}
+
+	if opts.ProvisionerTest {
+		job.Spec.Template.Spec.Containers[0].VolumeMounts = []v1.VolumeMount{
+			{
+				Name:      "cloud-keys",
+				MountPath: "/root",
+			},
+		}
+
+		job.Spec.Template.Spec.Volumes = []v1.Volume{
+			{
+				Name: "cloud-keys",
+				VolumeSource: v1.VolumeSource{
+					Secret: &v1.SecretVolumeSource{
+						SecretName: "cloud-creds",
+					},
+				},
+			},
+		}
+	}
+
+	return job, nil
 }
 
 func GetTFEnv(opts *ProvisionOpts) []v1.EnvVar {

+ 36 - 0
internal/models/database.go

@@ -0,0 +1,36 @@
+package models
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type Database struct {
+	gorm.Model
+
+	ProjectID uint `json:"project_id"`
+	Project   Project
+
+	ClusterID uint `json:"cluster_id"`
+
+	InfraID uint `json:"infra_id"`
+	Infra   Infra
+
+	InstanceID       string `json:"rds_instance_id"`
+	InstanceEndpoint string `json:"rds_connection_endpoint"`
+	InstanceName     string `json:"rds_instance_name"`
+	Status           string
+}
+
+func (d *Database) ToDatabaseType() *types.Database {
+	return &types.Database{
+		ID:               d.ID,
+		ProjectID:        d.ProjectID,
+		ClusterID:        d.ClusterID,
+		InfraID:          d.InfraID,
+		InstanceID:       d.InstanceID,
+		InstanceEndpoint: d.InstanceEndpoint,
+		InstanceName:     d.InstanceName,
+		Status:           d.Status,
+	}
+}

+ 15 - 0
internal/models/infra.go

@@ -41,6 +41,9 @@ type Infra struct {
 	// this points to an OAuthIntegrationID
 	DOIntegrationID uint
 
+	// The database id for the infra, if this infra provisioned a database
+	DatabaseID uint
+
 	// ------------------------------------------------------------------
 	// All fields below this line are encrypted before storage
 	// ------------------------------------------------------------------
@@ -124,6 +127,18 @@ func (i *Infra) SafelyGetLastApplied() map[string]string {
 		resp["cluster_name"] = lastApplied.DOKSName
 		resp["do_region"] = lastApplied.DORegion
 
+		return resp
+	case types.InfraRDS:
+		lastApplied := &types.RDSInfraLastApplied{}
+
+		if err := json.Unmarshal(i.LastApplied, lastApplied); err != nil {
+			return resp
+		}
+
+		resp["cluster_id"] = fmt.Sprintf("%d", lastApplied.ClusterID)
+		resp["aws_region"] = lastApplied.AWSRegion
+		resp["db_name"] = lastApplied.DBName
+
 		return resp
 	}
 

+ 10 - 5
internal/models/project.go

@@ -36,6 +36,9 @@ type Project struct {
 	Clusters          []Cluster          `json:"clusters"`
 	ClusterCandidates []ClusterCandidate `json:"cluster_candidates"`
 
+	// linked databases
+	Databases []Database `json:"databases"`
+
 	// linked helm repos
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
@@ -53,7 +56,8 @@ type Project struct {
 	AWSIntegrations   []ints.AWSIntegration   `json:"aws_integrations"`
 	GCPIntegrations   []ints.GCPIntegration   `json:"gcp_integrations"`
 
-	PreviewEnvsEnabled bool
+	PreviewEnvsEnabled  bool
+	RDSDatabasesEnabled bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -65,9 +69,10 @@ func (p *Project) ToProjectType() *types.Project {
 	}
 
 	return &types.Project{
-		ID:                 p.ID,
-		Name:               p.Name,
-		Roles:              roles,
-		PreviewEnvsEnabled: p.PreviewEnvsEnabled,
+		ID:                  p.ID,
+		Name:                p.Name,
+		Roles:               roles,
+		PreviewEnvsEnabled:  p.PreviewEnvsEnabled,
+		RDSDatabasesEnabled: p.RDSDatabasesEnabled,
 	}
 }

+ 154 - 1
internal/kubernetes/provisioner/global_stream.go → internal/redis_stream/global_stream.go

@@ -1,4 +1,4 @@
-package provisioner
+package redis_stream
 
 import (
 	"context"
@@ -10,9 +10,13 @@ import (
 
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/analytics"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"gorm.io/gorm"
 
 	redis "github.com/go-redis/redis/v8"
 
+	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
@@ -84,6 +88,7 @@ type ResourceCRUDHandler interface {
 // updates models in the database as necessary
 func GlobalStreamListener(
 	client *redis.Client,
+	config *config.Config,
 	repo repository.Repository,
 	analyticsClient analytics.AnalyticsSegmentClient,
 	errorChan chan error,
@@ -174,6 +179,52 @@ func GlobalStreamListener(
 							InfraID:                 infra.ID,
 						},
 					))
+				} else if kind == string(types.InfraRDS) {
+					// parse the last applied field to get the cluster id
+					rdsRequest := &types.RDSInfraLastApplied{}
+					err := json.Unmarshal(infra.LastApplied, rdsRequest)
+
+					if err != nil {
+						continue
+					}
+
+					database := &models.Database{
+						Status: "running",
+					}
+
+					// parse raw data into ECR type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						err = json.Unmarshal([]byte(dataString), database)
+
+						if err != nil {
+						}
+					}
+
+					database.Model = gorm.Model{}
+					database.ProjectID = projID
+					database.ClusterID = rdsRequest.ClusterID
+					database.InfraID = infra.ID
+
+					database, err = repo.Database().CreateDatabase(database)
+
+					if err != nil {
+						continue
+					}
+
+					infra.DatabaseID = database.ID
+					infra, err = repo.Infra().UpdateInfra(infra)
+
+					if err != nil {
+						continue
+					}
+
+					err = createRDSEnvGroup(repo, config, infra, database, rdsRequest)
+
+					if err != nil {
+						continue
+					}
 				} else if kind == string(types.InfraEKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.AWS,
@@ -411,6 +462,32 @@ func GlobalStreamListener(
 							InfraID:                infra.ID,
 						},
 					))
+				} else if infra.Kind == types.InfraRDS && infra.DatabaseID != 0 {
+					rdsRequest := &types.RDSInfraLastApplied{}
+					err := json.Unmarshal(infra.LastApplied, rdsRequest)
+
+					if err != nil {
+						continue
+					}
+
+					database, err := repo.Database().ReadDatabase(infra.ProjectID, rdsRequest.ClusterID, infra.DatabaseID)
+
+					if err != nil {
+						continue
+					}
+
+					err = deleteRDSEnvGroup(repo, config, infra, database, rdsRequest)
+
+					if err != nil {
+						continue
+					}
+
+					// delete the database
+					err = repo.Database().DeleteDatabase(infra.ProjectID, rdsRequest.ClusterID, infra.DatabaseID)
+
+					if err != nil {
+						continue
+					}
 				}
 			}
 
@@ -429,3 +506,79 @@ func GlobalStreamListener(
 		}
 	}
 }
+
+func createRDSEnvGroup(repo repository.Repository, config *config.Config, infra *models.Infra, database *models.Database, rdsConfig *types.RDSInfraLastApplied) error {
+
+	cluster, err := repo.Cluster().ReadCluster(infra.ProjectID, rdsConfig.ClusterID)
+
+	if err != nil {
+		return err
+	}
+
+	ooc := &kubernetes.OutOfClusterConfig{
+		Repo:              config.Repo,
+		DigitalOceanOAuth: config.DOConf,
+		Cluster:           cluster,
+	}
+
+	agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+	if err != nil {
+		return fmt.Errorf("failed to get agent: %s", err.Error())
+	}
+
+	// split the instance endpoint on the port
+	port := "5432"
+	host := database.InstanceEndpoint
+
+	if strArr := strings.Split(database.InstanceEndpoint, ":"); len(strArr) == 2 {
+		host = strArr[0]
+		port = strArr[1]
+	}
+
+	_, err = envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
+		Name:      fmt.Sprintf("rds-credentials-%s", rdsConfig.DBName),
+		Namespace: rdsConfig.Namespace,
+		Variables: map[string]string{},
+		SecretVariables: map[string]string{
+			"PGPORT":     port,
+			"PGHOST":     host,
+			"PGPASSWORD": rdsConfig.Password,
+			"PGUSER":     rdsConfig.Username,
+		},
+	})
+
+	if err != nil {
+		return fmt.Errorf("failed to create RDS env group: %s", err.Error())
+	}
+
+	return nil
+}
+
+func deleteRDSEnvGroup(repo repository.Repository, config *config.Config, infra *models.Infra, database *models.Database, rdsConfig *types.RDSInfraLastApplied) error {
+	cluster, err := repo.Cluster().ReadCluster(infra.ProjectID, rdsConfig.ClusterID)
+
+	if err != nil {
+		return err
+	}
+
+	ooc := &kubernetes.OutOfClusterConfig{
+		Repo:              config.Repo,
+		DigitalOceanOAuth: config.DOConf,
+		Cluster:           cluster,
+	}
+
+	agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+	if err != nil {
+		return fmt.Errorf("failed to get agent: %s", err.Error())
+	}
+
+	err = envgroup.DeleteEnvGroup(agent, fmt.Sprintf("rds-credentials-%s", rdsConfig.DBName), rdsConfig.Namespace)
+
+	if err != nil {
+		return fmt.Errorf("failed to create RDS env group: %s", err.Error())
+	}
+
+	return nil
+}

+ 1 - 1
internal/kubernetes/provisioner/resource_stream.go → internal/redis_stream/resource_stream.go

@@ -1,4 +1,4 @@
-package provisioner
+package redis_stream
 
 import (
 	"context"

+ 14 - 0
internal/repository/database.go

@@ -0,0 +1,14 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DatabaseRepository interface {
+	CreateDatabase(database *models.Database) (*models.Database, error)
+	ReadDatabase(projectID, clusterID, databaseID uint) (*models.Database, error)
+	ReadDatabaseByInfraID(projectID, infraID uint) (*models.Database, error)
+	ListDatabases(projectID, clusterID uint) ([]*models.Database, error)
+	UpdateDatabase(database *models.Database) (*models.Database, error)
+	DeleteDatabase(projectID, clusterID, databaseID uint) error
+}

+ 79 - 0
internal/repository/gorm/database.go

@@ -0,0 +1,79 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type DatabaseRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+func NewDatabaseRepository(db *gorm.DB, key *[32]byte) repository.DatabaseRepository {
+	return &DatabaseRepository{db, key}
+}
+
+func (repo *DatabaseRepository) CreateDatabase(database *models.Database) (*models.Database, error) {
+	project := &models.Project{}
+	if err := repo.db.Debug().First(project, database.ProjectID).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Debug().Model(project).Association("Databases")
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(database); err != nil {
+		return nil, err
+	}
+
+	return database, nil
+}
+
+func (repo *DatabaseRepository) ReadDatabase(projectID, clusterID, databaseID uint) (*models.Database, error) {
+	database := &models.Database{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND id = ?", projectID, clusterID, databaseID).First(&database).Error; err != nil {
+		return nil, err
+	}
+
+	return database, nil
+}
+
+func (repo *DatabaseRepository) ReadDatabaseByInfraID(projectID, infraID uint) (*models.Database, error) {
+	database := &models.Database{}
+
+	if err := repo.db.Where("project_id = ? AND infra_id = ?", projectID, infraID).First(&database).Error; err != nil {
+		return nil, err
+	}
+
+	return database, nil
+}
+
+func (repo *DatabaseRepository) UpdateDatabase(database *models.Database) (*models.Database, error) {
+	if err := repo.db.Save(database).Error; err != nil {
+		return nil, err
+	}
+
+	return database, nil
+}
+
+func (repo *DatabaseRepository) DeleteDatabase(projectID, clusterID, databaseID uint) error {
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND id = ?", projectID, clusterID, databaseID).Delete(&models.Database{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (repo *DatabaseRepository) ListDatabases(projectID, clusterID uint) ([]*models.Database, error) {
+	databases := []*models.Database{}
+	if err := repo.db.Where("project_id = ? AND cluster_id = ?", projectID, clusterID).Find(&databases).Error; err != nil {
+		return nil, err
+	}
+
+	return databases, nil
+}

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

@@ -22,6 +22,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.Database{},
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},

+ 6 - 0
internal/repository/gorm/repository.go

@@ -11,6 +11,7 @@ type GormRepository struct {
 	session                   repository.SessionRepository
 	project                   repository.ProjectRepository
 	cluster                   repository.ClusterRepository
+	database                  repository.DatabaseRepository
 	helmRepo                  repository.HelmRepoRepository
 	registry                  repository.RegistryRepository
 	gitRepo                   repository.GitRepoRepository
@@ -58,6 +59,10 @@ func (t *GormRepository) Cluster() repository.ClusterRepository {
 	return t.cluster
 }
 
+func (t *GormRepository) Database() repository.DatabaseRepository {
+	return t.database
+}
+
 func (t *GormRepository) HelmRepo() repository.HelmRepoRepository {
 	return t.helmRepo
 }
@@ -182,6 +187,7 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		session:                   NewSessionRepository(db),
 		project:                   NewProjectRepository(db),
 		cluster:                   NewClusterRepository(db, key),
+		database:                  NewDatabaseRepository(db, key),
 		helmRepo:                  NewHelmRepoRepository(db, key),
 		registry:                  NewRegistryRepository(db, key),
 		gitRepo:                   NewGitRepoRepository(db, key),

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio