Procházet zdrojové kódy

create new provisioner service to provision infrastructure

Alexander Belanger před 4 roky
rodič
revize
090b24668a
100 změnil soubory, kde provedl 4493 přidání a 1887 odebrání
  1. 48 0
      .air.provisioner.toml
  2. 3 0
      Makefile
  3. 21 0
      api/client/k8s.go
  4. 1 1
      api/server/authn/handler.go
  5. 2 2
      api/server/authz/cluster.go
  6. 3 3
      api/server/authz/git_installation.go
  7. 2 2
      api/server/authz/helm_repo.go
  8. 2 2
      api/server/authz/infra.go
  9. 2 2
      api/server/authz/invite.go
  10. 60 0
      api/server/authz/operation.go
  11. 6 3
      api/server/authz/policy.go
  12. 2 2
      api/server/authz/project.go
  13. 2 2
      api/server/authz/registry.go
  14. 3 3
      api/server/authz/release.go
  15. 47 0
      api/server/handlers/database/list.go
  16. 3 3
      api/server/handlers/handler.go
  17. 164 0
      api/server/handlers/infra/create.go
  18. 23 233
      api/server/handlers/infra/delete.go
  19. 342 0
      api/server/handlers/infra/forms.go
  20. 18 1
      api/server/handlers/infra/get.go
  21. 52 0
      api/server/handlers/infra/get_operation.go
  22. 46 0
      api/server/handlers/infra/get_operation_logs.go
  23. 45 0
      api/server/handlers/infra/get_state.go
  24. 81 0
      api/server/handlers/infra/get_template.go
  25. 10 3
      api/server/handlers/infra/list.go
  26. 44 0
      api/server/handlers/infra/list_operations.go
  27. 92 0
      api/server/handlers/infra/list_templates.go
  28. 80 0
      api/server/handlers/infra/retry_create.go
  29. 58 0
      api/server/handlers/infra/retry_delete.go
  30. 101 9
      api/server/handlers/infra/stream_logs.go
  31. 133 0
      api/server/handlers/infra/stream_state.go
  32. 80 0
      api/server/handlers/infra/update.go
  33. 23 33
      api/server/handlers/namespace/add_env_group_app.go
  34. 79 0
      api/server/handlers/namespace/clone_env_group.go
  35. 0 102
      api/server/handlers/namespace/create_configmap.go
  36. 369 0
      api/server/handlers/namespace/create_env_group.go
  37. 0 66
      api/server/handlers/namespace/delete_configmap.go
  38. 89 0
      api/server/handlers/namespace/delete_env_group.go
  39. 19 13
      api/server/handlers/namespace/get_env_group.go
  40. 82 0
      api/server/handlers/namespace/get_env_group_all_versions.go
  41. 0 48
      api/server/handlers/namespace/list_configmaps.go
  42. 93 0
      api/server/handlers/namespace/list_env_groups.go
  43. 89 0
      api/server/handlers/namespace/remove_env_group_app.go
  44. 32 3
      api/server/handlers/namespace/update_configmap.go
  45. 9 9
      api/server/handlers/project/create_test.go
  46. 1 1
      api/server/handlers/project/get_test.go
  47. 2 2
      api/server/handlers/project/list_test.go
  48. 0 69
      api/server/handlers/provision/helpers.go
  49. 0 137
      api/server/handlers/provision/provision_docr.go
  50. 0 138
      api/server/handlers/provision/provision_doks.go
  51. 0 136
      api/server/handlers/provision/provision_ecr.go
  52. 0 138
      api/server/handlers/provision/provision_eks.go
  53. 0 135
      api/server/handlers/provision/provision_gcr.go
  54. 0 139
      api/server/handlers/provision/provision_gke.go
  55. 2 2
      api/server/handlers/release/create.go
  56. 1 1
      api/server/handlers/release/get.go
  57. 1 1
      api/server/handlers/template/get.go
  58. 2 2
      api/server/handlers/user/cli_login.go
  59. 12 12
      api/server/handlers/user/create_test.go
  60. 1 1
      api/server/handlers/user/current_test.go
  61. 2 2
      api/server/handlers/user/delete_test.go
  62. 1 1
      api/server/handlers/user/email_verify_test.go
  63. 12 12
      api/server/handlers/user/login_test.go
  64. 29 0
      api/server/router/cluster.go
  65. 270 6
      api/server/router/infra.go
  66. 1 1
      api/server/router/middleware/panic.go
  67. 4 2
      api/server/router/middleware/usage.go
  68. 2 2
      api/server/router/middleware/websocket.go
  69. 157 36
      api/server/router/namespace.go
  70. 198 114
      api/server/router/project.go
  71. 6 0
      api/server/router/router.go
  72. 7 6
      api/server/shared/apierrors/errors.go
  73. 1 1
      api/server/shared/apitest/request.go
  74. 2 1
      api/server/shared/config/env/envconfs.go
  75. 2 2
      api/server/shared/endpoints.go
  76. 9 6
      api/server/shared/reader.go
  77. 4 0
      api/server/shared/websocket/response_writer.go
  78. 10 5
      api/server/shared/writer.go
  79. 22 0
      api/types/database.go
  80. 1 0
      api/types/form.go
  81. 73 0
      api/types/infra.go
  82. 53 0
      api/types/namespace.go
  83. 5 2
      api/types/policy.go
  84. 5 4
      api/types/project.go
  85. 156 2
      api/types/provision.go
  86. 33 0
      api/types/provision_test.go
  87. 1 0
      api/types/request.go
  88. 2 2
      cli/cmd/deploy/create.go
  89. 135 7
      cli/cmd/deploy/deploy.go
  90. 0 17
      cmd/app/main.go
  91. 113 0
      cmd/provisioner/main.go
  92. 1 1
      dashboard/babel.config.json
  93. 26 0
      dashboard/package-lock.json
  94. 3 0
      dashboard/package.json
  95. 13 0
      dashboard/src/components/Description.tsx
  96. 1 10
      dashboard/src/components/ExpandableResource.tsx
  97. 620 173
      dashboard/src/components/ProvisionerStatus.tsx
  98. 2 2
      dashboard/src/components/Selector.tsx
  99. 24 10
      dashboard/src/components/TabSelector.tsx
  100. 5 1
      dashboard/src/components/Table.tsx

+ 48 - 0
.air.provisioner.toml

@@ -0,0 +1,48 @@
+# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
+
+# Working directory
+# . or absolute path, please note that the directories following must be under root.
+root = "."
+tmp_dir = "tmp"
+
+[build]
+# Just plain old shell command. You could use `make` as well.
+cmd = "go build -o ./tmp/provisioner -tags ee -ldflags=\"-X 'main.Version=dev-ee'\" ./cmd/provisioner"
+
+# Binary file yields from `cmd`.
+bin = "tmp/provisioner"
+# Customize binary.
+full_bin = "tmp/provisioner"
+# Watch these filename extensions.
+include_ext = ["go", "mod", "sum", "html"]
+# Ignore these filename extensions or directories.
+exclude_dir = ["tmp", "dashboard"]
+# Watch these directories if you specified.
+include_dir = []
+# Exclude files.
+exclude_file = []
+# This log file places in your tmp_dir.
+log = "air.log"
+# It's not necessary to trigger build each time file changes if it's too frequent.
+delay = 1000 # ms
+# Stop running old binary when build errors occur.
+stop_on_error = true
+# Send Interrupt signal before killing process (windows does not support this feature)
+send_interrupt = false
+# Delay after sending Interrupt signal
+kill_delay = 500 # ms
+
+[log]
+# Show log time
+time = false
+
+[color]
+# Customize each part's color. If no color found, use the raw app log.
+main = "magenta"
+watcher = "cyan"
+build = "yellow"
+runner = "green"
+
+[misc]
+# Delete tmp directory on exit
+clean_on_exit = true

+ 3 - 0
Makefile

@@ -18,3 +18,6 @@ build-cli:
 
 build-cli-dev:
 	go build -tags cli -o $(BINDIR)/porter ./cli
+
+start-provisioner-dev: install setup-env-files
+	bash ./scripts/dev-environment/StartProvisionerServer.sh

+ 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,

+ 1 - 1
api/server/authn/handler.go

@@ -153,7 +153,7 @@ func (authn *AuthN) nextWithUserID(w http.ResponseWriter, r *http.Request, userI
 func (authn *AuthN) sendForbiddenError(err error, w http.ResponseWriter, r *http.Request) {
 	reqErr := apierrors.NewErrForbidden(err)
 
-	apierrors.HandleAPIError(authn.config, w, r, reqErr, true)
+	apierrors.HandleAPIError(authn.config.Logger, authn.config.Alerter, w, r, reqErr, true)
 }
 
 var errInvalidToken = fmt.Errorf("authorization header exists, but token is not valid")

+ 2 - 2
api/server/authz/cluster.go

@@ -50,11 +50,11 @@ func (p *ClusterScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("cluster with id %d not found in project %d", clusterID, proj.ID),
 			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 3 - 3
api/server/authz/git_installation.go

@@ -48,15 +48,15 @@ func (p *GitInstallationScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *ht
 	gitInstallation, err := p.config.Repo.GithubAppInstallation().ReadGithubAppInstallationByInstallationID(gitInstallationID)
 
 	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(err), true)
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(err), true)
 		return
 	} else if err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 
 	if err := p.doesUserHaveGitInstallationAccess(user.GithubAppIntegrationID, gitInstallationID); err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 

+ 2 - 2
api/server/authz/helm_repo.go

@@ -43,11 +43,11 @@ func (p *HelmRepoScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("helm repo with id %d not found in project %d", helmRepoID, proj.ID),
 			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 2 - 2
api/server/authz/infra.go

@@ -43,11 +43,11 @@ func (p *InfraScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("infra with id %d not found in project %d", infraID, proj.ID),
 			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 2 - 2
api/server/authz/invite.go

@@ -43,11 +43,11 @@ func (p *InviteScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("invite with id %d not found in project %d", inviteID, proj.ID),
 			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 60 - 0
api/server/authz/operation.go

@@ -0,0 +1,60 @@
+package authz
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"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"
+	"gorm.io/gorm"
+)
+
+type OperationScopedFactory struct {
+	config *config.Config
+}
+
+func NewOperationScopedFactory(
+	config *config.Config,
+) *OperationScopedFactory {
+	return &OperationScopedFactory{config}
+}
+
+func (p *OperationScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &OperationScopedMiddleware{next, p.config}
+}
+
+type OperationScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *OperationScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	operationID := reqScopes[types.OperationScope].Resource.Name
+
+	// look for matching operation for the infra
+	operation, err := p.config.Repo.Infra().ReadOperation(infra.ID, operationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(err), true)
+			return
+		}
+
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+
+	ctx := NewOperationContext(r.Context(), operation)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewOperationContext(ctx context.Context, operation *models.Operation) context.Context {
+	return context.WithValue(ctx, types.OperationScope, operation)
+}

+ 6 - 3
api/server/authz/policy.go

@@ -43,7 +43,7 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	reqScopes, reqErr := getRequestActionForEndpoint(r, h.endpointMeta)
 
 	if reqErr != nil {
-		apierrors.HandleAPIError(h.config, w, r, reqErr, true)
+		apierrors.HandleAPIError(h.config.Logger, h.config.Alerter, w, r, reqErr, true)
 		return
 	}
 
@@ -54,7 +54,7 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	policyDocs, reqErr := h.loader.LoadPolicyDocuments(user.ID, projID)
 
 	if reqErr != nil {
-		apierrors.HandleAPIError(h.config, w, r, reqErr, true)
+		apierrors.HandleAPIError(h.config.Logger, h.config.Alerter, w, r, reqErr, true)
 		return
 	}
 
@@ -63,7 +63,8 @@ func (h *PolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	if !hasAccess {
 		apierrors.HandleAPIError(
-			h.config,
+			h.config.Logger,
+			h.config.Alerter,
 			w,
 			r,
 			apierrors.NewErrForbidden(fmt.Errorf("policy forbids action for user %d in project %d", user.ID, projID)),
@@ -107,6 +108,8 @@ func getRequestActionForEndpoint(
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamGitInstallationID)
 		case types.InfraScope:
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInfraID)
+		case types.OperationScope:
+			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamOperationID)
 		case types.NamespaceScope:
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamNamespace)
 		case types.ReleaseScope:

+ 2 - 2
api/server/authz/project.go

@@ -41,14 +41,14 @@ func (p *ProjectScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("project not found with id %d", projID),
 			), true)
 
 			return
 		}
 
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 

+ 2 - 2
api/server/authz/registry.go

@@ -43,11 +43,11 @@ func (p *RegistryScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	if err != nil {
 		if err == gorm.ErrRecordNotFound {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
 				fmt.Errorf("registry with id %d not found in project %d", registryID, proj.ID),
 			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 3 - 3
api/server/authz/release.go

@@ -40,7 +40,7 @@ func (p *ReleaseScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	helmAgent, err := p.agentGetter.GetHelmAgent(r, cluster, "")
 
 	if err != nil {
-		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}
 
@@ -57,12 +57,12 @@ func (p *ReleaseScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		// ugly casing since at the time of this commit Helm doesn't have an errors package.
 		// so we rely on the Helm error containing "not found"
 		if strings.Contains(err.Error(), "not found") {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrPassThroughToClient(
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf("release not found"),
 				http.StatusNotFound,
 			), true)
 		} else {
-			apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
 		}
 
 		return

+ 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)
+}

+ 3 - 3
api/server/handlers/handler.go

@@ -58,11 +58,11 @@ func (d *DefaultPorterHandler) Repo() repository.Repository {
 }
 
 func (d *DefaultPorterHandler) HandleAPIError(w http.ResponseWriter, r *http.Request, err apierrors.RequestError) {
-	apierrors.HandleAPIError(d.Config(), w, r, err, true)
+	apierrors.HandleAPIError(d.Config().Logger, d.Config().Alerter, w, r, err, true)
 }
 
 func (d *DefaultPorterHandler) HandleAPIErrorNoWrite(w http.ResponseWriter, r *http.Request, err apierrors.RequestError) {
-	apierrors.HandleAPIError(d.Config(), w, r, err, false)
+	apierrors.HandleAPIError(d.Config().Logger, d.Config().Alerter, w, r, err, false)
 }
 
 func (d *DefaultPorterHandler) WriteResult(w http.ResponseWriter, r *http.Request, v interface{}) {
@@ -123,7 +123,7 @@ func NewUnavailable(config *config.Config, handlerID string) *Unavailable {
 }
 
 func (u *Unavailable) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	apierrors.HandleAPIError(u.config, w, r, apierrors.NewErrPassThroughToClient(
+	apierrors.HandleAPIError(u.config.Logger, u.config.Alerter, w, r, apierrors.NewErrPassThroughToClient(
 		fmt.Errorf("%s not available in community edition", u.handlerID),
 		http.StatusBadRequest,
 	), true, apierrors.ErrorOpts{

+ 164 - 0
api/server/handlers/infra/create.go

@@ -0,0 +1,164 @@
+package infra
+
+import (
+	"context"
+	"fmt"
+	"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/encryption"
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/provisioner/client"
+
+	ptypes "github.com/porter-dev/porter/provisioner/types"
+)
+
+type InfraCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInfraCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *InfraCreateHandler {
+	return &InfraCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InfraCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	req := &types.CreateInfraRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	suffix, err := encryption.GenerateRandomBytes(6)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	sourceLink, sourceVersion := getSourceLinkAndVersion(types.InfraKind(req.Kind))
+
+	// create the infra object
+	infra := &models.Infra{
+		Kind:            types.InfraKind(req.Kind),
+		APIVersion:      "v2",
+		ProjectID:       proj.ID,
+		Suffix:          suffix,
+		Status:          types.StatusCreating,
+		CreatedByUserID: user.ID,
+		SourceLink:      sourceLink,
+		SourceVersion:   sourceVersion,
+	}
+
+	// verify the credentials
+	err = checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	// handle write to the database
+	infra, err = c.Repo().Infra().CreateInfra(infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
+
+	resp, err := pClient.Apply(context.Background(), proj.ID, infra.ID, &ptypes.ApplyBaseRequest{
+		Kind:          req.Kind,
+		Values:        req.Values,
+		OperationKind: "create",
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}
+
+func checkInfraCredentials(config *config.Config, proj *models.Project, infra *models.Infra, req *types.InfraCredentials) error {
+	if req == nil {
+		return nil
+	}
+
+	if req.DOIntegrationID != 0 {
+		_, err := config.Repo.OAuthIntegration().ReadOAuthIntegration(proj.ID, req.DOIntegrationID)
+
+		if err != nil {
+			return fmt.Errorf("do integration id %d not found in project %d", req.DOIntegrationID, proj.ID)
+		}
+
+		infra.DOIntegrationID = req.DOIntegrationID
+		infra.AWSIntegrationID = 0
+		infra.GCPIntegrationID = 0
+	} else if req.AWSIntegrationID != 0 {
+		_, err := config.Repo.AWSIntegration().ReadAWSIntegration(proj.ID, req.AWSIntegrationID)
+
+		if err != nil {
+			return fmt.Errorf("aws integration id %d not found in project %d", req.AWSIntegrationID, proj.ID)
+		}
+
+		infra.DOIntegrationID = 0
+		infra.AWSIntegrationID = req.AWSIntegrationID
+		infra.GCPIntegrationID = 0
+	} else if req.GCPIntegrationID != 0 {
+		_, err := config.Repo.GCPIntegration().ReadGCPIntegration(proj.ID, req.GCPIntegrationID)
+
+		if err != nil {
+			return fmt.Errorf("gcp integration id %d not found in project %d", req.GCPIntegrationID, proj.ID)
+		}
+
+		infra.DOIntegrationID = 0
+		infra.AWSIntegrationID = 0
+		infra.GCPIntegrationID = req.GCPIntegrationID
+	}
+
+	if infra.DOIntegrationID == 0 && infra.AWSIntegrationID == 0 && infra.GCPIntegrationID == 0 {
+		return fmt.Errorf("at least one integration id must be set")
+	}
+
+	return nil
+}
+
+// getSourceLinkAndVersion returns the source link and version for the infrastructure. For now,
+// this is hardcoded
+func getSourceLinkAndVersion(kind types.InfraKind) (string, string) {
+	switch kind {
+	case types.InfraECR:
+		return "porter/aws/ecr", "v0.1.0"
+	case types.InfraEKS:
+		return "porter/aws/eks", "v0.1.0"
+	case types.InfraRDS:
+		return "porter/aws/rds", "v0.1.0"
+	case types.InfraGCR:
+		return "porter/gcp/gcr", "v0.1.0"
+	case types.InfraGKE:
+		return "porter/gcp/gke", "v0.1.0"
+	case types.InfraDOCR:
+		return "porter/do/docr", "v0.1.0"
+	case types.InfraDOKS:
+		return "porter/do/doks", "v0.1.0"
+	}
+
+	return "porter/test", "v0.1.0"
+}

+ 23 - 233
api/server/handlers/infra/delete.go

@@ -1,23 +1,19 @@
 package infra
 
 import (
-	"encoding/json"
+	"context"
 	"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/analytics"
-	"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/gke"
 	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/provisioner/client"
+
+	ptypes "github.com/porter-dev/porter/provisioner/types"
 )
 
 type InfraDeleteHandler struct {
@@ -35,250 +31,44 @@ func NewInfraDeleteHandler(
 }
 
 func (c *InfraDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
 
-	request := &types.DeleteInfraRequest{}
+	req := &types.DeleteInfraRequest{}
 
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
 		return
 	}
 
-	if infra.Kind == types.InfraDOKS || infra.Kind == types.InfraGKE || infra.Kind == types.InfraEKS {
-		c.Config().AnalyticsClient.Track(analytics.ClusterDestroyingStartTrack(
-			&analytics.ClusterDestroyingStartTrackOpts{
-				ClusterScopedTrackOpts: analytics.GetClusterScopedTrackOpts(infra.CreatedByUserID, infra.ProjectID, 0),
-				ClusterType:            infra.Kind,
-				InfraID:                infra.ID,
-			},
-		))
-	}
-
-	infra.Status = types.StatusDestroying
-	infra, err := c.Repo().Infra().UpdateInfra(infra)
+	// verify the credentials
+	err := checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
 		return
 	}
 
-	switch infra.Kind {
-	case types.InfraECR:
-		err = destroyECR(c.Config(), infra)
-	case types.InfraEKS:
-		err = destroyEKS(c.Config(), infra)
-	case types.InfraDOCR:
-		err = destroyDOCR(c.Config(), infra)
-	case types.InfraDOKS:
-		err = destroyDOKS(c.Config(), infra)
-	case types.InfraGKE:
-		err = destroyGKE(c.Config(), infra)
-	}
+	// mark the infra as destroying
+	infra.Status = types.StatusDestroying
+
+	infra, err = c.Repo().Infra().UpdateInfra(infra)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
-}
-
-func destroyECR(conf *config.Config, infra *models.Infra) error {
-	lastAppliedECR := &types.CreateECRInfraRequest{}
-
-	// parse infra last applied into ECR config
-	if err := json.Unmarshal(infra.LastApplied, lastAppliedECR); 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.ECR = &ecr.Conf{
-		AWSRegion: awsInt.AWSRegion,
-		ECRName:   lastAppliedECR.ECRName,
-	}
-
-	opts.OperationKind = provisioner.Destroy
-
-	err = conf.ProvisionerAgent.Provision(opts)
-
-	return err
-}
-
-func destroyEKS(conf *config.Config, infra *models.Infra) error {
-	lastAppliedEKS := &types.CreateEKSInfraRequest{}
-
-	// parse infra last applied into EKS config
-	if err := json.Unmarshal(infra.LastApplied, lastAppliedEKS); 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.EKS = &eks.Conf{
-		AWSRegion:   awsInt.AWSRegion,
-		ClusterName: lastAppliedEKS.EKSName,
-		MachineType: lastAppliedEKS.MachineType,
-		IssuerEmail: lastAppliedEKS.IssuerEmail,
-	}
-	opts.OperationKind = provisioner.Destroy
-
-	err = conf.ProvisionerAgent.Provision(opts)
-
-	return err
-}
-
-func destroyDOCR(conf *config.Config, infra *models.Infra) error {
-	lastAppliedDOCR := &types.CreateDOCRInfraRequest{}
-
-	// parse infra last applied into DOCR config
-	if err := json.Unmarshal(infra.LastApplied, lastAppliedDOCR); err != nil {
-		return err
-	}
-
-	doInt, err := conf.Repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
-
-	if err != nil {
-		return err
-	}
-
-	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
-
-	vaultToken := ""
-
-	if conf.CredentialBackend != nil {
-		vaultToken, err = conf.CredentialBackend.CreateOAuthToken(doInt)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-
-	opts.DOCR = &docr.Conf{
-		DOCRName:             lastAppliedDOCR.DOCRName,
-		DOCRSubscriptionTier: lastAppliedDOCR.DOCRSubscriptionTier,
-	}
 
-	opts.OperationKind = provisioner.Destroy
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
 
-	err = conf.ProvisionerAgent.Provision(opts)
-
-	return err
-}
-
-func destroyDOKS(conf *config.Config, infra *models.Infra) error {
-	lastAppliedDOKS := &types.CreateDOKSInfraRequest{}
-
-	// parse infra last applied into DOKS config
-	if err := json.Unmarshal(infra.LastApplied, lastAppliedDOKS); err != nil {
-		return err
-	}
-
-	doInt, err := conf.Repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+	resp, err := pClient.Delete(context.Background(), proj.ID, infra.ID, &ptypes.DeleteBaseRequest{
+		OperationKind: "delete",
+	})
 
 	if err != nil {
-		return err
-	}
-
-	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
-
-	vaultToken := ""
-
-	if conf.CredentialBackend != nil {
-		vaultToken, err = conf.CredentialBackend.CreateOAuthToken(doInt)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-
-	opts.DOKS = &doks.Conf{
-		DORegion:        lastAppliedDOKS.DORegion,
-		DOKSClusterName: lastAppliedDOKS.DOKSName,
-		IssuerEmail:     lastAppliedDOKS.IssuerEmail,
-	}
-
-	opts.OperationKind = provisioner.Destroy
-
-	err = conf.ProvisionerAgent.Provision(opts)
-
-	return err
-}
-
-func destroyGKE(conf *config.Config, infra *models.Infra) error {
-	lastAppliedGKE := &types.CreateGKEInfraRequest{}
-
-	// parse infra last applied into DOKS config
-	if err := json.Unmarshal(infra.LastApplied, lastAppliedGKE); err != nil {
-		return err
-	}
-
-	gcpInt, err := conf.Repo.GCPIntegration().ReadGCPIntegration(infra.ProjectID, infra.GCPIntegrationID)
-
-	if err != nil {
-		return err
-	}
-
-	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
-
-	vaultToken := ""
-
-	if conf.CredentialBackend != nil {
-		vaultToken, err = conf.CredentialBackend.CreateGCPToken(gcpInt)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-	opts.GKE = &gke.Conf{
-		GCPProjectID: gcpInt.GCPProjectID,
-		GCPRegion:    lastAppliedGKE.GCPRegion,
-		ClusterName:  lastAppliedGKE.GKEName,
-		IssuerEmail:  lastAppliedGKE.IssuerEmail,
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
-	opts.OperationKind = provisioner.Destroy
-
-	err = conf.ProvisionerAgent.Provision(opts)
-
-	return err
+	c.WriteResult(w, r, resp)
 }

+ 342 - 0
api/server/handlers/infra/forms.go

@@ -0,0 +1,342 @@
+package infra
+
+const testForm = `name: Test
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: String to echo
+    - type: string-input
+      variable: echo
+      settings:
+        default: hello
+`
+
+// note: region options for AWS, if needed
+// - type: select
+//       label: 📍 AWS Region
+//       variable: aws_region
+//       settings:
+//         default: us-east-2
+//         options:
+//         - label: US East (N. Virginia) us-east-1
+//           value: us-east-1
+//         - label: US East (Ohio) us-east-2
+//           value: us-east-2
+//         - label: US West (N. California) us-west-1
+//           value: us-west-1
+//         - label: US West (Oregon) us-west-2
+//           value: us-west-2
+//         - label: Africa (Cape Town) af-south-1
+//           value: af-south-1
+//         - label: Asia Pacific (Hong Kong) ap-east-1
+//           value: ap-east-1
+//         - label: Asia Pacific (Mumbai) ap-south-1
+//           value: ap-south-1
+//         - label: Asia Pacific (Seoul) ap-northeast-2
+//           value: ap-northeast-2
+//         - label: Asia Pacific (Singapore) ap-southeast-1
+//           value: ap-southeast-1
+//         - label: Asia Pacific (Sydney) ap-southeast-2
+//           value: ap-southeast-2
+//         - label: Asia Pacific (Tokyo) ap-northeast-1
+//           value: ap-northeast-1
+//         - label: Canada (Central) ca-central-1
+//           value: ca-central-1
+//         - label: Europe (Ireland) eu-west-1
+//           value: eu-west-1
+//         - label: Europe (London) eu-west-2
+//           value: eu-west-2
+//         - label: Europe (Milan) eu-south-1
+//           value: eu-south-1
+//         - label: Europe (Paris) eu-west-3
+//           value: eu-west-3
+//         - label: Europe (Stockholm) eu-north-1
+//           value: eu-north-1
+//         - label: Middle East (Bahrain) me-south-1
+//           value: me-south-1
+//         - label: South America (São Paulo) sa-east-1
+//           value: sa-east-1
+
+const ecrForm = `name: ECR
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: ECR Configuration
+    - type: string-input
+      label: ECR Name
+      required: true
+      placeholder: my-awesome-registry
+      variable: ecr_name
+`
+
+const eksForm = `name: EKS
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: EKS Configuration
+    - type: select
+      label: ⚙️ AWS Machine Type
+      variable: machine_type
+      settings:
+        default: t2.medium
+        options:
+        - label: t2.medium
+          value: t2.medium
+    - type: string-input
+      label: 👤 Issuer Email
+      required: true
+      placeholder: example@example.com
+      variable: issuer_email
+    - type: string-input
+      label: EKS Cluster Name
+      required: true
+      placeholder: my-cluster
+      variable: cluster_name
+`
+
+const gcrForm = `name: GCR
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: GCR Configuration
+    - type: select
+      label: 📍 GCP Region
+      variable: gcp_region
+      settings:
+        default: us-central1
+        options:
+        - label: asia-east1
+          value: asia-east1
+        - label: asia-east2
+          value: asia-east2
+        - label: asia-northeast1
+          value: asia-northeast1
+        - label: asia-northeast2
+          value: asia-northeast2
+        - label: asia-northeast3
+          value: asia-northeast3
+        - label: asia-south1
+          value: asia-south1
+        - label: asia-southeast1
+          value: asia-southeast1
+        - label: asia-southeast2
+          value: asia-southeast2
+        - label: australia-southeast1
+          value: australia-southeast1
+        - label: europe-north1
+          value: europe-north1
+        - label: europe-west1
+          value: europe-west1
+        - label: europe-west2
+          value: europe-west2
+        - label: europe-west3
+          value: europe-west3
+        - label: europe-west4
+          value: europe-west4
+        - label: europe-west6
+          value: europe-west6
+        - label: northamerica-northeast1
+          value: northamerica-northeast1
+        - label: southamerica-east1
+          value: southamerica-east1
+        - label: us-central1
+          value: us-central1
+        - label: us-east1
+          value: us-east1
+        - label: us-east4
+          value: us-east4
+        - label: us-east1
+          value: us-east1
+        - label: us-east1
+          value: us-east1
+        - label: us-west1
+          value: us-west1
+        - label: us-east1
+          value: us-west2
+        - label: us-west3
+          value: us-west3
+        - label: us-west4
+          value: us-west4
+`
+
+const gkeForm = `name: GKE
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: GKE Configuration
+    - type: select
+      label: 📍 GCP Region
+      variable: gcp_region
+      settings:
+        default: us-central1
+        options:
+        - label: asia-east1
+          value: asia-east1
+        - label: asia-east2
+          value: asia-east2
+        - label: asia-northeast1
+          value: asia-northeast1
+        - label: asia-northeast2
+          value: asia-northeast2
+        - label: asia-northeast3
+          value: asia-northeast3
+        - label: asia-south1
+          value: asia-south1
+        - label: asia-southeast1
+          value: asia-southeast1
+        - label: asia-southeast2
+          value: asia-southeast2
+        - label: australia-southeast1
+          value: australia-southeast1
+        - label: europe-north1
+          value: europe-north1
+        - label: europe-west1
+          value: europe-west1
+        - label: europe-west2
+          value: europe-west2
+        - label: europe-west3
+          value: europe-west3
+        - label: europe-west4
+          value: europe-west4
+        - label: europe-west6
+          value: europe-west6
+        - label: northamerica-northeast1
+          value: northamerica-northeast1
+        - label: southamerica-east1
+          value: southamerica-east1
+        - label: us-central1
+          value: us-central1
+        - label: us-east1
+          value: us-east1
+        - label: us-east4
+          value: us-east4
+        - label: us-east1
+          value: us-east1
+        - label: us-east1
+          value: us-east1
+        - label: us-west1
+          value: us-west1
+        - label: us-east1
+          value: us-west2
+        - label: us-west3
+          value: us-west3
+        - label: us-west4
+          value: us-west4
+    - type: string-input
+      label: 👤 Issuer Email
+      required: true
+      placeholder: example@example.com
+      variable: issuer_email
+    - type: string-input
+      label: GKE Cluster Name
+      required: true
+      placeholder: my-cluster
+      variable: cluster_name
+`
+
+const docrForm = `name: DOCR
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: DOCR Configuration
+    - type: select
+      label: DO Subscription Tier
+      variable: docr_subscription_tier
+      settings:
+        default: basic
+        options:
+        - label: Basic
+          value: basic
+        - label: Professional
+          value: professional
+    - type: string-input
+      label: DOCR Name
+      required: true
+      placeholder: my-awesome-registry
+      variable: docr_name
+`
+
+const doksForm = `name: DOKS
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: DOKS Configuration
+    - type: select
+      label: 📍 DO Region
+      variable: do_region
+      settings:
+        default: nyc1
+        options:
+        - label: Amsterdam 3
+          value: ams3
+        - label: Bangalore 1
+          value: blr1
+        - label: Frankfurt 1
+          value: fra1
+        - label: London 1
+          value: lon1
+        - label: New York 1
+          value: nyc1
+        - label: New York 3
+          value: nyc3
+        - label: San Francisco 2
+          value: sfo2
+        - label: San Francisco 3
+          value: sfo3
+        - label: Singapore 1
+          value: sgp1
+        - label: Toronto 1
+          value: tor1
+    - type: string-input
+      label: 👤 Issuer Email
+      required: true
+      placeholder: example@example.com
+      variable: issuer_email
+    - type: string-input
+      label: DOKS Cluster Name
+      required: true
+      placeholder: my-cluster
+      variable: cluster_name
+`

+ 18 - 1
api/server/handlers/infra/get.go

@@ -5,6 +5,7 @@ import (
 
 	"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"
@@ -26,5 +27,21 @@ func NewInfraGetHandler(
 func (c *InfraGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
 
-	c.WriteResult(w, r, infra.ToInfraType())
+	res := infra.ToInfraType()
+
+	// look for the latest operation and attach it, if it exists
+	operation, err := c.Repo().Infra().GetLatestOperation(infra)
+
+	if err == nil && operation != nil {
+		op, err := operation.ToOperationType()
+
+		if err != nil {
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+			return
+		} else {
+			res.LatestOperation = op
+		}
+	}
+
+	c.WriteResult(w, r, res)
 }

+ 52 - 0
api/server/handlers/infra/get_operation.go

@@ -0,0 +1,52 @@
+package infra
+
+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"
+	"github.com/porter-dev/porter/internal/templater/parser"
+)
+
+type InfraGetOperationHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetOperationHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetOperationHandler {
+	return &InfraGetOperationHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetOperationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+	operation, _ := r.Context().Value(types.OperationScope).(*models.Operation)
+
+	op, err := operation.ToOperationType()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// TODO: get corresponding form rather than test form
+	formYAML, err := parser.FormYAMLFromBytes(&parser.ClientConfigDefault{
+		InfraOperation: operation,
+	}, getFormBytesFromKind(string(infra.Kind)), "declared", "infra")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	op.Form = formYAML
+
+	c.WriteResult(w, r, op)
+}

+ 46 - 0
api/server/handlers/infra/get_operation_logs.go

@@ -0,0 +1,46 @@
+package infra
+
+import (
+	"context"
+	"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"
+	"github.com/porter-dev/porter/provisioner/client"
+)
+
+type InfraGetOperationLogsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetOperationLogsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetOperationLogsHandler {
+	return &InfraGetOperationLogsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetOperationLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+	operation, _ := r.Context().Value(types.OperationScope).(*models.Operation)
+
+	workspaceID := models.GetWorkspaceID(infra, operation)
+
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
+
+	resp, err := pClient.GetLogs(context.Background(), workspaceID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 45 - 0
api/server/handlers/infra/get_state.go

@@ -0,0 +1,45 @@
+package infra
+
+import (
+	"context"
+	"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"
+
+	"github.com/porter-dev/porter/provisioner/client"
+)
+
+type InfraGetStateHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetStateHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetStateHandler {
+	return &InfraGetStateHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetStateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
+
+	resp, err := pClient.GetState(context.Background(), proj.ID, infra.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 81 - 0
api/server/handlers/infra/get_template.go

@@ -0,0 +1,81 @@
+package infra
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/templater/parser"
+)
+
+type InfraGetTemplateHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraGetTemplateHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraGetTemplateHandler {
+	return &InfraGetTemplateHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraGetTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	_, reqErr := requestutils.GetURLParamString(r, types.URLParamTemplateVersion)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamTemplateName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+
+		return
+	}
+
+	nameLower := strings.ToLower(name)
+
+	formYAML, err := parser.FormYAMLFromBytes(&parser.ClientConfigDefault{}, getFormBytesFromKind(name), "declared", "infra")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.InfraTemplate{
+		InfraTemplateMeta: templateMap[nameLower],
+		Form:              formYAML,
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func getFormBytesFromKind(kind string) []byte {
+	formBytes := []byte(testForm)
+
+	switch strings.ToLower(kind) {
+	case "ecr":
+		formBytes = []byte(ecrForm)
+	case "eks":
+		formBytes = []byte(eksForm)
+	case "gcr":
+		formBytes = []byte(gcrForm)
+	case "gke":
+		formBytes = []byte(gkeForm)
+	case "docr":
+		formBytes = []byte(docrForm)
+	case "doks":
+		formBytes = []byte(doksForm)
+	}
+
+	return formBytes
+}

+ 10 - 3
api/server/handlers/infra/list.go

@@ -12,22 +12,29 @@ import (
 )
 
 type InfraListHandler struct {
-	handlers.PorterHandlerWriter
+	handlers.PorterHandlerReadWriter
 }
 
 func NewInfraListHandler(
 	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
 ) *InfraListHandler {
 	return &InfraListHandler{
-		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 	}
 }
 
 func (p *InfraListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
-	infras, err := p.Repo().Infra().ListInfrasByProjectID(proj.ID)
+	req := &types.ListInfraRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	infras, err := p.Repo().Infra().ListInfrasByProjectID(proj.ID, req.Version)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 44 - 0
api/server/handlers/infra/list_operations.go

@@ -0,0 +1,44 @@
+package infra
+
+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 InfraListOperationsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraListOperationsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraListOperationsHandler {
+	return &InfraListOperationsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraListOperationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	ops, err := c.Repo().Infra().ListOperations(infra.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.OperationMeta, 0)
+
+	for _, op := range ops {
+		res = append(res, op.ToOperationMetaType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 92 - 0
api/server/handlers/infra/list_templates.go

@@ -0,0 +1,92 @@
+package infra
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type InfraListTemplateHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraListTemplateHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraListTemplateHandler {
+	return &InfraListTemplateHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraListTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	res := make([]types.InfraTemplateMeta, 0)
+
+	for _, val := range templateMap {
+		res = append(res, *val)
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+var templateMap = map[string]*types.InfraTemplateMeta{
+	"test": {
+		Icon:               "",
+		Description:        "Create a test resource.",
+		Name:               "Test",
+		Version:            "v0.1.0",
+		Kind:               "test",
+		RequiredCredential: "do_integration_id",
+	},
+	"ecr": {
+		Icon:               "https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4",
+		Description:        "Create an Elastic Container Registry.",
+		Name:               "ECR",
+		Version:            "v0.1.0",
+		Kind:               "ecr",
+		RequiredCredential: "aws_integration_id",
+	},
+	"eks": {
+		Icon:               "https://img.stackshare.io/service/7991/amazon-eks.png",
+		Description:        "Create an Elastic Kubernetes Service cluster.",
+		Name:               "EKS",
+		Version:            "v0.1.0",
+		Kind:               "eks",
+		RequiredCredential: "aws_integration_id",
+	},
+	"gcr": {
+		Icon:               "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
+		Description:        "Create a Google Container Registry.",
+		Name:               "GCR",
+		Version:            "v0.1.0",
+		Kind:               "gcr",
+		RequiredCredential: "gcp_integration_id",
+	},
+	"gke": {
+		Icon:               "https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png",
+		Description:        "Create a Google Kubernetes Engine cluster.",
+		Name:               "GKE",
+		Version:            "v0.1.0",
+		Kind:               "gke",
+		RequiredCredential: "gcp_integration_id",
+	},
+	"docr": {
+		Icon:               "",
+		Description:        "Create a Digital Ocean Container Registry.",
+		Name:               "DOCR",
+		Version:            "v0.1.0",
+		Kind:               "docr",
+		RequiredCredential: "do_integration_id",
+	},
+	"doks": {
+		Icon:               "",
+		Description:        "Create a Digital Ocean Kubernetes Service cluster.",
+		Name:               "DOKS",
+		Version:            "v0.1.0",
+		Kind:               "doks",
+		RequiredCredential: "do_integration_id",
+	},
+}

+ 80 - 0
api/server/handlers/infra/retry_create.go

@@ -0,0 +1,80 @@
+package infra
+
+import (
+	"context"
+	"encoding/json"
+	"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"
+	"github.com/porter-dev/porter/provisioner/client"
+	ptypes "github.com/porter-dev/porter/provisioner/types"
+)
+
+type InfraRetryCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInfraRetryCreateHandler(config *config.Config, decoderValidator shared.RequestDecoderValidator, writer shared.ResultWriter) *InfraRetryCreateHandler {
+	return &InfraRetryCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InfraRetryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	req := &types.RetryInfraRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// verify the credentials
+	err := checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	// if the values are nil, get the last applied values and marshal them
+	if req.Values == nil || len(req.Values) == 0 {
+		lastOperation, err := c.Repo().Infra().GetLatestOperation(infra)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		rawValues := lastOperation.LastApplied
+
+		err = json.Unmarshal(rawValues, &req.Values)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
+
+	resp, err := pClient.Apply(context.Background(), proj.ID, infra.ID, &ptypes.ApplyBaseRequest{
+		Kind:          string(infra.Kind),
+		Values:        req.Values,
+		OperationKind: "retry_create",
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 58 - 0
api/server/handlers/infra/retry_delete.go

@@ -0,0 +1,58 @@
+package infra
+
+import (
+	"context"
+	"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"
+	"github.com/porter-dev/porter/provisioner/client"
+	ptypes "github.com/porter-dev/porter/provisioner/types"
+)
+
+type InfraRetryDeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInfraRetryDeleteHandler(config *config.Config, decoderValidator shared.RequestDecoderValidator, writer shared.ResultWriter) *InfraRetryDeleteHandler {
+	return &InfraRetryDeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InfraRetryDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	req := &types.DeleteInfraRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// verify the credentials
+	err := checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
+
+	resp, err := pClient.Delete(context.Background(), proj.ID, infra.ID, &ptypes.DeleteBaseRequest{
+		OperationKind: "retry_delete",
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 101 - 9
api/server/handlers/infra/stream_logs.go

@@ -1,7 +1,12 @@
 package infra
 
 import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
 	"net/http"
+	"sync"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -9,39 +14,126 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"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/provisioner/pb"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/metadata"
 )
 
-type InfraStreamLogsHandler struct {
+type InfraStreamLogHandler struct {
 	handlers.PorterHandlerWriter
 }
 
-func NewInfraStreamLogsHandler(
+func NewInfraStreamLogHandler(
 	config *config.Config,
 	writer shared.ResultWriter,
-) *InfraStreamLogsHandler {
-	return &InfraStreamLogsHandler{
+) *InfraStreamLogHandler {
+	return &InfraStreamLogHandler{
 		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 	}
 }
 
-func (c *InfraStreamLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
 	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+	operation, _ := r.Context().Value(types.OperationScope).(*models.Operation)
+	workspaceID := models.GetWorkspaceID(infra, operation)
 
-	client, err := adapter.NewRedisClient(c.Config().RedisConf)
+	conn, err := grpc.Dial("localhost:8082", grpc.WithInsecure())
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	err = provisioner.ResourceStream(client, infra.GetUniqueName(), safeRW)
+	client := pb.NewProvisionerClient(conn)
+
+	header := metadata.New(map[string]string{
+		"workspace_id": workspaceID,
+	})
+
+	ctx := metadata.NewOutgoingContext(context.Background(), header)
+
+	ctx, cancel := context.WithCancel(ctx)
+
+	defer cancel()
+
+	stream, err := client.GetLog(ctx, &pb.Infra{
+		ProjectId: int64(infra.ProjectID),
+		Id:        int64(infra.ID),
+		Suffix:    infra.Suffix,
+	})
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
+
+	errorchan := make(chan error)
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	go func() {
+		wg.Wait()
+		close(errorchan)
+	}()
+
+	go func() {
+		defer wg.Done()
+
+		for {
+			if _, _, err := safeRW.ReadMessage(); err != nil {
+				errorchan <- nil
+				fmt.Println("closing websocket goroutine")
+				return
+			}
+		}
+	}()
+
+	go func() {
+		defer wg.Done()
+
+		for {
+			tfLog, err := stream.Recv()
+
+			if err != nil {
+				if err == io.EOF || errors.Is(ctx.Err(), context.Canceled) {
+					errorchan <- nil
+				} else {
+					errorchan <- err
+				}
+
+				fmt.Println("closing grpc goroutine")
+
+				return
+			}
+
+			_, err = safeRW.Write([]byte(tfLog.Log))
+
+			if err != nil {
+				errorchan <- nil
+				fmt.Println("closing grpc goroutine")
+				return
+			}
+		}
+
+	}()
+
+	for err = range errorchan {
+		if err != nil {
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+
+		// close the grpc stream: do not check for error case since the stream could already be
+		// closed
+		stream.CloseSend()
+
+		// close the websocket stream: do not check for error case since the WS could already be
+		// closed
+		safeRW.Close()
+
+		// cancel the context set for the grpc stream to ensure that Recv is unblocked
+		cancel()
+	}
 }

+ 133 - 0
api/server/handlers/infra/stream_state.go

@@ -0,0 +1,133 @@
+package infra
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"sync"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/provisioner/pb"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/metadata"
+)
+
+type InfraStreamStateHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInfraStreamStateHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *InfraStreamStateHandler {
+	return &InfraStreamStateHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *InfraStreamStateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+	operation, _ := r.Context().Value(types.OperationScope).(*models.Operation)
+	workspaceID := models.GetWorkspaceID(infra, operation)
+
+	conn, err := grpc.Dial("localhost:8082", grpc.WithInsecure())
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client := pb.NewProvisionerClient(conn)
+
+	header := metadata.New(map[string]string{
+		"workspace_id": workspaceID,
+	})
+
+	ctx := metadata.NewOutgoingContext(context.Background(), header)
+
+	ctx, cancel := context.WithCancel(ctx)
+
+	defer cancel()
+
+	stream, err := client.GetStateUpdate(ctx, &pb.Infra{
+		ProjectId: int64(infra.ProjectID),
+		Id:        int64(infra.ID),
+		Suffix:    infra.Suffix,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	errorchan := make(chan error)
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	go func() {
+		wg.Wait()
+		close(errorchan)
+	}()
+
+	go func() {
+		defer wg.Done()
+
+		for {
+			if _, _, err := safeRW.ReadMessage(); err != nil {
+				errorchan <- nil
+				fmt.Println("STATE STREAMER closing websocket goroutine")
+				return
+			}
+		}
+	}()
+
+	go func() {
+		defer wg.Done()
+
+		for {
+
+			stateUpdate, err := stream.Recv()
+
+			if err != nil {
+				if err == io.EOF || errors.Is(ctx.Err(), context.Canceled) {
+					errorchan <- nil
+				} else {
+					errorchan <- err
+				}
+
+				fmt.Println("STATE STREAMER closing grpc goroutine")
+
+				return
+			}
+
+			safeRW.WriteJSONWithChannel(stateUpdate, errorchan)
+		}
+	}()
+
+	for err = range errorchan {
+		if err != nil {
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+
+		// close the grpc stream: do not check for error case since the stream could already be
+		// closed
+		stream.CloseSend()
+
+		// close the websocket stream: do not check for error case since the WS could already be
+		// closed
+		safeRW.Close()
+
+		// cancel the context set for the grpc stream to ensure that Recv is unblocked
+		cancel()
+	}
+}

+ 80 - 0
api/server/handlers/infra/update.go

@@ -0,0 +1,80 @@
+package infra
+
+import (
+	"context"
+	"encoding/json"
+	"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"
+	"github.com/porter-dev/porter/provisioner/client"
+	ptypes "github.com/porter-dev/porter/provisioner/types"
+)
+
+type InfraUpdateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInfraUpdateHandler(config *config.Config, decoderValidator shared.RequestDecoderValidator, writer shared.ResultWriter) *InfraUpdateHandler {
+	return &InfraUpdateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *InfraUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	req := &types.RetryInfraRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// verify the credentials
+	err := checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	// if the values are nil, get the last applied values and marshal them
+	if req.Values == nil || len(req.Values) == 0 {
+		lastOperation, err := c.Repo().Infra().GetLatestOperation(infra)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		rawValues := lastOperation.LastApplied
+
+		err = json.Unmarshal(rawValues, &req.Values)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// call apply on the provisioner service
+	pClient := client.NewClient("http://localhost:8082/api/v1")
+
+	resp, err := pClient.Apply(context.Background(), proj.ID, infra.ID, &ptypes.ApplyBaseRequest{
+		Kind:          string(infra.Kind),
+		Values:        req.Values,
+		OperationKind: "update",
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 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
+	}
+
+	// trigger rollout of new applications
+	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.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf(strings.Join(errStrArr, ","))))
+		return
+	}
+
+	c.WriteResult(w, r, envGroup)
+}
+
+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)

+ 9 - 9
api/server/handlers/project/create_test.go

@@ -26,8 +26,8 @@ func TestCreateProjectSuccessful(t *testing.T) {
 
 	handler := project.NewProjectCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -66,7 +66,7 @@ func TestFailingDecoderValidator(t *testing.T) {
 	handler := project.NewProjectCreateHandler(
 		config,
 		apitest.NewFailingDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -90,8 +90,8 @@ func TestFailingCreateMethod(t *testing.T) {
 
 	handler := project.NewProjectCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -115,8 +115,8 @@ func TestFailingCreateRoleMethod(t *testing.T) {
 
 	handler := project.NewProjectCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -140,8 +140,8 @@ func TestFailingReadMethod(t *testing.T) {
 
 	handler := project.NewProjectCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 1 - 1
api/server/handlers/project/get_test.go

@@ -29,7 +29,7 @@ func TestGetProjectSuccessful(t *testing.T) {
 
 	handler := project.NewProjectGetHandler(
 		config,
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 2 - 2
api/server/handlers/project/list_test.go

@@ -37,7 +37,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 
 	handler := project.NewProjectListHandler(
 		config,
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -65,7 +65,7 @@ func TestFailingListMethod(t *testing.T) {
 
 	handler := project.NewProjectListHandler(
 		config,
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

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

@@ -1,69 +0,0 @@
-package provision
-
-import (
-	"fmt"
-	"time"
-
-	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/random"
-	"golang.org/x/crypto/bcrypt"
-)
-
-func CreateCEToken(conf *config.Config, infra *models.Infra) (*models.CredentialsExchangeToken, string, error) {
-	// convert the form to a project model
-	expiry := time.Now().Add(6 * time.Hour)
-
-	rawToken, err := random.StringWithCharset(32, "")
-
-	if err != nil {
-		return nil, "", err
-	}
-
-	hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), 8)
-
-	if err != nil {
-		return nil, "", err
-	}
-
-	ceToken := &models.CredentialsExchangeToken{
-		ProjectID:       infra.ProjectID,
-		Expiry:          &expiry,
-		Token:           hashedToken,
-		DOCredentialID:  infra.DOIntegrationID,
-		AWSCredentialID: infra.AWSIntegrationID,
-		GCPCredentialID: infra.GCPIntegrationID,
-	}
-
-	// handle write to the database
-	ceToken, err = conf.Repo.CredentialsExchangeToken().CreateCredentialsExchangeToken(ceToken)
-
-	if err != nil {
-		return nil, "", err
-	}
-
-	return ceToken, rawToken, nil
-}
-
-func GetSharedProvisionerOpts(conf *config.Config, infra *models.Infra) (*provisioner.ProvisionOpts, error) {
-	ceToken, rawToken, err := CreateCEToken(conf, infra)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return &provisioner.ProvisionOpts{
-		DryRun:              true,
-		Infra:               infra,
-		ProvImageTag:        conf.ServerConf.ProvisionerImageTag,
-		ProvJobNamespace:    conf.ServerConf.ProvisionerJobNamespace,
-		ProvImagePullSecret: conf.ServerConf.ProvisionerImagePullSecret,
-		TFHTTPBackendURL:    conf.ServerConf.ProvisionerBackendURL,
-		CredentialExchange: &provisioner.ProvisionCredentialExchange{
-			CredExchangeEndpoint: fmt.Sprintf("%s/api/internal/credentials", conf.ServerConf.ProvisionerCredExchangeURL),
-			CredExchangeToken:    rawToken,
-			CredExchangeID:       ceToken.ID,
-		},
-	}, nil
-}

+ 0 - 137
api/server/handlers/provision/provision_docr.go

@@ -1,137 +0,0 @@
-package provision
-
-import (
-	"encoding/json"
-	"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/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-type ProvisionDOCRHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewProvisionDOCRHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ProvisionDOCRHandler {
-	return &ProvisionDOCRHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ProvisionDOCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// read the user and project from context
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	request := &types.CreateDOCRInfraRequest{
-		ProjectID: proj.ID,
-	}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// get the DO integration, to check that integration exists and belongs to the project
-	doInt, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
-
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		}
-
-		return
-	}
-
-	suffix, err := repository.GenerateRandomBytes(6)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	lastApplied, err := json.Marshal(request)
-
-	// parse infra last applied into DOCR config
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	infra := &models.Infra{
-		Kind:            types.InfraDOCR,
-		ProjectID:       proj.ID,
-		Suffix:          suffix,
-		Status:          types.StatusCreating,
-		DOIntegrationID: request.DOIntegrationID,
-		CreatedByUserID: user.ID,
-		LastApplied:     lastApplied,
-	}
-
-	// handle write to the database
-	infra, err = c.Repo().Infra().CreateInfra(infra)
-
-	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
-	}
-
-	vaultToken := ""
-
-	if c.Config().CredentialBackend != nil {
-		vaultToken, err = c.Config().CredentialBackend.CreateOAuthToken(doInt)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-	opts.DOCR = &docr.Conf{
-		DOCRName:             request.DOCRName,
-		DOCRSubscriptionTier: request.DOCRSubscriptionTier,
-	}
-
-	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.Config().AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
-		&analytics.RegistryProvisioningStartTrackOpts{
-			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
-			RegistryType:           types.InfraDOCR,
-			InfraID:                infra.ID,
-		},
-	))
-
-	c.WriteResult(w, r, infra.ToInfraType())
-}

+ 0 - 138
api/server/handlers/provision/provision_doks.go

@@ -1,138 +0,0 @@
-package provision
-
-import (
-	"encoding/json"
-	"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/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-type ProvisionDOKSHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewProvisionDOKSHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ProvisionDOKSHandler {
-	return &ProvisionDOKSHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ProvisionDOKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// read the user and project from context
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	request := &types.CreateDOKSInfraRequest{
-		ProjectID: proj.ID,
-	}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// get the DO integration, to check that integration exists and belongs to the project
-	doInt, err := c.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
-
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		}
-
-		return
-	}
-
-	suffix, err := repository.GenerateRandomBytes(6)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	lastApplied, err := json.Marshal(request)
-
-	// parse infra last applied into DOKS config
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	infra := &models.Infra{
-		Kind:            types.InfraDOKS,
-		ProjectID:       proj.ID,
-		Suffix:          suffix,
-		Status:          types.StatusCreating,
-		DOIntegrationID: request.DOIntegrationID,
-		CreatedByUserID: user.ID,
-		LastApplied:     lastApplied,
-	}
-
-	// handle write to the database
-	infra, err = c.Repo().Infra().CreateInfra(infra)
-
-	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
-	}
-
-	vaultToken := ""
-
-	if c.Config().CredentialBackend != nil {
-		vaultToken, err = c.Config().CredentialBackend.CreateOAuthToken(doInt)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-	opts.DOKS = &doks.Conf{
-		DORegion:        request.DORegion,
-		DOKSClusterName: request.DOKSName,
-		IssuerEmail:     request.IssuerEmail,
-	}
-
-	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.Config().AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
-		&analytics.ClusterProvisioningStartTrackOpts{
-			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
-			ClusterType:            types.InfraDOKS,
-			InfraID:                infra.ID,
-		},
-	))
-
-	c.WriteResult(w, r, infra.ToInfraType())
-}

+ 0 - 136
api/server/handlers/provision/provision_ecr.go

@@ -1,136 +0,0 @@
-package provision
-
-import (
-	"encoding/json"
-	"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/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-type ProvisionECRHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewProvisionECRHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ProvisionECRHandler {
-	return &ProvisionECRHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ProvisionECRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// read the user and project from context
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	request := &types.CreateECRInfraRequest{
-		ProjectID: proj.ID,
-	}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// get the AWS integration, to check that integration exists and belongs to the project
-	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
-
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		}
-
-		return
-	}
-
-	suffix, err := repository.GenerateRandomBytes(6)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	lastApplied, err := json.Marshal(request)
-
-	// parse infra last applied into ECR config
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	infra := &models.Infra{
-		Kind:             types.InfraECR,
-		ProjectID:        proj.ID,
-		Suffix:           suffix,
-		Status:           types.StatusCreating,
-		AWSIntegrationID: request.AWSIntegrationID,
-		CreatedByUserID:  user.ID,
-		LastApplied:      lastApplied,
-	}
-
-	// handle write to the database
-	infra, err = c.Repo().Infra().CreateInfra(infra)
-
-	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
-	}
-
-	vaultToken := ""
-
-	if c.Config().CredentialBackend != nil {
-		vaultToken, err = c.Config().CredentialBackend.CreateAWSToken(awsInt)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-	opts.ECR = &ecr.Conf{
-		AWSRegion: awsInt.AWSRegion,
-		ECRName:   request.ECRName,
-	}
-	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.Config().AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
-		&analytics.RegistryProvisioningStartTrackOpts{
-			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
-			RegistryType:           types.InfraECR,
-			InfraID:                infra.ID,
-		},
-	))
-
-	c.WriteResult(w, r, infra.ToInfraType())
-}

+ 0 - 138
api/server/handlers/provision/provision_eks.go

@@ -1,138 +0,0 @@
-package provision
-
-import (
-	"encoding/json"
-	"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/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-type ProvisionEKSHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewProvisionEKSHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ProvisionEKSHandler {
-	return &ProvisionEKSHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ProvisionEKSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// read the user and project from context
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	request := &types.CreateEKSInfraRequest{
-		ProjectID: proj.ID,
-	}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// get the AWS integration, to check that integration exists and belongs to the project
-	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
-
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		}
-
-		return
-	}
-
-	suffix, err := repository.GenerateRandomBytes(6)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	lastApplied, err := json.Marshal(request)
-
-	// parse infra last applied into EKS config
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	infra := &models.Infra{
-		Kind:             types.InfraEKS,
-		ProjectID:        proj.ID,
-		Suffix:           suffix,
-		Status:           types.StatusCreating,
-		AWSIntegrationID: request.AWSIntegrationID,
-		CreatedByUserID:  user.ID,
-		LastApplied:      lastApplied,
-	}
-
-	// handle write to the database
-	infra, err = c.Repo().Infra().CreateInfra(infra)
-
-	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
-	}
-
-	vaultToken := ""
-
-	if c.Config().CredentialBackend != nil {
-		vaultToken, err = c.Config().CredentialBackend.CreateAWSToken(awsInt)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-	opts.EKS = &eks.Conf{
-		AWSRegion:   awsInt.AWSRegion,
-		ClusterName: request.EKSName,
-		MachineType: request.MachineType,
-		IssuerEmail: request.IssuerEmail,
-	}
-	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.Config().AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
-		&analytics.ClusterProvisioningStartTrackOpts{
-			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
-			ClusterType:            types.InfraEKS,
-			InfraID:                infra.ID,
-		},
-	))
-
-	c.WriteResult(w, r, infra.ToInfraType())
-}

+ 0 - 135
api/server/handlers/provision/provision_gcr.go

@@ -1,135 +0,0 @@
-package provision
-
-import (
-	"encoding/json"
-	"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/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gcr"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-type ProvisionGCRHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewProvisionGCRHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ProvisionGCRHandler {
-	return &ProvisionGCRHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ProvisionGCRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// read the user and project from context
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	request := &types.CreateGCRInfraRequest{
-		ProjectID: proj.ID,
-	}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// get the GCP integration, to check that integration exists and belongs to the project
-	gcpInt, err := c.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
-
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		}
-
-		return
-	}
-
-	suffix, err := repository.GenerateRandomBytes(6)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	lastApplied, err := json.Marshal(request)
-
-	// parse infra last applied into GCR config
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	infra := &models.Infra{
-		Kind:             types.InfraGCR,
-		ProjectID:        proj.ID,
-		Suffix:           suffix,
-		Status:           types.StatusCreating,
-		GCPIntegrationID: request.GCPIntegrationID,
-		CreatedByUserID:  user.ID,
-		LastApplied:      lastApplied,
-	}
-
-	// handle write to the database
-	infra, err = c.Repo().Infra().CreateInfra(infra)
-
-	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
-	}
-
-	vaultToken := ""
-
-	if c.Config().CredentialBackend != nil {
-		vaultToken, err = c.Config().CredentialBackend.CreateGCPToken(gcpInt)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	opts.GCR = &gcr.Conf{
-		GCPProjectID: gcpInt.GCPProjectID,
-	}
-	opts.CredentialExchange.VaultToken = vaultToken
-	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.Config().AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
-		&analytics.RegistryProvisioningStartTrackOpts{
-			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
-			RegistryType:           types.InfraGCR,
-			InfraID:                infra.ID,
-		},
-	))
-
-	c.WriteResult(w, r, infra.ToInfraType())
-}

+ 0 - 139
api/server/handlers/provision/provision_gke.go

@@ -1,139 +0,0 @@
-package provision
-
-import (
-	"encoding/json"
-	"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/analytics"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-type ProvisionGKEHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-func NewProvisionGKEHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ProvisionGKEHandler {
-	return &ProvisionGKEHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ProvisionGKEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// read the user and project from context
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	request := &types.CreateGKEInfraRequest{
-		ProjectID: proj.ID,
-	}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// get the GCP integration, to check that integration exists and belongs to the project
-	gcpInt, err := c.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
-
-	if err != nil {
-		if err == gorm.ErrRecordNotFound {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		}
-
-		return
-	}
-
-	suffix, err := repository.GenerateRandomBytes(6)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	lastApplied, err := json.Marshal(request)
-
-	// parse infra last applied into GKE config
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	infra := &models.Infra{
-		Kind:             types.InfraGKE,
-		ProjectID:        proj.ID,
-		Suffix:           suffix,
-		Status:           types.StatusCreating,
-		GCPIntegrationID: request.GCPIntegrationID,
-		CreatedByUserID:  user.ID,
-		LastApplied:      lastApplied,
-	}
-
-	// handle write to the database
-	infra, err = c.Repo().Infra().CreateInfra(infra)
-
-	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
-	}
-
-	vaultToken := ""
-
-	if c.Config().CredentialBackend != nil {
-		vaultToken, err = c.Config().CredentialBackend.CreateGCPToken(gcpInt)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-	opts.GKE = &gke.Conf{
-		GCPProjectID: gcpInt.GCPProjectID,
-		GCPRegion:    request.GCPRegion,
-		ClusterName:  request.GKEName,
-		IssuerEmail:  request.IssuerEmail,
-	}
-
-	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.Config().AnalyticsClient.Track(analytics.ClusterProvisioningStartTrack(
-		&analytics.ClusterProvisioningStartTrackOpts{
-			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
-			ClusterType:            types.InfraGKE,
-			InfraID:                infra.ID,
-		},
-	))
-
-	c.WriteResult(w, r, infra.ToInfraType())
-}

+ 2 - 2
api/server/handlers/release/create.go

@@ -14,13 +14,13 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
-	"github.com/porter-dev/porter/internal/repository"
 	"gopkg.in/yaml.v2"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -164,7 +164,7 @@ func createReleaseFromHelmRelease(
 	projectID, clusterID uint,
 	helmRelease *release.Release,
 ) (*models.Release, error) {
-	token, err := repository.GenerateRandomBytes(16)
+	token, err := encryption.GenerateRandomBytes(16)
 
 	if err != nil {
 		return nil, err

+ 1 - 1
api/server/handlers/release/get.go

@@ -123,7 +123,7 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	if res.Form == nil {
 		// for now just case by name
 		if res.Release.Chart.Name() == "cert-manager" {
-			formYAML, err := parser.FormYAMLFromBytes(parserDef, []byte(certManagerForm), "")
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, []byte(certManagerForm), "", "")
 
 			if err == nil {
 				res.Form = formYAML

+ 1 - 1
api/server/handlers/template/get.go

@@ -66,7 +66,7 @@ func (t *TemplateGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	for _, file := range chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
-			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data, "declared")
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data, "declared", "")
 
 			if err != nil {
 				break

+ 2 - 2
api/server/handlers/user/cli_login.go

@@ -12,8 +12,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
 )
 
 type CLILoginHandler struct {
@@ -64,7 +64,7 @@ func (c *CLILoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// generate 64 characters long authorization code
-	code, err := repository.GenerateRandomBytes(32)
+	code, err := encryption.GenerateRandomBytes(32)
 
 	if err != nil {
 		err = fmt.Errorf("CLI random code generation failed: %s", err.Error())

+ 12 - 12
api/server/handlers/user/create_test.go

@@ -27,8 +27,8 @@ func TestCreateUserSuccessful(t *testing.T) {
 
 	handler := user.NewUserCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -59,8 +59,8 @@ func TestCreateUserBadEmail(t *testing.T) {
 
 	handler := user.NewUserCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -84,8 +84,8 @@ func TestCreateUserMissingField(t *testing.T) {
 
 	handler := user.NewUserCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -113,8 +113,8 @@ func TestCreateUserSameEmail(t *testing.T) {
 
 	handler := user.NewUserCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -139,8 +139,8 @@ func TestFailingCreateUserMethod(t *testing.T) {
 
 	handler := user.NewUserCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -163,8 +163,8 @@ func TestFailingCreateSessionMethod(t *testing.T) {
 
 	handler := user.NewUserCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 1 - 1
api/server/handlers/user/current_test.go

@@ -18,7 +18,7 @@ func TestGetCurrentUserSuccessful(t *testing.T) {
 
 	handler := user.NewUserGetCurrentHandler(
 		config,
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 2 - 2
api/server/handlers/user/delete_test.go

@@ -26,7 +26,7 @@ func TestDeleteUserSuccessful(t *testing.T) {
 
 	handler := user.NewUserDeleteHandler(
 		config,
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -63,7 +63,7 @@ func TestFailingDeleteUserMethod(t *testing.T) {
 
 	handler := user.NewUserDeleteHandler(
 		config,
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 1 - 1
api/server/handlers/user/email_verify_test.go

@@ -89,7 +89,7 @@ func TestEmailVerifyFinalizeSuccessful(t *testing.T) {
 
 	handler := user.NewVerifyEmailFinalizeHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 12 - 12
api/server/handlers/user/login_test.go

@@ -28,8 +28,8 @@ func TestLoginUserSuccessful(t *testing.T) {
 
 	handler := user.NewUserLoginHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -61,8 +61,8 @@ func TestLoginUserIncorrectPassword(t *testing.T) {
 
 	handler := user.NewUserLoginHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -88,8 +88,8 @@ func TestLoginUserBadEmail(t *testing.T) {
 
 	handler := user.NewUserLoginHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -115,8 +115,8 @@ func TestLoginUserEmptyPassword(t *testing.T) {
 
 	handler := user.NewUserLoginHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -142,8 +142,8 @@ func TestLoginUserNotExist(t *testing.T) {
 
 	handler := user.NewUserLoginHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)
@@ -167,8 +167,8 @@ func TestLoginUserFailingReadUserByEmailMethod(t *testing.T) {
 
 	handler := user.NewUserLoginHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(config),
-		shared.NewDefaultResultWriter(config),
+		shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter),
+		shared.NewDefaultResultWriter(config.Logger, config.Alerter),
 	)
 
 	handler.ServeHTTP(rr, req)

+ 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{

+ 270 - 6
api/server/router/infra.go

@@ -1,6 +1,8 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -70,6 +72,7 @@ func getInfraRoutes(
 
 	listInfraHandler := infra.NewInfraListHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
@@ -107,35 +110,268 @@ func getInfraRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/infras/{infra_id}/logs -> infra.NewInfraStreamLogsHandler
-	streamLogsEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/infras/{infra_id}/retry_create -> infra.NewInfraRetryHandler
+	retryCreateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/retry_create",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	retryCreateHandler := infra.NewInfraRetryCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: retryCreateEndpoint,
+		Handler:  retryCreateHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/infras/{infra_id}/update -> infra.NewInfraUpdateHandler
+	updateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/update",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	updateHandler := infra.NewInfraUpdateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateEndpoint,
+		Handler:  updateHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/infras/{infra_id}/retry_delete -> infra.NewInfraRetryDeleteHandler
+	retryDeleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/retry_delete",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	retryDeleteHandler := infra.NewInfraRetryDeleteHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: retryDeleteEndpoint,
+		Handler:  retryDeleteHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/infras/{infra_id}/operations -> infra.NewInfraListOperationsHandler
+	listOperationsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/operations",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	listOperationsHandler := infra.NewInfraListOperationsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listOperationsEndpoint,
+		Handler:  listOperationsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/infras/{infra_id}/operations/{operation_id} -> infra.NewInfraGetOperationHandler
+	getOperationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/operations/{%s}", relPath, types.URLParamOperationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+				types.OperationScope,
+			},
+		},
+	)
+
+	getOperationHandler := infra.NewInfraGetOperationHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getOperationEndpoint,
+		Handler:  getOperationHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/infras/{infra_id}/operations/{operation_id}/state -> infra.NewInfraStreamStateHandler
+	streamStateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/logs",
+				RelativePath: fmt.Sprintf("%s/operations/{%s}/state", relPath, types.URLParamOperationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 				types.InfraScope,
+				types.OperationScope,
 			},
 			IsWebsocket: true,
 		},
 	)
 
-	streamLogsHandler := infra.NewInfraStreamLogsHandler(
+	streamStateHandler := infra.NewInfraStreamStateHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: streamStateEndpoint,
+		Handler:  streamStateHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/infras/{infra_id}/operations/{operation_id}/log_stream -> infra.NewInfraStreamLogHandler
+	streamLogEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/operations/{%s}/log_stream", relPath, types.URLParamOperationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+				types.OperationScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamLogHandler := infra.NewInfraStreamLogHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: streamLogEndpoint,
+		Handler:  streamLogHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/infras/{infra_id}/operations/{operation_id}/logs -> infra.NewInfraGetOperationLogsHandler
+	getOperationLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/operations/{%s}/logs", relPath, types.URLParamOperationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+				types.OperationScope,
+			},
+		},
+	)
+
+	getOperationLogsHandler := infra.NewInfraGetOperationLogsHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: streamLogsEndpoint,
-		Handler:  streamLogsHandler,
+		Endpoint: getOperationLogsEndpoint,
+		Handler:  getOperationLogsHandler,
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/infras/{infra_id}/logs -> infra.NewInfraStreamLogsHandler
+	// streamLogsEndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbGet,
+	// 		Method: types.HTTPVerbGet,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/logs",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 			types.InfraScope,
+	// 		},
+	// 		IsWebsocket: true,
+	// 	},
+	// )
+
+	// streamLogsHandler := infra.NewInfraStreamLogsHandler(
+	// 	config,
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: streamLogsEndpoint,
+	// 	Handler:  streamLogsHandler,
+	// 	Router:   r,
+	// })
+
 	// GET /api/projects/{project_id}/infras/{infra_id}/current -> infra.NewInfraGetHandler
 	getCurrentEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -192,6 +428,34 @@ func getInfraRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/infras/{infra_id}/state -> infra.NewInfraGetStateHandler
+	getStateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/state",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	getStateHandler := infra.NewInfraGetStateHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getStateEndpoint,
+		Handler:  getStateHandler,
+		Router:   r,
+	})
+
 	// DELETE /api/projects/{project_id}/infras/{infra_id} -> infra.NewInfraDeleteHandler
 	deleteEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 1
api/server/router/middleware/panic.go

@@ -22,7 +22,7 @@ func (pmw *PanicMiddleware) Middleware(next http.Handler) http.Handler {
 			err := recover()
 
 			if err != nil {
-				apierrors.HandleAPIError(pmw.config, w, r, apierrors.NewErrInternal(fmt.Errorf("%v", err)), true)
+				apierrors.HandleAPIError(pmw.config.Logger, pmw.config.Alerter, w, r, apierrors.NewErrInternal(fmt.Errorf("%v", err)), true)
 			}
 		}()
 

+ 4 - 2
api/server/router/middleware/usage.go

@@ -36,7 +36,8 @@ func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 
 		if err != nil {
 			apierrors.HandleAPIError(
-				b.config,
+				b.config.Logger,
+				b.config.Alerter,
 				w, r,
 				apierrors.NewErrInternal(err),
 				true,
@@ -54,7 +55,8 @@ func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
 			limit, curr := getMetricUsage(limit, currentUsage, b.metric)
 
 			apierrors.HandleAPIError(
-				b.config,
+				b.config.Logger,
+				b.config.Alerter,
 				w, r,
 				apierrors.NewErrPassThroughToClient(
 					fmt.Errorf(UsageErrFmt, b.metric, limit, curr),

+ 2 - 2
api/server/router/middleware/websocket.go

@@ -25,10 +25,10 @@ func (wm *WebsocketMiddleware) Middleware(next http.Handler) http.Handler {
 
 		if err != nil {
 			if errors.Is(err, websocket.UpgraderCheckOriginErr) {
-				apierrors.HandleAPIError(wm.config, w, r, apierrors.NewErrForbidden(err), true)
+				apierrors.HandleAPIError(wm.config.Logger, wm.config.Alerter, w, r, apierrors.NewErrForbidden(err), true)
 				return
 			} else {
-				apierrors.HandleAPIError(wm.config, w, r, apierrors.NewErrInternal(err), false)
+				apierrors.HandleAPIError(wm.config.Logger, wm.config.Alerter, w, r, apierrors.NewErrInternal(err), false)
 				return
 			}
 		}

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

@@ -56,14 +56,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 +104,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 +133,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 +223,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 +253,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 +283,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 +343,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,
 	})
 

+ 198 - 114
api/server/router/project.go

@@ -1,12 +1,14 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
+	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/handlers/project"
-	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -632,72 +634,14 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	//  POST /api/projects/{project_id}/provision/ecr -> provision.NewProvisionECRHandler
-	provisionECREndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/provision/ecr",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-		},
-	)
-
-	provisionECRHandler := provision.NewProvisionECRHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: provisionECREndpoint,
-		Handler:  provisionECRHandler,
-		Router:   r,
-	})
-
-	//  POST /api/projects/{project_id}/provision/eks -> provision.NewProvisionEKSHandler
-	provisionEKSEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/provision/eks",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-			CheckUsage:  true,
-			UsageMetric: types.Clusters,
-		},
-	)
-
-	provisionEKSHandler := provision.NewProvisionEKSHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: provisionEKSEndpoint,
-		Handler:  provisionEKSHandler,
-		Router:   r,
-	})
-
-	//  POST /api/projects/{project_id}/provision/docr -> provision.NewProvisionDOCRHandler
-	provisionDOCREndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/infras -> infra.NewInfraCreateHandler
+	createInfraEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/provision/docr",
+				RelativePath: relPath + "/infras",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -706,56 +650,53 @@ func getProjectRoutes(
 		},
 	)
 
-	provisionDOCRHandler := provision.NewProvisionDOCRHandler(
+	createInfraHandler := infra.NewInfraCreateHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: provisionDOCREndpoint,
-		Handler:  provisionDOCRHandler,
+		Endpoint: createInfraEndpoint,
+		Handler:  createInfraHandler,
 		Router:   r,
 	})
 
-	//  POST /api/projects/{project_id}/provision/doks -> provision.NewProvisionDOKSHandler
-	provisionDOKSEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/infras/templates -> infra.NewInfraGetHandler
+	getTemplatesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/provision/doks",
+				RelativePath: relPath + "/infras/templates",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 			},
-			CheckUsage:  true,
-			UsageMetric: types.Clusters,
 		},
 	)
 
-	provisionDOKSHandler := provision.NewProvisionDOKSHandler(
+	getTemplatesHandler := infra.NewInfraListTemplateHandler(
 		config,
-		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: provisionDOKSEndpoint,
-		Handler:  provisionDOKSHandler,
+		Endpoint: getTemplatesEndpoint,
+		Handler:  getTemplatesHandler,
 		Router:   r,
 	})
 
-	//  POST /api/projects/{project_id}/provision/gcr -> provision.NewProvisionGCRHandler
-	provisionGCREndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/infras/templates -> infra.NewInfraGetHandler
+	getTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/provision/gcr",
+				RelativePath: fmt.Sprintf("%s/infras/templates/{%s}/{%s}", relPath, types.URLParamTemplateName, types.URLParamTemplateVersion),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -764,47 +705,190 @@ func getProjectRoutes(
 		},
 	)
 
-	provisionGCRHandler := provision.NewProvisionGCRHandler(
+	getTemplateHandler := infra.NewInfraGetTemplateHandler(
 		config,
-		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: provisionGCREndpoint,
-		Handler:  provisionGCRHandler,
+		Endpoint: getTemplateEndpoint,
+		Handler:  getTemplateHandler,
 		Router:   r,
 	})
 
-	//  POST /api/projects/{project_id}/provision/gke -> provision.NewProvisionGKEHandler
-	provisionGKEEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/provision/gke",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-			CheckUsage:  true,
-			UsageMetric: types.Clusters,
-		},
-	)
-
-	provisionGKEHandler := provision.NewProvisionGKEHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: provisionGKEEndpoint,
-		Handler:  provisionGKEHandler,
-		Router:   r,
-	})
+	// //  POST /api/projects/{project_id}/provision/ecr -> provision.NewProvisionECRHandler
+	// provisionECREndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbCreate,
+	// 		Method: types.HTTPVerbPost,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/provision/ecr",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 		},
+	// 	},
+	// )
+
+	// provisionECRHandler := provision.NewProvisionECRHandler(
+	// 	config,
+	// 	factory.GetDecoderValidator(),
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: provisionECREndpoint,
+	// 	Handler:  provisionECRHandler,
+	// 	Router:   r,
+	// })
+
+	// //  POST /api/projects/{project_id}/provision/eks -> provision.NewProvisionEKSHandler
+	// provisionEKSEndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbCreate,
+	// 		Method: types.HTTPVerbPost,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/provision/eks",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 		},
+	// 		CheckUsage:  true,
+	// 		UsageMetric: types.Clusters,
+	// 	},
+	// )
+
+	// provisionEKSHandler := provision.NewProvisionEKSHandler(
+	// 	config,
+	// 	factory.GetDecoderValidator(),
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: provisionEKSEndpoint,
+	// 	Handler:  provisionEKSHandler,
+	// 	Router:   r,
+	// })
+
+	// //  POST /api/projects/{project_id}/provision/docr -> provision.NewProvisionDOCRHandler
+	// provisionDOCREndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbCreate,
+	// 		Method: types.HTTPVerbPost,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/provision/docr",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 		},
+	// 	},
+	// )
+
+	// provisionDOCRHandler := provision.NewProvisionDOCRHandler(
+	// 	config,
+	// 	factory.GetDecoderValidator(),
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: provisionDOCREndpoint,
+	// 	Handler:  provisionDOCRHandler,
+	// 	Router:   r,
+	// })
+
+	// //  POST /api/projects/{project_id}/provision/doks -> provision.NewProvisionDOKSHandler
+	// provisionDOKSEndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbCreate,
+	// 		Method: types.HTTPVerbPost,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/provision/doks",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 		},
+	// 		CheckUsage:  true,
+	// 		UsageMetric: types.Clusters,
+	// 	},
+	// )
+
+	// provisionDOKSHandler := provision.NewProvisionDOKSHandler(
+	// 	config,
+	// 	factory.GetDecoderValidator(),
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: provisionDOKSEndpoint,
+	// 	Handler:  provisionDOKSHandler,
+	// 	Router:   r,
+	// })
+
+	// //  POST /api/projects/{project_id}/provision/gcr -> provision.NewProvisionGCRHandler
+	// provisionGCREndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbCreate,
+	// 		Method: types.HTTPVerbPost,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/provision/gcr",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 		},
+	// 	},
+	// )
+
+	// provisionGCRHandler := provision.NewProvisionGCRHandler(
+	// 	config,
+	// 	factory.GetDecoderValidator(),
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: provisionGCREndpoint,
+	// 	Handler:  provisionGCRHandler,
+	// 	Router:   r,
+	// })
+
+	// //  POST /api/projects/{project_id}/provision/gke -> provision.NewProvisionGKEHandler
+	// provisionGKEEndpoint := factory.NewAPIEndpoint(
+	// 	&types.APIRequestMetadata{
+	// 		Verb:   types.APIVerbCreate,
+	// 		Method: types.HTTPVerbPost,
+	// 		Path: &types.Path{
+	// 			Parent:       basePath,
+	// 			RelativePath: relPath + "/provision/gke",
+	// 		},
+	// 		Scopes: []types.PermissionScope{
+	// 			types.UserScope,
+	// 			types.ProjectScope,
+	// 		},
+	// 		CheckUsage:  true,
+	// 		UsageMetric: types.Clusters,
+	// 	},
+	// )
+
+	// provisionGKEHandler := provision.NewProvisionGKEHandler(
+	// 	config,
+	// 	factory.GetDecoderValidator(),
+	// 	factory.GetResultWriter(),
+	// )
+
+	// routes = append(routes, &Route{
+	// 	Endpoint: provisionGKEEndpoint,
+	// 	Handler:  provisionGKEHandler,
+	// 	Router:   r,
+	// })
 
 	return routes, newPath
 }

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

@@ -183,6 +183,10 @@ func registerRoutes(config *config.Config, routes []*Route) {
 	// after authorization. Each subsequent http.Handler can lookup the infra in context.
 	infraFactory := authz.NewInfraScopedFactory(config)
 
+	// Create a new "operation-scoped" factory which will create a new operation-scoped request
+	// after authorization. Each subsequent http.Handler can lookup the operation in context.
+	operationFactory := authz.NewOperationScopedFactory(config)
+
 	// Create a new "release-scoped" factory which will create a new release-scoped request
 	// after authorization. Each subsequent http.Handler can lookup the release in context.
 	releaseFactory := authz.NewReleaseScopedFactory(config)
@@ -227,6 +231,8 @@ func registerRoutes(config *config.Config, routes []*Route) {
 				atomicGroup.Use(gitInstallationFactory.Middleware)
 			case types.InfraScope:
 				atomicGroup.Use(infraFactory.Middleware)
+			case types.OperationScope:
+				atomicGroup.Use(operationFactory.Middleware)
 			case types.ReleaseScope:
 				atomicGroup.Use(releaseFactory.Middleware)
 			}

+ 7 - 6
api/server/shared/apierrors/errors.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"strings"
 
-	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/logger"
 )
@@ -97,7 +97,8 @@ type ErrorOpts struct {
 }
 
 func HandleAPIError(
-	config *config.Config,
+	l *logger.Logger,
+	alerter alerter.Alerter,
 	w http.ResponseWriter,
 	r *http.Request,
 	err RequestError,
@@ -107,7 +108,7 @@ func HandleAPIError(
 	extErrorStr := err.ExternalError()
 
 	// log the internal error
-	event := config.Logger.Warn().
+	event := l.Warn().
 		Str("internal_error", err.InternalError()).
 		Str("external_error", extErrorStr)
 
@@ -117,11 +118,11 @@ func HandleAPIError(
 	event.Send()
 
 	// if the status code is internal server error, use alerter
-	if err.GetStatusCode() == http.StatusInternalServerError && config.Alerter != nil {
+	if err.GetStatusCode() == http.StatusInternalServerError && alerter != nil {
 		data["method"] = r.Method
 		data["url"] = r.URL.String()
 
-		config.Alerter.SendAlert(r.Context(), err, data)
+		alerter.SendAlert(r.Context(), err, data)
 	}
 
 	if writeErr {
@@ -140,7 +141,7 @@ func HandleAPIError(
 		writerErr := json.NewEncoder(w).Encode(resp)
 
 		if writerErr != nil {
-			event := config.Logger.Error().
+			event := l.Error().
 				Err(writerErr)
 
 			logger.AddLoggingContextScopes(r.Context(), event)

+ 1 - 1
api/server/shared/apitest/request.go

@@ -65,7 +65,7 @@ func (f *failingDecoderValidator) DecodeAndValidate(
 	r *http.Request,
 	v interface{},
 ) (ok bool) {
-	apierrors.HandleAPIError(f.config, w, r, apierrors.NewErrInternal(fmt.Errorf("fake error")), true)
+	apierrors.HandleAPIError(f.config.Logger, f.config.Alerter, w, r, apierrors.NewErrInternal(fmt.Errorf("fake error")), true)
 	return false
 }
 

+ 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"`

+ 2 - 2
api/server/shared/endpoints.go

@@ -23,8 +23,8 @@ type APIObjectEndpointFactory struct {
 }
 
 func NewAPIObjectEndpointFactory(conf *config.Config) APIEndpointFactory {
-	decoderValidator := NewDefaultRequestDecoderValidator(conf)
-	resultWriter := NewDefaultResultWriter(conf)
+	decoderValidator := NewDefaultRequestDecoderValidator(conf.Logger, conf.Alerter)
+	resultWriter := NewDefaultResultWriter(conf.Logger, conf.Alerter)
 
 	return &APIObjectEndpointFactory{
 		DecoderValidator: decoderValidator,

+ 9 - 6
api/server/shared/reader.go

@@ -5,8 +5,9 @@ import (
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
-	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/internal/logger"
 )
 
 type RequestDecoderValidator interface {
@@ -15,18 +16,20 @@ type RequestDecoderValidator interface {
 }
 
 type DefaultRequestDecoderValidator struct {
-	config    *config.Config
+	logger    *logger.Logger
+	alerter   alerter.Alerter
 	validator requestutils.Validator
 	decoder   requestutils.Decoder
 }
 
 func NewDefaultRequestDecoderValidator(
-	conf *config.Config,
+	logger *logger.Logger,
+	alerter alerter.Alerter,
 ) RequestDecoderValidator {
 	validator := requestutils.NewDefaultValidator()
 	decoder := requestutils.NewDefaultDecoder()
 
-	return &DefaultRequestDecoderValidator{conf, validator, decoder}
+	return &DefaultRequestDecoderValidator{logger, alerter, validator, decoder}
 }
 
 func (j *DefaultRequestDecoderValidator) DecodeAndValidate(
@@ -38,13 +41,13 @@ func (j *DefaultRequestDecoderValidator) DecodeAndValidate(
 
 	// decode the request parameters (body and query)
 	if requestErr = j.decoder.Decode(v, r); requestErr != nil {
-		apierrors.HandleAPIError(j.config, w, r, requestErr, true)
+		apierrors.HandleAPIError(j.logger, j.alerter, w, r, requestErr, true)
 		return false
 	}
 
 	// validate the request object
 	if requestErr = j.validator.Validate(v); requestErr != nil {
-		apierrors.HandleAPIError(j.config, w, r, requestErr, true)
+		apierrors.HandleAPIError(j.logger, j.alerter, w, r, requestErr, true)
 		return false
 	}
 

+ 4 - 0
api/server/shared/websocket/response_writer.go

@@ -46,6 +46,10 @@ func (w *WebsocketSafeReadWriter) ReadMessage() (messageType int, p []byte, err
 	return w.conn.ReadMessage()
 }
 
+func (w *WebsocketSafeReadWriter) Close() error {
+	return w.conn.Close()
+}
+
 type WebsocketResponseWriter struct {
 	conn       *websocket.Conn
 	safeWriter *WebsocketSafeReadWriter

+ 10 - 5
api/server/shared/writer.go

@@ -7,7 +7,8 @@ import (
 	"syscall"
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
-	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
+	"github.com/porter-dev/porter/internal/logger"
 )
 
 type ResultWriter interface {
@@ -17,11 +18,15 @@ type ResultWriter interface {
 // default generalizes response codes for common operations
 // (http.StatusOK, http.StatusCreated, etc)
 type DefaultResultWriter struct {
-	config *config.Config
+	logger  *logger.Logger
+	alerter alerter.Alerter
 }
 
-func NewDefaultResultWriter(conf *config.Config) ResultWriter {
-	return &DefaultResultWriter{conf}
+func NewDefaultResultWriter(
+	logger *logger.Logger,
+	alerter alerter.Alerter,
+) ResultWriter {
+	return &DefaultResultWriter{logger, alerter}
 }
 
 func (j *DefaultResultWriter) WriteResult(w http.ResponseWriter, r *http.Request, v interface{}) {
@@ -32,6 +37,6 @@ func (j *DefaultResultWriter) WriteResult(w http.ResponseWriter, r *http.Request
 		// the server was sending bytes.
 		return
 	} else if err != nil {
-		apierrors.HandleAPIError(j.config, w, r, apierrors.NewErrInternal(err), true)
+		apierrors.HandleAPIError(j.logger, j.alerter, w, r, apierrors.NewErrInternal(err), true)
 	}
 }

+ 22 - 0
api/types/database.go

@@ -0,0 +1,22 @@
+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"`
+	InstanceStatus    string `json:"instance_status"`
+	InstanceDBFamily  string `json:"instance_db_family"`
+	InstanceDBVersion string `json:"instance_db_version"`
+}
+
+type ListDatabaseResponse []*Database

+ 1 - 0
api/types/form.go

@@ -52,6 +52,7 @@ type FormYAML struct {
 	Icon                string     `yaml:"icon" json:"icon"`
 	HasSource           string     `yaml:"hasSource" json:"hasSource"`
 	IncludeHiddenFields string     `yaml:"includeHiddenFields,omitempty" json:"includeHiddenFields,omitempty"`
+	IsClusterScoped     bool       `yaml:"isClusterscoped" json:"isClusterScoped"`
 	Description         string     `yaml:"description" json:"description"`
 	Tags                []string   `yaml:"tags" json:"tags"`
 	Tabs                []*FormTab `yaml:"tabs" json:"tabs,omitempty"`

+ 73 - 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 {
@@ -37,6 +39,10 @@ type Infra struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
+	APIVersion    string `json:"api_version,omitempty"`
+	SourceLink    string `json:"source_link,omitempty"`
+	SourceVersion string `json:"source_version,omitempty"`
+
 	// The type of infra that was provisioned
 	Kind InfraKind `json:"kind"`
 
@@ -57,4 +63,71 @@ type Infra struct {
 	// this is a map[string]string since we marshal into env vars anyway, but
 	// eventually this config will be more complex.
 	LastApplied map[string]string `json:"last_applied"`
+
+	// LatestOperation is the last operation that was run against this infra, if
+	// one exists
+	LatestOperation *Operation `json:"latest_operation"`
+}
+
+type InfraCredentials struct {
+	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
+	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
+	DOIntegrationID  uint `json:"do_integration_id,omitempty"`
+}
+
+type CreateInfraRequest struct {
+	*InfraCredentials
+
+	Kind   string                 `json:"kind" form:"required"`
+	Values map[string]interface{} `json:"values" form:"required"`
+}
+
+type ListInfraRequest struct {
+	Version string `schema:"version"`
+}
+
+type DeleteInfraRequest struct {
+	*InfraCredentials
+}
+
+type RetryInfraRequest struct {
+	// Integration IDs are not required -- if they are passed in, they will override the
+	// existing integration IDs
+	*InfraCredentials
+
+	// Values are not required -- if they are not passed in, the values will be
+	// automatically populated from the previous operation
+	Values map[string]interface{} `json:"values"`
+}
+
+type OperationMeta struct {
+	LastUpdated time.Time `json:"last_updated"`
+	UID         string    `json:"id"`
+	InfraID     uint      `json:"infra_id"`
+	Type        string    `json:"type"`
+	Status      string    `json:"status"`
+	Errored     bool      `json:"errored"`
+	Error       string    `json:"error"`
+}
+
+type Operation struct {
+	*OperationMeta
+
+	LastApplied map[string]interface{} `json:"last_applied"`
+	Form        *FormYAML              `json:"form"`
+}
+
+type InfraTemplateMeta struct {
+	Icon               string `json:"icon"`
+	Description        string `json:"description"`
+	Name               string `json:"name"`
+	Version            string `json:"version"`
+	Kind               string `json:"kind"`
+	RequiredCredential string `json:"required_credential"`
+}
+
+type InfraTemplate struct {
+	*InfraTemplateMeta
+
+	Form *FormYAML `json:"form"`
 }

+ 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 - 2
api/types/policy.go

@@ -10,6 +10,7 @@ const (
 	InviteScope          PermissionScope = "invite"
 	HelmRepoScope        PermissionScope = "helm_repo"
 	InfraScope           PermissionScope = "infra"
+	OperationScope       PermissionScope = "operation"
 	GitInstallationScope PermissionScope = "git_installation"
 	NamespaceScope       PermissionScope = "namespace"
 	SettingsScope        PermissionScope = "settings"
@@ -43,8 +44,10 @@ var ScopeHeirarchy = ScopeTree{
 		RegistryScope:        {},
 		HelmRepoScope:        {},
 		GitInstallationScope: {},
-		InfraScope:           {},
-		SettingsScope:        {},
+		InfraScope: {
+			OperationScope: {},
+		},
+		SettingsScope: {},
 	},
 }
 

+ 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")
+	}
+}

+ 1 - 0
api/types/request.go

@@ -39,6 +39,7 @@ const (
 	URLParamHelmRepoID        URLParam = "helm_repo_id"
 	URLParamGitInstallationID URLParam = "git_installation_id"
 	URLParamInfraID           URLParam = "infra_id"
+	URLParamOperationID       URLParam = "operation_id"
 	URLParamInviteID          URLParam = "invite_id"
 	URLParamNamespace         URLParam = "namespace"
 	URLParamReleaseName       URLParam = "name"

+ 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) {

+ 0 - 17
cmd/app/main.go

@@ -9,8 +9,6 @@ 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"
 )
 
 // Version will be linked by an ldflag during build
@@ -35,21 +33,6 @@ func main() {
 		log.Fatal("Config loading failed: ", err)
 	}
 
-	if config.RedisConf.Enabled {
-		redis, err := adapter.NewRedisClient(config.RedisConf)
-
-		if err != nil {
-			config.Logger.Fatal().Err(err).Msg("redis connection failed")
-			return
-		}
-
-		provisioner.InitGlobalStream(redis)
-
-		errorChan := make(chan error)
-
-		go provisioner.GlobalStreamListener(redis, config.Repo, config.AnalyticsClient, errorChan)
-	}
-
 	appRouter := router.NewAPIRouter(config)
 
 	address := fmt.Sprintf(":%d", config.ServerConf.Port)

+ 113 - 0
cmd/provisioner/main.go

@@ -0,0 +1,113 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/provisioner/integrations/redis_stream"
+	"github.com/porter-dev/porter/provisioner/server/config"
+	"github.com/porter-dev/porter/provisioner/server/router"
+	"golang.org/x/net/http2"
+	"golang.org/x/net/http2/h2c"
+
+	"github.com/porter-dev/porter/provisioner/pb"
+	"google.golang.org/grpc"
+
+	pgrpc "github.com/porter-dev/porter/provisioner/server/grpc"
+)
+
+// Version will be linked by an ldflag during build
+var Version string = "dev-ce"
+
+func main() {
+	var versionFlag bool
+	flag.BoolVar(&versionFlag, "version", false, "print version and exit")
+	flag.Parse()
+
+	// Exit safely when version is used
+	if versionFlag {
+		fmt.Println(Version)
+		os.Exit(0)
+	}
+
+	envConf, err := config.FromEnv()
+
+	if err != nil {
+		log.Fatal("Environment loading failed: ", err)
+	}
+
+	config, err := config.GetConfig(envConf)
+
+	if err != nil {
+		log.Fatal("Config loading failed: ", err)
+	}
+
+	if config.RedisConf.Enabled {
+		redis, err := adapter.NewRedisClient(config.RedisConf)
+
+		if err != nil {
+			config.Logger.Fatal().Err(err).Msg("redis connection failed")
+			return
+		}
+
+		redis_stream.InitGlobalStream(redis)
+
+		errorChan := make(chan error)
+
+		go redis_stream.GlobalStreamListener(redis, config, config.Repo, nil, errorChan)
+	}
+
+	appRouter := router.NewAPIRouter(config)
+
+	// if config.RedisConf.Enabled {
+	// 	redis, err := adapter.NewRedisClient(config.RedisConf)
+
+	// 	if err != nil {
+	// 		config.Logger.Fatal().Err(err).Msg("redis connection failed")
+	// 		return
+	// 	}
+
+	// 	redis_stream.InitGlobalStream(redis)
+
+	// 	errorChan := make(chan error)
+
+	// 	go redis_stream.GlobalStreamListener(redis, config, config.Repo, config.AnalyticsClient, errorChan)
+	// }
+
+	address := fmt.Sprintf(":%d", config.ProvisionerConf.Port)
+
+	config.Logger.Info().Msgf("Starting server %v", address)
+
+	grpcServer := grpc.NewServer()
+	pb.RegisterProvisionerServer(grpcServer, pgrpc.NewProvisionerServer(config))
+
+	http2Server := &http2.Server{}
+	s := &http.Server{
+		Addr: address,
+		Handler: h2c.NewHandler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+			if request.ProtoMajor != 2 {
+				appRouter.ServeHTTP(writer, request)
+				return
+			}
+
+			if strings.Contains(request.Header.Get("Content-Type"), "application/grpc") {
+				grpcServer.ServeHTTP(writer, request)
+				return
+			}
+
+			appRouter.ServeHTTP(writer, request)
+		}), http2Server),
+		ReadTimeout:  config.ProvisionerConf.TimeoutRead,
+		WriteTimeout: config.ProvisionerConf.TimeoutWrite,
+		IdleTimeout:  config.ProvisionerConf.TimeoutIdle,
+	}
+
+	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+		config.Logger.Fatal().Err(err).Msg("Server startup failed")
+	}
+}

+ 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",

+ 13 - 0
dashboard/src/components/Description.tsx

@@ -0,0 +1,13 @@
+import styled from "styled-components";
+
+const Description = styled.div`
+  width: 100%;
+  color: white;
+  font-size: 13px;
+  color: #aaaabb;
+  margin: 20px 0 10px 0;
+  display: flex;
+  align-items: center;
+`;
+
+export default Description;

+ 1 - 10
dashboard/src/components/ExpandableResource.tsx

@@ -4,6 +4,7 @@ import { Context } from "shared/Context";
 import ResourceTab from "./ResourceTab";
 import SaveButton from "./SaveButton";
 import { baseApi } from "shared/baseApi";
+import { readableDate } from "shared/string_utils";
 
 type Props = {
   resource: any;
@@ -51,16 +52,6 @@ const ExpandableResource: React.FC<Props> = (props) => {
       .catch((err) => console.log(err));
   };
 
-  const 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}`;
-  };
-
   return (
     <ResourceTab
       label={resource.label}

+ 620 - 173
dashboard/src/components/ProvisionerStatus.tsx

@@ -1,38 +1,39 @@
 import { Steps } from "main/home/onboarding/types";
-import React, { useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import { integrationList } from "shared/common";
 
 import loading from "assets/loading.gif";
 
 import styled, { keyframes } from "styled-components";
+import { capitalize, readableDate } from "shared/string_utils";
+import {
+  Infrastructure,
+  KindMap,
+  Operation,
+  OperationStatus,
+  OperationType,
+  TFResourceState,
+  TFState,
+} from "shared/types";
+import api from "shared/api";
+import Placeholder from "./Placeholder";
+import Loading from "./Loading";
+import ExpandedOperation from "main/home/infrastructure/components/ExpandedOperation";
+import { Context } from "shared/Context";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+import Description from "./Description";
+import Heading from "./form-components/Heading";
+import PorterFormWrapper from "./porter-form/PorterFormWrapper";
+import SaveButton from "./SaveButton";
+import { ProgressPlugin } from "webpack";
 
 type Props = {
-  modules: TFModule[];
+  infras: Infrastructure[];
+  project_id: number;
+  setInfraStatus: (status: { hasError: boolean; description?: string }) => void;
+  auto_expanded?: boolean;
 };
 
-export interface TFModule {
-  id: number;
-  kind: string;
-  status: string;
-  created_at: string;
-  updated_at: string;
-  global_errors?: TFResourceError[];
-  got_desired: boolean;
-  // optional resources, if not created
-  resources?: TFResource[];
-}
-
-export interface TFResourceError {
-  errored_out?: boolean;
-  error_context?: string;
-}
-
-export interface TFResource {
-  addr: string;
-  provisioned: boolean;
-  errored: TFResourceError;
-}
-
 const nameMap: { [key: string]: string } = {
   eks: "Elastic Kubernetes Service (EKS)",
   ecr: "Elastic Container Registry (ECR)",
@@ -40,130 +41,562 @@ 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 }) => {
-  const renderStatus = (status: string) => {
-    if (status === "successful") {
-      return (
-        <StatusIcon successful={true}>
-          <i className="material-icons">done</i>
-        </StatusIcon>
-      );
-    } else if (status === "loading") {
-      return (
-        <StatusIcon>
-          <LoadingGif src={loading} />
-        </StatusIcon>
-      );
-    } else if (status === "error") {
-      return (
-        <StatusIcon>
-          <i className="material-icons">error_outline</i>
-        </StatusIcon>
-      );
+const ProvisionerStatus: React.FC<Props> = ({
+  infras,
+  project_id,
+  auto_expanded,
+  setInfraStatus,
+}) => {
+  const renderV1Infra = (infra: Infrastructure) => {
+    let errors: string[] = [];
+
+    if (infra.status == "destroyed" || infra.status == "deleted") {
+      errors.push("This infrastructure was destroyed.");
+    }
+
+    let error = null;
+
+    if (errors.length > 0) {
+      error = errors.map((error, index) => {
+        return <ExpandedError key={index}>{error}</ExpandedError>;
+      });
     }
+
+    return (
+      <StyledInfraObject key={infra.id}>
+        <InfraHeader is_clickable={!auto_expanded}>
+          <Flex>
+            {integrationList[infra.kind] && (
+              <Icon src={integrationList[infra.kind].icon} />
+            )}
+            {KindMap[infra.kind]?.provider_name}
+          </Flex>
+          <Timestamp>Started {readableDate(infra.created_at)}</Timestamp>
+        </InfraHeader>
+        <ErrorWrapper>{error}</ErrorWrapper>
+      </StyledInfraObject>
+    );
   };
 
-  const readableDate = (s: string) => {
-    const ts = new Date(s);
-    const date = ts.toLocaleDateString();
-    const time = ts.toLocaleTimeString([], {
-      hour: "numeric",
-      minute: "2-digit",
+  const updateInfraStatus = (infra: Infrastructure) => {
+    setInfraStatus({
+      hasError: infra.status === "errored",
     });
-    return `${time} on ${date}`;
   };
 
-  const renderModules = () => {
-    return modules.map((val) => {
-      const totalResources = val.resources?.length;
-      const provisionedResources = val.resources?.filter((resource) => {
-        return resource.provisioned;
-      }).length;
-
-      let errors: string[] = [];
+  const renderV2Infra = (infra: Infrastructure) => {
+    return (
+      <InfraObject
+        key={infra.id}
+        project_id={project_id}
+        infra={infra}
+        is_expanded={auto_expanded}
+        is_collapsible={!auto_expanded}
+        updateInfraStatus={updateInfraStatus}
+      />
+    );
+  };
 
-      if (val.status == "destroyed") {
-        errors.push("Note: this infrastructure was automatically destroyed.");
+  const renderInfras = () => {
+    return infras.map((infra) => {
+      if (infra.api_version == "" || infra.api_version == "v1") {
+        return renderV1Infra(infra);
       }
 
-      let hasError =
-        val.resources?.filter((resource) => {
-          if (resource.errored?.errored_out) {
-            errors.push(resource.errored?.error_context);
+      return renderV2Infra(infra);
+    });
+  };
+
+  return <StyledProvisionerStatus>{renderInfras()}</StyledProvisionerStatus>;
+};
+
+export default ProvisionerStatus;
+
+type InfraObjectProps = {
+  infra: Infrastructure;
+  project_id: number;
+  is_expanded: boolean;
+  is_collapsible: boolean;
+  updateInfraStatus: (infra: Infrastructure) => void;
+};
+
+const InfraObject: React.FC<InfraObjectProps> = ({
+  infra,
+  project_id,
+  is_expanded,
+  is_collapsible,
+  updateInfraStatus,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(is_expanded);
+  const [isInProgress, setIsInProgress] = useState(
+    infra.status == "creating" ||
+      infra.status == "updating" ||
+      infra.status == "deleting"
+  );
+  const [fullInfra, setFullInfra] = useState<Infrastructure>(null);
+  const [infraState, setInfraState] = useState<TFState>(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  useEffect(() => {
+    if ((isExpanded || isInProgress) && !fullInfra) {
+      refreshInfra();
+    }
+  }, [infra, project_id, isExpanded, isInProgress]);
+
+  useEffect(() => {
+    if ((isExpanded || isInProgress) && !infraState) {
+      refreshInfraState();
+    }
+  }, [infra, project_id, isExpanded, isInProgress]);
+
+  const refreshInfraState = () => {
+    api
+      .getInfraState(
+        "<token>",
+        {},
+        {
+          project_id: project_id,
+          infra_id: infra.id,
+        }
+      )
+      .then(({ data }) => {
+        setInfraState(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  const refreshInfra = () => {
+    setIsLoading(true);
+
+    api
+      .getInfraByID(
+        "<token>",
+        {},
+        {
+          project_id: project_id,
+          infra_id: infra.id,
+        }
+      )
+      .then(({ data }) => {
+        setFullInfra(data);
+        updateInfraStatus(data);
+
+        // re-query for the infra state
+        refreshInfraState();
+
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  const renderExpandedContentsCreated = () => {
+    return <OperationDetails infra={fullInfra} refreshInfra={refreshInfra} />;
+  };
+
+  const renderExpandedContents = () => {
+    if (!isExpanded) {
+      return null;
+    } else if (fullInfra) {
+      return renderExpandedContentsCreated();
+    }
+
+    return (
+      <ErrorWrapper>
+        <Placeholder>
+          <Loading />{" "}
+        </Placeholder>
+      </ErrorWrapper>
+    );
+  };
+
+  const renderTimestampSection = () => {
+    let timestampLabel = "Started at";
+
+    switch (infra.status) {
+      case "created":
+        timestampLabel = "Created at";
+        break;
+      case "deleted":
+        timestampLabel = "Deleted at";
+        break;
+      case "errored":
+        timestampLabel = "Errored at";
+        break;
+    }
+
+    return (
+      <Timestamp>
+        {timestampLabel} {readableDate(infra.updated_at)}
+      </Timestamp>
+    );
+  };
+
+  return (
+    <StyledInfraObject key={infra.id}>
+      <InfraHeader
+        is_clickable={is_collapsible}
+        onClick={() => {
+          if (is_collapsible) {
+            setIsExpanded((val) => {
+              setIsLoading(true);
+              return !val;
+            });
           }
+        }}
+      >
+        <Flex>
+          {integrationList[infra.kind] && (
+            <Icon src={integrationList[infra.kind].icon} />
+          )}
+          {KindMap[infra.kind]?.provider_name}
+        </Flex>
+        <Flex>
+          {renderTimestampSection()}
+          <ExpandIconContainer hidden={!is_collapsible}>
+            <i className="material-icons expand-icon">
+              {isExpanded ? "expand_less" : "expand_more"}
+            </i>
+          </ExpandIconContainer>
+        </Flex>
+      </InfraHeader>
+      {renderExpandedContents()}
+    </StyledInfraObject>
+  );
+};
 
-          return resource.errored?.errored_out;
-        }).length > 0;
+type OperationDetailsProps = {
+  infra: Infrastructure;
+  refreshInfra: () => void;
+};
 
-      if (val.global_errors) {
-        for (let globalErr of val.global_errors) {
-          errors.push(globalErr.error_context);
-          hasError = true;
+const OperationDetails: React.FunctionComponent<OperationDetailsProps> = ({
+  infra,
+  refreshInfra,
+}) => {
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [operation, setOperation] = useState<Operation>(null);
+  const [infraState, setInfraState] = useState<TFState>(null);
+  const [infraStateInitialized, setInfraStateInitialized] = useState(false);
+  const { currentProject, setCurrentError } = useContext(Context);
+  const [erroredResources, setErroredResources] = useState<TFResourceState[]>(
+    []
+  );
+  const [createdResources, setCreatedResources] = useState<TFResourceState[]>(
+    []
+  );
+  const [plannedResources, setPlannedResources] = useState<TFResourceState[]>(
+    []
+  );
+
+  const { newWebsocket, openWebsocket, closeWebsocket } = useWebsockets();
+
+  const parseOperationWebsocketEvent = (evt: MessageEvent) => {
+    let { status, resource_id, error } = JSON.parse(evt.data);
+
+    if (status == "OPERATION_COMPLETED") {
+      // if the operation is completed, call the completed handler
+      refreshInfra();
+    } else if (status && resource_id) {
+      // if the status and resource_id are defined, add this to the infra state
+      setInfraState((curr) => {
+        let currCopy: TFState = {
+          last_updated: curr.last_updated,
+          operation_id: curr.operation_id,
+          status: curr.status,
+          resources: { ...curr.resources },
+        };
+
+        if (currCopy.resources[resource_id] && status == "deleted") {
+          delete currCopy.resources[resource_id];
+        } else if (currCopy.resources[resource_id]) {
+          currCopy.resources[resource_id].status = status;
+          currCopy.resources[resource_id].error = error;
+        } else {
+          currCopy.resources[resource_id] = {
+            id: resource_id,
+            status: status,
+            error: error,
+          };
         }
-      }
 
-      // remove duplicate errors
-      errors = errors.filter(
-        (error, index, self) =>
-          index ===
-          self.findIndex((e) => {
-            if (e && error) {
-              return e === error || e.includes(error) || error.includes(e);
+        return currCopy;
+      });
+    }
+  };
+
+  const setupOperationWebsocket = (websocketID: string) => {
+    let apiPath = `/api/projects/${currentProject.id}/infras/${infra.id}/operations/${infra.latest_operation.id}/state`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log(`connected to websocket:`, websocketID);
+      },
+      onmessage: parseOperationWebsocketEvent,
+      onclose: () => {
+        console.log(`closing websocket:`, websocketID);
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketID);
+      },
+    };
+
+    newWebsocket(websocketID, apiPath, wsConfig);
+    openWebsocket(websocketID);
+  };
+
+  useEffect(() => {
+    // if the latest_operation is in progress, open a websocket
+    if (infraStateInitialized && infra.latest_operation.status === "starting") {
+      const websocketID = infra.latest_operation.id;
+
+      setupOperationWebsocket(websocketID);
+
+      return () => {
+        closeWebsocket(websocketID);
+      };
+    }
+  }, [infraStateInitialized]);
+
+  useEffect(() => {
+    api
+      .getInfraState(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          infra_id: infra.id,
+        }
+      )
+      .then(({ data }) => {
+        setInfraState(data);
+        setIsLoading(false);
+        setInfraStateInitialized(true);
+      })
+      .catch((err) => {
+        console.error(err);
+        setInfraState({
+          last_updated: "",
+          operation_id: infra.latest_operation.id,
+          status: "creating",
+          resources: {},
+        });
+        setInfraStateInitialized(true);
+      });
+  }, [currentProject, infra]);
+
+  useEffect(() => {
+    api
+      .getOperation(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          infra_id: infra.id,
+          operation_id: infra.latest_operation.id,
+        }
+      )
+      .then(({ data }) => {
+        setOperation(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        setHasError(true);
+        setCurrentError(err.response?.data?.error);
+        setIsLoading(false);
+      });
+  }, [currentProject, infra]);
+
+  useEffect(() => {
+    if (infraState && infraState.resources) {
+      setErroredResources(
+        Object.keys(infraState.resources)
+          .map((key) => {
+            if (
+              infraState.resources[key].error &&
+              infraState.resources[key].error != null
+            ) {
+              return infraState.resources[key];
             }
+
+            return null;
           })
+          .filter((val) => val)
       );
 
-      const width =
-        val.status == "created"
-          ? 100
-          : 100 * (provisionedResources / (totalResources * 1.0)) || 0;
+      setCreatedResources(
+        Object.keys(infraState.resources)
+          .map((key) => {
+            if (infraState.resources[key].status == "created") {
+              return infraState.resources[key];
+            }
 
-      let error = null;
+            return null;
+          })
+          .filter((val) => val)
+      );
 
-      if (hasError) {
-        error = errors.map((error, index) => {
-          return <ExpandedError key={index}>{error}</ExpandedError>;
-        });
-      }
-      let loadingFill;
-      let status;
-
-      if (hasError || val.status == "destroyed") {
-        loadingFill = <LoadingFill status="error" width={width + "%"} />;
-        status = renderStatus("error");
-      } else if (width == 100) {
-        loadingFill = <LoadingFill status="successful" width={width + "%"} />;
-        status = renderStatus("successful");
-      } else {
-        loadingFill = <LoadingFill status="loading" width={width + "%"} />;
-        status = renderStatus("loading");
-      }
+      setPlannedResources(
+        Object.keys(infraState.resources)
+          .map((key) => {
+            if (infraState.resources[key].status == "planned_create") {
+              return infraState.resources[key];
+            }
+
+            return null;
+          })
+          .filter((val) => val)
+      );
+    }
+  }, [infraState]);
+
+  if (isLoading || !infraState) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
 
+  if (hasError) {
+    return <Placeholder>Error</Placeholder>;
+  }
+
+  const getOperationDescription = (
+    type: OperationType,
+    status: OperationStatus,
+    time: string
+  ): string => {
+    switch (type) {
+      case "retry_create":
+      case "create":
+        if (status == "starting") {
+          return (
+            "Status: infrastructure creation in progress, started at " +
+            readableDate(time)
+          );
+        } else if (status == "completed") {
+          return (
+            "Status: infrastructure creation completed at " + readableDate(time)
+          );
+        } else if (status == "errored") {
+          return "Status: this infrastructure encountered an error while creating.";
+        }
+      case "update":
+        if (status == "starting") {
+          return (
+            "Status: infrastructure update in progress, started at " +
+            readableDate(time)
+          );
+        } else if (status == "completed") {
+          return (
+            "Status: infrastructure update completed at " + readableDate(time)
+          );
+        } else if (status == "errored") {
+          return "Status: this infrastructure encountered an error while updating.";
+        }
+      case "retry_delete":
+      case "delete":
+        if (status == "starting") {
+          return (
+            "Status: infrastructure deletion in progress, started at " +
+            readableDate(time)
+          );
+        } else if (status == "completed") {
+          return (
+            "Status: infrastructure deletion completed at " + readableDate(time)
+          );
+        } else if (status == "errored") {
+          return "Status: this infrastructure encountered an error while deleting.";
+        }
+    }
+  };
+
+  const renderLoadingBar = (
+    completedResourceCount: number,
+    plannedResourceCount: number
+  ) => {
+    let width = (100.0 * completedResourceCount) / plannedResourceCount;
+
+    let operationKind = "Created";
+
+    switch (infra.latest_operation.type) {
+      case "retry_create":
+      case "create":
+        operationKind = "Created";
+        break;
+      case "update":
+        operationKind = "Updated";
+        break;
+      case "retry_delete":
+      case "delete":
+        operationKind = "Deleted";
+    }
+
+    return (
+      <StatusContainer>
+        <LoadingBar>
+          <LoadingFill status="loading" width={width + "%"} />
+        </LoadingBar>
+        <ResourceNumber>{`${completedResourceCount} / ${plannedResourceCount} ${operationKind}`}</ResourceNumber>
+      </StatusContainer>
+    );
+  };
+
+  const renderErrorSection = () => {
+    if (erroredResources.length > 0) {
       return (
-        <InfraObject key={val.id}>
-          <InfraHeader>
-            <Flex>
-              {status}
-              {integrationList[val.kind] && (
-                <Icon src={integrationList[val.kind].icon} />
-              )}
-              {nameMap[val.kind]}
-            </Flex>
-            <Timestamp>Started {readableDate(val.created_at)}</Timestamp>
-          </InfraHeader>
-          <LoadingBar>{loadingFill}</LoadingBar>
-          <ErrorWrapper>{error}</ErrorWrapper>
-        </InfraObject>
+        <>
+          <Description>
+            Encountered the following errors while provisioning:
+          </Description>
+          <ErrorWrapper>
+            {erroredResources.map((resource, index) => {
+              return (
+                <ExpandedError key={index}>{resource.error}</ExpandedError>
+              );
+            })}
+          </ErrorWrapper>
+        </>
       );
-    });
+    }
   };
 
-  return <StyledProvisionerStatus>{renderModules()}</StyledProvisionerStatus>;
+  return (
+    <StyledCard>
+      {renderLoadingBar(
+        createdResources.length,
+        createdResources.length +
+          erroredResources.length +
+          plannedResources.length
+      )}
+      <Description>
+        {getOperationDescription(
+          operation.type,
+          operation.status,
+          operation.last_updated
+        )}
+      </Description>
+      {renderErrorSection()}
+    </StyledCard>
+  );
 };
 
-export default ProvisionerStatus;
+const StyledCard = styled.div`
+  padding: 12px 20px;
+  max-height: 300px;
+  overflow-y: auto;
+`;
 
 const Flex = styled.div`
   display: flex;
@@ -182,7 +615,6 @@ const Icon = styled.img`
 `;
 
 const ErrorWrapper = styled.div`
-  max-height: 150px;
   margin-top: 20px;
   overflow-y: auto;
   user-select: text;
@@ -200,6 +632,27 @@ const ExpandedError = styled.div`
   padding-bottom: 17px;
 `;
 
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const StatusText = styled.div`
+  font-size: 13px;
+  margin-left: 15px;
+  color: #aaaabb;
+  font-weight: 400;
+`;
+
+const ResourceNumber = styled.div`
+  font-size: 12px;
+  margin-left: 7px;
+  min-width: 100px;
+  text-align: right;
+  color: #aaaabb;
+`;
+
 const movingGradient = keyframes`
   0% {
       background-position: left bottom;
@@ -210,74 +663,68 @@ const movingGradient = keyframes`
   }
 `;
 
-const LoadingFill = styled.div<{ width: string; status: string }>`
-  width: ${(props) => props.width};
-  background: ${(props) =>
-    props.status === "successful"
-      ? "rgb(56, 168, 138)"
-      : props.status === "error"
-      ? "#fcba03"
-      : "linear-gradient(to right, #8ce1ff, #616FEE)"};
-  height: 100%;
-  background-size: 250% 100%;
-  animation: ${movingGradient} 2s infinite;
-  animation-timing-function: ease-in-out;
-  animation-direction: alternate;
+const StyledProvisionerStatus = styled.div`
+  margin-top: 25px;
 `;
 
-const StatusIcon = styled.div<{ successful?: boolean }>`
+const StyledInfraObject = styled.div`
+  background: #ffffff1a;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  margin-bottom: 10px;
+  position: relative;
+`;
+
+const InfraHeader = styled.div<{ is_clickable: boolean }>`
+  font-size: 13px;
+  font-weight: 500;
+  justify-content: space-between;
+  padding: 15px;
   display: flex;
   align-items: center;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #ffffff55;
-  max-width: 500px;
-  overflow: hidden;
-  text-overflow: ellipsis;
+  cursor: ${(props) => (props.is_clickable ? "pointer" : "default")};
+  height: 50px;
 
-  > i {
-    font-size: 18px;
-    margin-right: 10px;
-    float: left;
-    color: ${(props) => (props.successful ? "rgb(56, 168, 138)" : "#fcba03")};
+  :hover {
+    background: ${(props) => (props.is_clickable ? "#ffffff12" : "none")};
   }
-`;
 
-const LoadingGif = styled.img`
-  width: 15px;
-  height: 15px;
-  margin-right: 9px;
-  margin-bottom: 0px;
-`;
+  .expand-icon {
+    display: none;
+    color: #ffffff55;
+  }
 
-const StyledProvisionerStatus = styled.div`
-  margin-top: 25px;
+  :hover .expand-icon {
+    display: inline-block;
+  }
 `;
 
 const LoadingBar = styled.div`
-  width: calc(100% - 30px);
   background: #ffffff22;
-  border: 100px;
-  margin: 15px 15px 0;
-  height: 18px;
+  width: 100%;
+  height: 8px;
   overflow: hidden;
   border-radius: 100px;
 `;
 
-const InfraObject = styled.div`
-  background: #ffffff22;
-  padding: 15px 0 0;
-  border: 1px solid #aaaabb;
-  border-radius: 5px;
-  margin-bottom: 10px;
-  position: relative;
+const LoadingFill = styled.div<{ width: string; status: string }>`
+  width: ${(props) => props.width};
+  background: ${(props) =>
+    props.status === "successful"
+      ? "rgb(56, 168, 138)"
+      : props.status === "error"
+      ? "#fcba03"
+      : "linear-gradient(to right, #8ce1ff, #616FEE)"};
+  height: 100%;
+  background-size: 250% 100%;
+  animation: ${movingGradient} 2s infinite;
+  animation-timing-function: ease-in-out;
+  animation-direction: alternate;
 `;
 
-const InfraHeader = styled.div`
-  font-size: 13px;
-  font-weight: 500;
-  justify-content: space-between;
-  padding: 0 15px;
-  display: flex;
-  align-items: center;
+const ExpandIconContainer = styled.div<{ hidden: boolean }>`
+  width: 30px;
+  margin-left: 10px;
+  padding-top: 2px;
+  display: ${(props) => (props.hidden ? "none" : "inline")};
 `;

+ 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>
               ))}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů