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

Merge branch 'master' of github.com:porter-dev/porter into nico/expanded-chart-overhaul

jnfrati 4 лет назад
Родитель
Сommit
95ae95ea87
100 измененных файлов с 4227 добавлено и 2880 удалено
  1. 48 0
      .air.provisioner.toml
  2. 3 0
      Makefile
  3. 1 1
      api/server/authn/handler.go
  4. 2 2
      api/server/authz/cluster.go
  5. 3 3
      api/server/authz/git_installation.go
  6. 2 2
      api/server/authz/helm_repo.go
  7. 2 2
      api/server/authz/infra.go
  8. 2 2
      api/server/authz/invite.go
  9. 60 0
      api/server/authz/operation.go
  10. 6 3
      api/server/authz/policy.go
  11. 2 2
      api/server/authz/project.go
  12. 2 2
      api/server/authz/registry.go
  13. 3 3
      api/server/authz/release.go
  14. 54 0
      api/server/handlers/database/update.go
  15. 3 3
      api/server/handlers/handler.go
  16. 1 1
      api/server/handlers/helmrepo/get_chart.go
  17. 302 0
      api/server/handlers/infra/create.go
  18. 32 294
      api/server/handlers/infra/delete.go
  19. 584 0
      api/server/handlers/infra/forms.go
  20. 29 1
      api/server/handlers/infra/get.go
  21. 0 51
      api/server/handlers/infra/get_current.go
  22. 0 51
      api/server/handlers/infra/get_desired.go
  23. 52 0
      api/server/handlers/infra/get_operation.go
  24. 43 0
      api/server/handlers/infra/get_operation_logs.go
  25. 41 0
      api/server/handlers/infra/get_state.go
  26. 83 0
      api/server/handlers/infra/get_template.go
  27. 10 3
      api/server/handlers/infra/list.go
  28. 44 0
      api/server/handlers/infra/list_operations.go
  29. 100 0
      api/server/handlers/infra/list_templates.go
  30. 0 195
      api/server/handlers/infra/retry.go
  31. 127 0
      api/server/handlers/infra/retry_create.go
  32. 73 0
      api/server/handlers/infra/retry_delete.go
  33. 87 12
      api/server/handlers/infra/stream_logs.go
  34. 116 0
      api/server/handlers/infra/stream_state.go
  35. 126 0
      api/server/handlers/infra/update.go
  36. 9 9
      api/server/handlers/project/create_test.go
  37. 1 1
      api/server/handlers/project/get_test.go
  38. 2 2
      api/server/handlers/project/list_test.go
  39. 0 70
      api/server/handlers/provision/helpers.go
  40. 0 137
      api/server/handlers/provision/provision_docr.go
  41. 0 138
      api/server/handlers/provision/provision_doks.go
  42. 0 136
      api/server/handlers/provision/provision_ecr.go
  43. 0 138
      api/server/handlers/provision/provision_eks.go
  44. 0 135
      api/server/handlers/provision/provision_gcr.go
  45. 0 139
      api/server/handlers/provision/provision_gke.go
  46. 0 336
      api/server/handlers/provision/provision_rds.go
  47. 2 2
      api/server/handlers/release/create.go
  48. 1 1
      api/server/handlers/release/get.go
  49. 1 1
      api/server/handlers/template/get.go
  50. 2 2
      api/server/handlers/user/cli_login.go
  51. 12 12
      api/server/handlers/user/create_test.go
  52. 1 1
      api/server/handlers/user/current_test.go
  53. 2 2
      api/server/handlers/user/delete_test.go
  54. 1 1
      api/server/handlers/user/email_verify_test.go
  55. 12 12
      api/server/handlers/user/login_test.go
  56. 204 24
      api/server/router/infra.go
  57. 1 1
      api/server/router/middleware/panic.go
  58. 4 2
      api/server/router/middleware/usage.go
  59. 2 2
      api/server/router/middleware/websocket.go
  60. 1 32
      api/server/router/namespace.go
  61. 198 114
      api/server/router/project.go
  62. 6 0
      api/server/router/router.go
  63. 7 6
      api/server/shared/apierrors/errors.go
  64. 1 1
      api/server/shared/apitest/request.go
  65. 3 4
      api/server/shared/config/config.go
  66. 4 10
      api/server/shared/config/env/envconfs.go
  67. 12 19
      api/server/shared/config/loader/loader.go
  68. 4 3
      api/server/shared/config/metadata.go
  69. 2 2
      api/server/shared/endpoints.go
  70. 9 6
      api/server/shared/reader.go
  71. 10 5
      api/server/shared/writer.go
  72. 11 5
      api/types/database.go
  73. 1 0
      api/types/form.go
  74. 72 0
      api/types/infra.go
  75. 5 2
      api/types/policy.go
  76. 1 0
      api/types/project.go
  77. 2 184
      api/types/provision.go
  78. 1 0
      api/types/request.go
  79. 0 17
      cmd/app/main.go
  80. 1 1
      cmd/migrate/main.go
  81. 113 0
      cmd/provisioner/main.go
  82. 13 0
      dashboard/src/components/Description.tsx
  83. 1 10
      dashboard/src/components/ExpandableResource.tsx
  84. 382 0
      dashboard/src/components/MultiSaveButton.tsx
  85. 844 169
      dashboard/src/components/ProvisionerStatus.tsx
  86. 0 1
      dashboard/src/components/Table.tsx
  87. 90 0
      dashboard/src/components/expanded-object/Header.tsx
  88. 11 1
      dashboard/src/components/porter-form/PorterForm.tsx
  89. 28 9
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  90. 3 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  91. 1 0
      dashboard/src/components/porter-form/types.ts
  92. 12 1
      dashboard/src/main/home/Home.tsx
  93. 1 10
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  94. 52 9
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  95. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx
  96. 1 14
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx
  97. 0 302
      dashboard/src/main/home/cluster-dashboard/databases/CreateDatabaseForm.tsx
  98. 17 1
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  99. 6 0
      dashboard/src/main/home/cluster-dashboard/databases/mock_data.ts
  100. 0 4
      dashboard/src/main/home/cluster-dashboard/databases/routes.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

+ 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

+ 54 - 0
api/server/handlers/database/update.go

@@ -0,0 +1,54 @@
+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 DatabaseUpdateStatusHandler struct {
+	handlers.PorterHandlerReader
+}
+
+func NewDatabaseUpdateStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) *DatabaseUpdateStatusHandler {
+	return &DatabaseUpdateStatusHandler{
+		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (p *DatabaseUpdateStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project from context
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
+
+	req := &types.UpdateDatabaseStatusRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// read all clusters for this project
+	db, err := p.Repo().Database().ReadDatabaseByInfraID(proj.ID, infra.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	db.Status = req.Status
+
+	db, err = p.Repo().Database().UpdateDatabase(db)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

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

+ 1 - 1
api/server/handlers/helmrepo/get_chart.go

@@ -65,7 +65,7 @@ func (t *ChartGetHandler) 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

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

@@ -0,0 +1,302 @@
+package infra
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/mitchellh/mapstructure"
+	"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"
+	"gorm.io/gorm"
+
+	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
+	}
+
+	var cluster *models.Cluster
+	var err error
+
+	if req.ClusterID != 0 {
+		cluster, err = c.Repo().Cluster().ReadCluster(proj.ID, req.ClusterID)
+
+		if err != nil {
+			if err == gorm.ErrRecordNotFound {
+				c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("cluster with id %d not found in project %d", req.ClusterID, proj.ID),
+				))
+			} else {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			}
+
+			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,
+		ParentClusterID: req.ClusterID,
+	}
+
+	// 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
+	vals := req.Values
+
+	// if this is cluster-scoped and the kind is RDS, run the postrenderer
+	if req.ClusterID != 0 && req.Kind == "rds" {
+		var ok bool
+
+		pr := &InfraRDSPostrenderer{
+			config: c.Config(),
+		}
+
+		if vals, ok = pr.Run(w, r, &Opts{
+			Cluster: cluster,
+			Values:  req.Values,
+		}); !ok {
+			return
+		}
+	}
+
+	// handle write to the database
+	infra, err = c.Repo().Infra().CreateInfra(infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, err := c.Config().ProvisionerClient.Apply(context.Background(), proj.ID, infra.ID, &ptypes.ApplyBaseRequest{
+		Kind:          req.Kind,
+		Values:        vals,
+		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"
+}
+
+type InfraRDSPostrenderer struct {
+	config *config.Config
+}
+
+type Opts struct {
+	Cluster *models.Cluster
+	Values  map[string]interface{}
+}
+
+func (i *InfraRDSPostrenderer) Run(w http.ResponseWriter, r *http.Request, opts *Opts) (map[string]interface{}, bool) {
+	if opts.Cluster != nil {
+		proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+		values := opts.Values
+
+		// find the corresponding infra id
+		clusterInfra, err := i.config.Repo.Infra().ReadInfra(proj.ID, opts.Cluster.InfraID)
+
+		if err != nil {
+			apierrors.HandleAPIError(i.config.Logger, i.config.Alerter, w, r, apierrors.NewErrForbidden(fmt.Errorf("could not get cluster infra: %v", err)), true)
+			return nil, false
+		}
+
+		clusterInfraOperation, err := i.config.Repo.Infra().GetLatestOperation(clusterInfra)
+
+		// get the raw state for the cluster
+		rawState, err := i.config.ProvisionerClient.GetRawState(context.Background(), models.GetWorkspaceID(clusterInfra, clusterInfraOperation))
+
+		if err != nil {
+			apierrors.HandleAPIError(i.config.Logger, i.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+			return nil, false
+		}
+
+		vpcID, subnetIDs, err := getVPCFromEKSTFState(rawState)
+
+		if err != nil {
+			return values, false
+		}
+
+		// if the length of the subnets is not 3, return an error
+		if len(subnetIDs) < 3 {
+			apierrors.HandleAPIError(i.config.Logger, i.config.Alerter, w, r, apierrors.NewErrInternal(fmt.Errorf("invalid number of subnet IDs in VPC configuration")), true)
+			return nil, false
+		}
+
+		values["porter_cluster_vpc"] = vpcID
+		values["porter_cluster_subnet_1"] = subnetIDs[0]
+		values["porter_cluster_subnet_2"] = subnetIDs[1]
+		values["porter_cluster_subnet_3"] = subnetIDs[2]
+
+		return values, true
+	}
+
+	return opts.Values, true
+}
+
+type AWSVPCConfig struct {
+	SubNetIDs []string `json:"subnet_ids" mapstructure:"subnet_ids"`
+	VPCID     string   `json:"vpc_id" mapstructure:"vpc_id"`
+}
+
+func getVPCFromEKSTFState(tfState *ptypes.ParseableRawTFState) (string, []string, error) {
+	for _, resource := range tfState.Resources {
+		if "aws_eks_cluster.cluster" == resource.Type+"."+resource.Name {
+			for _, instance := range resource.Instances {
+				vpcConfig, ok := instance.Attributes["vpc_config"]
+				if !ok {
+					return "", []string{}, errors.New("name not found for the requested resource name-type")
+				}
+
+				awsVPCConfigIface, ok := vpcConfig.([]interface{})
+				if !ok {
+					fmt.Printf("%#v\n", vpcConfig)
+					return "", []string{}, errors.New("cannot cast returned value to vpc config")
+				}
+
+				if len(awsVPCConfigIface) == 0 {
+					return "", []string{}, errors.New("empty vpc config")
+				}
+
+				awsVPCConfigMap, ok := awsVPCConfigIface[0].(map[string]interface{})
+				if !ok {
+					return "", []string{}, errors.New("cannot cast returned value to vpc config map")
+				}
+
+				var awsVPCConfig AWSVPCConfig
+
+				err := mapstructure.Decode(awsVPCConfigMap, &awsVPCConfig)
+				if err != nil {
+					return "", []string{}, errors.New("cannot cast returned value to vpc config")
+				}
+
+				return awsVPCConfig.VPCID, awsVPCConfig.SubNetIDs, nil
+			}
+
+			return "", []string{}, errors.New("name not found for the requested resource name-type")
+		}
+	}
+
+	return "", []string{}, errors.New("name not found for the requested resource name-type")
+}

+ 32 - 294
api/server/handlers/infra/delete.go

@@ -1,24 +1,18 @@
 package infra
 
 import (
-	"encoding/json"
+	"context"
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/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/aws/rds"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 	"github.com/porter-dev/porter/internal/models"
+
+	ptypes "github.com/porter-dev/porter/provisioner/types"
 )
 
 type InfraDeleteHandler struct {
@@ -36,315 +30,59 @@ 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)
 
-	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)
+	req := &types.DeleteInfraRequest{}
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
 		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)
-	case types.InfraRDS:
-		err = destroyRDS(c.Config(), 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
 	}
-}
-
-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 destroyRDS(conf *config.Config, infra *models.Infra) error {
-	// find the database and mark as deleting
-	database, err := conf.Repo.Database().ReadDatabaseByInfraID(infra.ProjectID, infra.ID)
-
-	if err != nil {
-		return err
-	}
-
-	database.Status = "destroying"
-
-	database, err = conf.Repo.Database().UpdateDatabase(database)
-
-	if err != nil {
-		return err
-	}
-
-	lastAppliedRDS := &types.RDSInfraLastApplied{}
-
-	// parse infra last applied into EKS config
-	if err := json.Unmarshal(infra.LastApplied, lastAppliedRDS); err != nil {
-		return err
-	}
-
-	awsInt, err := conf.Repo.AWSIntegration().ReadAWSIntegration(infra.ProjectID, infra.AWSIntegrationID)
-
-	if err != nil {
-		return err
-	}
-
-	opts, err := provision.GetSharedProvisionerOpts(conf, infra)
-
-	vaultToken := ""
-
-	if conf.CredentialBackend != nil {
-		vaultToken, err = conf.CredentialBackend.CreateAWSToken(awsInt)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	opts.CredentialExchange.VaultToken = vaultToken
-
-	opts.RDS = &rds.Conf{
-		AWSRegion:             awsInt.AWSRegion,
-		DBName:                lastAppliedRDS.DBName,
-		MachineType:           lastAppliedRDS.MachineType,
-		DBEngineVersion:       lastAppliedRDS.DBEngineVersion,
-		DBFamily:              lastAppliedRDS.DBFamily,
-		DBMajorEngineVersion:  lastAppliedRDS.DBMajorEngineVersion,
-		DBAllocatedStorage:    lastAppliedRDS.DBStorage,
-		DBMaxAllocatedStorage: lastAppliedRDS.DBMaxStorage,
-		DBStorageEncrypted:    lastAppliedRDS.DBStorageEncrypted,
-		Username:              lastAppliedRDS.Username,
-		Password:              lastAppliedRDS.Password,
-		VPCID:                 lastAppliedRDS.VPCID,
-		Subnet1:               lastAppliedRDS.Subnet1,
-		Subnet2:               lastAppliedRDS.Subnet2,
-		Subnet3:               lastAppliedRDS.Subnet3,
-		DeletionProtection:    lastAppliedRDS.DeletionProtection,
-	}
-
-	opts.OperationKind = provisioner.Destroy
-
-	err = conf.ProvisionerAgent.Provision(opts)
 
-	return err
-}
-
-func destroyDOCR(conf *config.Config, infra *models.Infra) error {
-	lastAppliedDOCR := &types.CreateDOCRInfraRequest{}
-
-	// 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)
+	lastOperation, err := c.Repo().Infra().GetLatestOperation(infra)
 
 	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
-		}
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
-	opts.CredentialExchange.VaultToken = vaultToken
+	// if the last operation is in a "starting" state, block apply
+	if lastOperation.Status == "starting" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("Operation currently in progress. Please try again when latest operation has completed."),
+			http.StatusBadRequest,
+		))
 
-	opts.DOCR = &docr.Conf{
-		DOCRName:             lastAppliedDOCR.DOCRName,
-		DOCRSubscriptionTier: lastAppliedDOCR.DOCRSubscriptionTier,
+		return
 	}
 
-	opts.OperationKind = provisioner.Destroy
-
-	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
-	}
+	// mark the infra as destroying
+	infra.Status = types.StatusDestroying
 
-	doInt, err := conf.Repo.OAuthIntegration().ReadOAuthIntegration(infra.ProjectID, infra.DOIntegrationID)
+	infra, err = c.Repo().Infra().UpdateInfra(infra)
 
 	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
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
-	gcpInt, err := conf.Repo.GCPIntegration().ReadGCPIntegration(infra.ProjectID, infra.GCPIntegrationID)
+	// call apply on the provisioner service
+	resp, err := c.Config().ProvisionerClient.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.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)
 }

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

@@ -0,0 +1,584 @@
+package infra
+
+const testForm = `name: Test
+hasSource: false
+includeHiddenFields: true
+isClusterScoped: 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
+`
+
+const rdsForm = `name: RDS
+hasSource: false
+includeHiddenFields: true
+isClusterScoped: true
+tabs:
+- name: main
+  label: Main
+  sections:
+  - name: heading
+    contents: 
+    - type: heading
+      label: Database Settings
+  - name: user
+    contents:
+    - type: string-input
+      label: Database Master User
+      required: true
+      placeholder: "admin"
+      variable: db_user
+  - name: password
+    contents:
+    - type: string-input
+      required: true
+      label: Database Master Password
+      variable: db_passwd
+  - name: name
+    contents:
+    - type: string-input
+      label: Database Name
+      required: true
+      placeholder: "rds-staging"
+      variable: db_name
+  - name: machine-type
+    contents:
+    - type: select
+      label: ⚙️ Database Machine Type
+      variable: machine_type
+      settings:
+        default: db.t3.medium
+        options:
+        - label: db.t2.medium
+          value: db.t2.medium
+        - label: db.t2.xlarge
+          value: db.t2.xlarge
+        - label: db.t2.2xlarge
+          value: db.t2.2xlarge
+        - label: db.t3.medium
+          value: db.t3.medium
+        - label: db.t3.xlarge
+          value: db.t3.xlarge
+        - label: db.t3.2xlarge
+          value: db.t3.2xlarge
+  - name: family-versions
+    contents:
+    - type: select
+      label:  Database Family Version
+      variable: db_family
+      settings:
+        default: postgres13
+        options:
+        - label: "Postgres 9"
+          value: postgres9
+        - label: "Postgres 10"
+          value: postgres10
+        - label: "Postgres 11"
+          value: postgres11
+        - label: "Postgres 12"
+          value: postgres12
+        - label: "Postgres 13"
+          value: postgres13
+  - name: pg-9-versions
+    show_if: 
+      is: "postgres9"
+      variable: db_family
+    contents:
+    - type: select
+      label:  Database Version
+      variable: db_engine_version
+      settings:
+        default: "9.6.23"
+        options:
+        - label: "v9.6.1"
+          value: "9.6.1"
+        - label: "v9.6.2"
+          value: "9.6.2"
+        - label: "v9.6.3"
+          value: "9.6.3"
+        - label: "v9.6.4"
+          value: "9.6.4"
+        - label: "v9.6.5"
+          value: "9.6.5"
+        - label: "v9.6.6"
+          value: "9.6.6"
+        - label: "v9.6.7"
+          value: "9.6.7"
+        - label: "v9.6.8"
+          value: "9.6.8"
+        - label: "v9.6.10"
+          value: "9.6.10"
+        - label: "v9.6.11"
+          value: "9.6.11"
+        - label: "v9.6.12"
+          value: "9.6.12"
+        - label: "v9.6.13"
+          value: "9.6.13"
+        - label: "v9.6.14"
+          value: "9.6.14"
+        - label: "v9.6.15"
+          value: "9.6.15"
+        - label: "v9.6.16"
+          value: "9.6.16"
+        - label: "v9.6.17"
+          value: "9.6.17"
+        - label: "v9.6.18"
+          value: "9.6.18"
+        - label: "v9.6.19"
+          value: "9.6.19"
+        - label: "v9.6.20"
+          value: "9.6.20"
+        - label: "v9.6.21"
+          value: "9.6.21"
+        - label: "v9.6.22"
+          value: "9.6.22"
+        - label: "v9.6.23"
+          value: "9.6.23"
+  - name: pg-10-versions
+    show_if: 
+      is: "postgres10"
+      variable: db_family
+    contents:
+    - type: select
+      label:  Database Version
+      variable: db_engine_version
+      settings:
+        default: "10.18"
+        options:
+        - label: "v10.1"
+          value: "10.1"
+        - label: "v10.2"
+          value: "10.2"
+        - label: "v10.3"
+          value: "10.3"
+        - label: "v10.4"
+          value: "10.4"
+        - label: "v10.5"
+          value: "10.5"
+        - label: "v10.6"
+          value: "10.6"
+        - label: "v10.7"
+          value: "10.7"
+        - label: "v10.8"
+          value: "10.8"
+        - label: "v10.9"
+          value: "10.9"
+        - label: "v10.10"
+          value: "10.10"
+        - label: "v10.11"
+          value: "10.11"
+        - label: "v10.12"
+          value: "10.12"
+        - label: "v10.13"
+          value: "10.13"
+        - label: "v10.14"
+          value: "10.14"
+        - label: "v10.15"
+          value: "10.15"
+        - label: "v10.16"
+          value: "10.16"
+        - label: "v10.17"
+          value: "10.17"
+        - label: "v10.18"
+          value: "10.18"
+  - name: pg-11-versions
+    show_if: 
+      is: "postgres11"
+      variable: db_family
+    contents:
+    - type: select
+      label:  Database Version
+      variable: db_engine_version
+      settings:
+        default: "11.13"
+        options:
+        - label: "v11.1"
+          value: "11.1"
+        - label: "v11.2"
+          value: "11.2"
+        - label: "v11.3"
+          value: "11.3"
+        - label: "v11.4"
+          value: "11.4"
+        - label: "v11.5"
+          value: "11.5"
+        - label: "v11.6"
+          value: "11.6"
+        - label: "v11.7"
+          value: "11.7"
+        - label: "v11.8"
+          value: "11.8"
+        - label: "v11.9"
+          value: "11.9"
+        - label: "v11.10"
+          value: "11.10"
+        - label: "v11.11"
+          value: "11.11"
+        - label: "v11.12"
+          value: "11.12"
+        - label: "v11.13"
+          value: "11.13"
+  - name: pg-12-versions
+    show_if: 
+      is: "postgres12"
+      variable: db_family
+    contents:
+    - type: select
+      label:  Database Version
+      variable: db_engine_version
+      settings:
+        default: "12.8"
+        options:
+        - label: "v12.2"
+          value: "12.2"
+        - label: "v12.3"
+          value: "12.3"
+        - label: "v12.4"
+          value: "12.4"
+        - label: "v12.5"
+          value: "12.5"
+        - label: "v12.6"
+          value: "12.6"
+        - label: "v12.7"
+          value: "12.7"
+        - label: "v12.8"
+          value: "12.8"
+  - name: pg-13-versions
+    show_if: 
+      is: "postgres13"
+      variable: db_family
+    contents:
+    - type: select
+      label:  Database Version
+      variable: db_engine_version
+      settings:
+        default: "13.4"
+        options:
+        - label: "v13.1"
+          value: "13.1"
+        - label: "v13.2"
+          value: "13.2"
+        - label: "v13.3"
+          value: "13.3"
+        - label: "v13.4"
+          value: "13.4"
+  - name: additional-settings
+    contents:
+    - type: heading
+      label: Additional Settings
+    - type: checkbox
+      variable: db_deletion_protection
+      label: Enable deletion protection for the database.
+      settings:
+        default: false
+- name: storage
+  label: Storage
+  sections:
+  - name: storage
+    contents:
+    - type: heading
+      label: Storage Settings
+    - type: number-input
+      label: Gigabytes
+      variable: db_allocated_storage
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+    - type: number-input
+      label: Gigabytes
+      variable: db_max_allocated_storage
+      placeholder: "ex: 20"
+      settings:
+        default: 20
+    - type: checkbox
+      variable: db_storage_encrypted
+      label: Enable storage encryption for the database. 
+      settings:
+        default: false`
+
+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
+`

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

@@ -1,13 +1,17 @@
 package infra
 
 import (
+	"errors"
+	"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/models"
+	"gorm.io/gorm"
 )
 
 type InfraGetHandler struct {
@@ -26,5 +30,29 @@ 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 {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("latest operation not found")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	op, err := operation.ToOperationType()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res.LatestOperation = op
+
+	c.WriteResult(w, r, res)
 }

+ 0 - 51
api/server/handlers/infra/get_current.go

@@ -1,51 +0,0 @@
-package infra
-
-import (
-	"errors"
-	"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/ee/integrations/httpbackend"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-type InfraGetCurrentHandler struct {
-	handlers.PorterHandlerWriter
-}
-
-func NewInfraGetCurrentHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) *InfraGetCurrentHandler {
-	return &InfraGetCurrentHandler{
-		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
-	}
-}
-
-func (c *InfraGetCurrentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
-
-	// TODO: move client out of this call
-	client := httpbackend.NewClient(c.Config().ServerConf.ProvisionerBackendURL)
-
-	// get the unique infra name and query from the TF HTTP backend
-	current, err := client.GetCurrentState(infra.GetUniqueName())
-
-	if err != nil && errors.Is(err, httpbackend.ErrNotFound) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			err,
-			http.StatusNotFound,
-		))
-
-		return
-	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	c.WriteResult(w, r, current)
-}

+ 0 - 51
api/server/handlers/infra/get_desired.go

@@ -1,51 +0,0 @@
-package infra
-
-import (
-	"errors"
-	"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/ee/integrations/httpbackend"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-type InfraGetDesiredHandler struct {
-	handlers.PorterHandlerWriter
-}
-
-func NewInfraGetDesiredHandler(
-	config *config.Config,
-	writer shared.ResultWriter,
-) *InfraGetDesiredHandler {
-	return &InfraGetDesiredHandler{
-		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
-	}
-}
-
-func (c *InfraGetDesiredHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	infra, _ := r.Context().Value(types.InfraScope).(*models.Infra)
-
-	// TODO: move client out of this call
-	client := httpbackend.NewClient(c.Config().ServerConf.ProvisionerBackendURL)
-
-	// get the unique infra name and query from the TF HTTP backend
-	desired, err := client.GetDesiredState(infra.GetUniqueName())
-
-	if err != nil && errors.Is(err, httpbackend.ErrNotFound) {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			err,
-			http.StatusNotFound,
-		))
-
-		return
-	} else if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	c.WriteResult(w, r, desired)
-}

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

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

@@ -0,0 +1,43 @@
+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"
+)
+
+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
+	resp, err := c.Config().ProvisionerClient.GetLogs(context.Background(), workspaceID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

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

@@ -0,0 +1,41 @@
+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"
+)
+
+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
+	resp, err := c.Config().ProvisionerClient.GetState(context.Background(), proj.ID, infra.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

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

@@ -0,0 +1,83 @@
+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 "rds":
+		formBytes = []byte(rdsForm)
+	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)
+}

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

@@ -0,0 +1,100 @@
+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",
+	},
+	"rds": {
+		Icon:               "",
+		Description:        "Create a Relational Database Service instance.",
+		Name:               "RDS",
+		Version:            "v0.1.0",
+		Kind:               "rds",
+		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",
+	},
+}

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

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

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

@@ -0,0 +1,127 @@
+package infra
+
+import (
+	"context"
+	"encoding/json"
+	"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/models"
+	ptypes "github.com/porter-dev/porter/provisioner/types"
+	"gorm.io/gorm"
+)
+
+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
+	}
+
+	var cluster *models.Cluster
+	var err error
+
+	if infra.ParentClusterID != 0 {
+		cluster, err = c.Repo().Cluster().ReadCluster(proj.ID, infra.ParentClusterID)
+
+		if err != nil {
+			if err == gorm.ErrRecordNotFound {
+				c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("cluster with id %d not found in project %d", infra.ParentClusterID, proj.ID),
+				))
+			} else {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			}
+
+			return
+		}
+	}
+
+	// verify the credentials
+	err = checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	lastOperation, err := c.Repo().Infra().GetLatestOperation(infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the last operation is in a "starting" state, block apply
+	if lastOperation.Status == "starting" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("Operation currently in progress. Please try again when latest operation has completed."),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	// if the values are nil, get the last applied values and marshal them
+	if req.Values == nil || len(req.Values) == 0 {
+
+		rawValues := lastOperation.LastApplied
+
+		err = json.Unmarshal(rawValues, &req.Values)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	vals := req.Values
+
+	// if this is cluster-scoped and the kind is RDS, run the postrenderer
+	if infra.ParentClusterID != 0 && infra.Kind == "rds" {
+		var ok bool
+
+		pr := &InfraRDSPostrenderer{
+			config: c.Config(),
+		}
+
+		if vals, ok = pr.Run(w, r, &Opts{
+			Cluster: cluster,
+			Values:  vals,
+		}); !ok {
+			return
+		}
+	}
+
+	// call apply on the provisioner service
+	resp, err := c.Config().ProvisionerClient.Apply(context.Background(), proj.ID, infra.ID, &ptypes.ApplyBaseRequest{
+		Kind:          string(infra.Kind),
+		Values:        vals,
+		OperationKind: "retry_create",
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

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

@@ -0,0 +1,73 @@
+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/models"
+	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
+	}
+
+	lastOperation, err := c.Repo().Infra().GetLatestOperation(infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the last operation is in a "starting" state, block apply
+	if lastOperation.Status == "starting" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("Operation currently in progress. Please try again when latest operation has completed."),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	// call apply on the provisioner service
+	resp, err := c.Config().ProvisionerClient.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)
+}

+ 87 - 12
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,109 @@ 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/models"
-	"github.com/porter-dev/porter/internal/redis_stream"
+	"github.com/porter-dev/porter/provisioner/pb"
 )
 
-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)
+	ctx, cancel := c.Config().ProvisionerClient.NewGRPCContext(workspaceID)
+
+	defer cancel()
+
+	stream, err := c.Config().ProvisionerClient.GRPCClient.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
 	}
 
-	err = redis_stream.ResourceStream(client, infra.GetUniqueName(), safeRW)
+	errorchan := make(chan error)
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+	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()
 	}
 }

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

@@ -0,0 +1,116 @@
+package infra
+
+import (
+	"context"
+	"errors"
+	"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"
+)
+
+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)
+
+	ctx, cancel := c.Config().ProvisionerClient.NewGRPCContext(workspaceID)
+
+	defer cancel()
+
+	stream, err := c.Config().ProvisionerClient.GRPCClient.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
+				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
+				}
+
+				return
+			}
+
+			err = safeRW.WriteJSON(stateUpdate)
+
+			if err != nil {
+				errorchan <- err
+			}
+		}
+	}()
+
+	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()
+	}
+}

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

@@ -0,0 +1,126 @@
+package infra
+
+import (
+	"context"
+	"encoding/json"
+	"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/models"
+	ptypes "github.com/porter-dev/porter/provisioner/types"
+	"gorm.io/gorm"
+)
+
+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
+	}
+
+	var cluster *models.Cluster
+	var err error
+
+	if infra.ParentClusterID != 0 {
+		cluster, err = c.Repo().Cluster().ReadCluster(proj.ID, infra.ParentClusterID)
+
+		if err != nil {
+			if err == gorm.ErrRecordNotFound {
+				c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("cluster with id %d not found in project %d", infra.ParentClusterID, proj.ID),
+				))
+			} else {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			}
+
+			return
+		}
+	}
+
+	// verify the credentials
+	err = checkInfraCredentials(c.Config(), proj, infra, req.InfraCredentials)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	lastOperation, err := c.Repo().Infra().GetLatestOperation(infra)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the last operation is in a "starting" state, block apply
+	if lastOperation.Status == "starting" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("Operation currently in progress. Please try again when latest operation has completed."),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	// if the values are nil, get the last applied values and marshal them
+	if req.Values == nil || len(req.Values) == 0 {
+		rawValues := lastOperation.LastApplied
+
+		err = json.Unmarshal(rawValues, &req.Values)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	vals := req.Values
+
+	// if this is cluster-scoped and the kind is RDS, run the postrenderer
+	if infra.ParentClusterID != 0 && infra.Kind == "rds" {
+		var ok bool
+
+		pr := &InfraRDSPostrenderer{
+			config: c.Config(),
+		}
+
+		if vals, ok = pr.Run(w, r, &Opts{
+			Cluster: cluster,
+			Values:  vals,
+		}); !ok {
+			return
+		}
+	}
+
+	// call apply on the provisioner service
+	resp, err := c.Config().ProvisionerClient.Apply(context.Background(), proj.ID, infra.ID, &ptypes.ApplyBaseRequest{
+		Kind:          string(infra.Kind),
+		Values:        vals,
+		OperationKind: "update",
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 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 - 70
api/server/handlers/provision/helpers.go

@@ -1,70 +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,
-		ProvisionerTest:     conf.ServerConf.ProvisionerTest,
-		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())
-}

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

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

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

+ 204 - 24
api/server/router/infra.go

@@ -1,7 +1,10 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/database"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -70,6 +73,7 @@ func getInfraRoutes(
 
 	listInfraHandler := infra.NewInfraListHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
@@ -107,14 +111,14 @@ func getInfraRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/infras/{infra_id}/retry -> infra.NewInfraRetryHandler
-	retryProvisionEndpoint := 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",
+				RelativePath: relPath + "/retry_create",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -124,82 +128,230 @@ func getInfraRoutes(
 		},
 	)
 
-	retryProvisionHandler := infra.NewInfraRetryHandler(
+	retryCreateHandler := infra.NewInfraRetryCreateHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: retryProvisionEndpoint,
-		Handler:  retryProvisionHandler,
+		Endpoint: retryCreateEndpoint,
+		Handler:  retryCreateHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/infras/{infra_id}/logs -> infra.NewInfraStreamLogsHandler
-	streamLogsEndpoint := factory.NewAPIEndpoint(
+	// 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: fmt.Sprintf("%s/operations/{%s}/state", relPath, types.URLParamOperationID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+				types.OperationScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	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: relPath + "/logs",
+				RelativePath: fmt.Sprintf("%s/operations/{%s}/log_stream", relPath, types.URLParamOperationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 				types.InfraScope,
+				types.OperationScope,
 			},
 			IsWebsocket: true,
 		},
 	)
 
-	streamLogsHandler := infra.NewInfraStreamLogsHandler(
+	streamLogHandler := infra.NewInfraStreamLogHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: streamLogsEndpoint,
-		Handler:  streamLogsHandler,
+		Endpoint: streamLogEndpoint,
+		Handler:  streamLogHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/infras/{infra_id}/current -> infra.NewInfraGetHandler
-	getCurrentEndpoint := factory.NewAPIEndpoint(
+	// 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: relPath + "/current",
+				RelativePath: fmt.Sprintf("%s/operations/{%s}/logs", relPath, types.URLParamOperationID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
 				types.InfraScope,
+				types.OperationScope,
 			},
 		},
 	)
 
-	getCurrentHandler := infra.NewInfraGetCurrentHandler(
+	getOperationLogsHandler := infra.NewInfraGetOperationLogsHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: getCurrentEndpoint,
-		Handler:  getCurrentHandler,
+		Endpoint: getOperationLogsEndpoint,
+		Handler:  getOperationLogsHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/infras/{infra_id}/desired -> infra.NewInfraGetHandler
-	getDesiredEndpoint := factory.NewAPIEndpoint(
+	// 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 + "/desired",
+				RelativePath: relPath + "/state",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -209,14 +361,14 @@ func getInfraRoutes(
 		},
 	)
 
-	getDesiredHandler := infra.NewInfraGetDesiredHandler(
+	getStateHandler := infra.NewInfraGetStateHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &Route{
-		Endpoint: getDesiredEndpoint,
-		Handler:  getDesiredHandler,
+		Endpoint: getStateEndpoint,
+		Handler:  getStateHandler,
 		Router:   r,
 	})
 
@@ -249,5 +401,33 @@ func getInfraRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/infras/{infra_id}/database -> database.NewDatabaseUpdateStatusHandler
+	updateDBStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/database",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.InfraScope,
+			},
+		},
+	)
+
+	updateDBStatusHandler := database.NewDatabaseUpdateStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateDBStatusEndpoint,
+		Handler:  updateDBStatusHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

+ 1 - 32
api/server/router/namespace.go

@@ -7,7 +7,6 @@ import (
 
 	"github.com/porter-dev/porter/api/server/handlers/job"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
-	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -57,37 +56,7 @@ func getNamespaceRoutes(
 
 	routes := make([]*Route, 0)
 
-	// 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}/envgroup/list -> namespace.NewListEnvGroupsHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/envgroups/list -> namespace.NewListEnvGroupsHandler
 	listEnvGroupsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,

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

@@ -1,13 +1,15 @@
 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/helmrepo"
+	"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"
@@ -633,72 +635,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,
@@ -707,56 +651,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,
@@ -765,47 +706,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,
+	// })
 
 	//  POST /api/projects/{project_id}/helmrepos -> helmrepo.NewHelmRepoCreateHandler
 	hrCreateEndpoint := factory.NewAPIEndpoint(

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

+ 3 - 4
api/server/shared/config/config.go

@@ -10,12 +10,12 @@ import (
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/credentials"
+	"github.com/porter-dev/porter/provisioner/client"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
 )
@@ -75,9 +75,8 @@ type Config struct {
 	// URLCache contains a cache of chart names to chart repos
 	URLCache *urlcache.ChartURLCache
 
-	// ProvisionerAgent is the kubernetes client responsible for creating new provisioner
-	// jobs
-	ProvisionerAgent *kubernetes.Agent
+	// ProvisionerClient is an authenticated client for the provisioner service
+	ProvisionerClient *client.Client
 
 	// DB is the gorm DB instance
 	DB *gorm.DB

+ 4 - 10
api/server/shared/config/env/envconfs.go

@@ -65,12 +65,9 @@ type ServerConf struct {
 	DOClientID     string `env:"DO_CLIENT_ID"`
 	DOClientSecret string `env:"DO_CLIENT_SECRET"`
 
-	// Options for the provisioner jobs
-	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
-	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
-	ProvisionerJobNamespace    string `env:"PROV_JOB_NAMESPACE,default=default"`
-	ProvisionerBackendURL      string `env:"PROV_BACKEND_URL"`
-	ProvisionerCredExchangeURL string `env:"PROV_CRED_EXCHANGE_URL,default=http://porter:8080"`
+	// Options for the provisioner service
+	ProvisionerServerURL string `env:"PROVISIONER_SERVER_URL"`
+	ProvisionerToken     string `env:"PROVISIONER_TOKEN"`
 
 	SegmentClientKey string `env:"SEGMENT_CLIENT_KEY"`
 
@@ -86,10 +83,7 @@ type ServerConf struct {
 	SentryDSN string `env:"SENTRY_DSN"`
 	SentryEnv string `env:"SENTRY_ENV,default=dev"`
 
-	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
-	IngressCluster     string `env:"INGRESS_CLUSTER"`
-	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
-	InitInCluster      bool   `env:"INIT_IN_CLUSTER,default=false"`
+	InitInCluster bool `env:"INIT_IN_CLUSTER,default=false"`
 
 	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
 

+ 12 - 19
api/server/shared/config/loader/loader.go

@@ -18,13 +18,12 @@ import (
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier/sendgrid"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository/credentials"
 	"github.com/porter-dev/porter/internal/repository/gorm"
+	"github.com/porter-dev/porter/provisioner/client"
 
 	lr "github.com/porter-dev/porter/internal/logger"
 
@@ -73,7 +72,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	res.Metadata = config.MetadataFromConf(envConf.ServerConf, e.version)
 	res.DB = InstanceDB
 
-	err = gorm.AutoMigrate(InstanceDB)
+	err = gorm.AutoMigrate(InstanceDB, sc.Debug)
 
 	if err != nil {
 		return nil, err
@@ -203,17 +202,13 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 
 	res.URLCache = urlcache.Init(sc.DefaultApplicationHelmRepoURL, sc.DefaultAddonHelmRepoURL)
 
-	provAgent, err := getProvisionerAgent(sc)
+	provClient, err := getProvisionerServiceClient(sc)
 
 	if err != nil {
 		return nil, err
 	}
 
-	res.ProvisionerAgent = provAgent
-
-	if res.ProvisionerAgent != nil && res.RedisConf.Enabled {
-		res.Metadata.Provisioning = true
-	}
+	res.ProvisionerClient = provClient
 
 	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, res.Logger)
 
@@ -224,20 +219,18 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	return res, nil
 }
 
-func getProvisionerAgent(sc *env.ServerConf) (*kubernetes.Agent, error) {
-	if sc.ProvisionerCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
-		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
+func getProvisionerServiceClient(sc *env.ServerConf) (*client.Client, error) {
+	if sc.ProvisionerServerURL != "" && sc.ProvisionerToken != "" {
+		baseURL := fmt.Sprintf("%s/api/v1", sc.ProvisionerServerURL)
+
+		pClient, err := client.NewClient(baseURL, sc.ProvisionerToken, 0)
 
 		if err != nil {
-			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
+			return nil, err
 		}
 
-		return agent, nil
-	} else if sc.ProvisionerCluster == "kubeconfig" {
-		return nil, fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
+		return pClient, nil
 	}
 
-	agent, _ := kubernetes.GetAgentInClusterConfig()
-
-	return agent, nil
+	return nil, fmt.Errorf("required env vars not set for provisioner")
 }

+ 4 - 3
api/server/shared/config/metadata.go

@@ -1,6 +1,8 @@
 package config
 
-import "github.com/porter-dev/porter/api/server/shared/config/env"
+import (
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+)
 
 type Metadata struct {
 	Provisioning       bool   `json:"provisioner"`
@@ -16,8 +18,7 @@ type Metadata struct {
 
 func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
 	return &Metadata{
-		// note: provisioning is set in the metadata after the loader is called
-		Provisioning:       false,
+		Provisioning:       sc.ProvisionerServerURL != "",
 		Github:             hasGithubAppVars(sc),
 		GithubLogin:        sc.GithubClientID != "" && sc.GithubClientSecret != "" && sc.GithubLoginEnabled,
 		BasicLogin:         sc.BasicLoginEnabled,

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

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

+ 11 - 5
api/types/database.go

@@ -11,11 +11,17 @@ type Database struct {
 
 	ClusterID uint `json:"cluster_id"`
 
-	InstanceID       string `json:"instance_id"`
-	InstanceEndpoint string `json:"instance_endpoint"`
-	InstanceName     string `json:"instance_name"`
-
-	Status string `json:"status"`
+	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"`
+	Status            string `json:"status"`
 }
 
 type ListDatabaseResponse []*Database
+
+type UpdateDatabaseStatusRequest struct {
+	Status string `json:"status" form:"required,oneof=destroying updating"`
+}

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

+ 72 - 0
api/types/infra.go

@@ -39,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"`
 
@@ -59,4 +63,72 @@ 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
+
+	ClusterID uint                   `json:"cluster_id"`
+	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"`
 }

+ 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: {},
 	},
 }
 

+ 1 - 0
api/types/project.go

@@ -6,6 +6,7 @@ type Project struct {
 	Roles               []*Role `json:"roles"`
 	PreviewEnvsEnabled  bool    `json:"preview_envs_enabled"`
 	RDSDatabasesEnabled bool    `json:"enable_rds_databases"`
+	ManagedInfraEnabled bool    `json:"managed_infra_enabled"`
 }
 
 type CreateProjectRequest struct {

+ 2 - 184
api/types/provision.go

@@ -1,57 +1,11 @@
 package types
 
-import "strings"
-
-type CreateECRInfraRequest struct {
-	ECRName          string `json:"ecr_name" form:"required"`
-	ProjectID        uint   `json:"-" form:"required"`
-	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
-}
-
-type CreateEKSInfraRequest struct {
-	EKSName          string `json:"eks_name" form:"required"`
-	MachineType      string `json:"machine_type"`
-	IssuerEmail      string `json:"issuer_email" form:"required"`
-	ProjectID        uint   `json:"-" form:"required"`
-	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
-}
-
-type CreateGCRInfraRequest struct {
-	ProjectID        uint `json:"-" form:"required"`
-	GCPIntegrationID uint `json:"gcp_integration_id" form:"required"`
-}
-
-type CreateGKEInfraRequest struct {
-	GKEName          string `json:"gke_name" form:"required"`
-	GCPRegion        string `json:"gcp_region" form:"required"`
-	IssuerEmail      string `json:"issuer_email" form:"required"`
-	ProjectID        uint   `json:"-" form:"required"`
-	GCPIntegrationID uint   `json:"gcp_integration_id" form:"required"`
-}
-
-type CreateDOCRInfraRequest struct {
-	DOCRName             string `json:"docr_name" form:"required"`
-	DOCRSubscriptionTier string `json:"docr_subscription_tier" form:"required"`
-	ProjectID            uint   `json:"-" form:"required"`
-	DOIntegrationID      uint   `json:"do_integration_id" form:"required"`
-}
-
-type CreateDOKSInfraRequest struct {
-	DORegion        string `json:"do_region" form:"required"`
-	IssuerEmail     string `json:"issuer_email" form:"required"`
-	DOKSName        string `json:"doks_name" form:"required"`
-	ProjectID       uint   `json:"-" form:"required"`
-	DOIntegrationID uint   `json:"do_integration_id" 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 type - postgres / mysql
+	DBFamily string `json:"db_family"`
 
 	// db instance credentials specifications
 	DBName   string `json:"db_name"`
@@ -63,139 +17,3 @@ type CreateRDSInfraRequest struct {
 	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: {},
-}

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

+ 0 - 17
cmd/app/main.go

@@ -11,9 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/router"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
-	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/redis_stream"
 	"gorm.io/gorm"
 )
 
@@ -45,21 +43,6 @@ func main() {
 		log.Fatal("Data initialization 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, config.AnalyticsClient, errorChan)
-	}
-
 	appRouter := router.NewAPIRouter(config)
 
 	address := fmt.Sprintf(":%d", config.ServerConf.Port)

+ 1 - 1
cmd/migrate/main.go

@@ -31,7 +31,7 @@ func main() {
 		return
 	}
 
-	err = gorm.AutoMigrate(db)
+	err = gorm.AutoMigrate(db, envConf.ServerConf.Debug)
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("gorm auto-migration failed")

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

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

@@ -0,0 +1,13 @@
+import styled from "styled-components";
+
+const Description = styled.div<{ margin?: string }>`
+  width: 100%;
+  color: white;
+  font-size: 13px;
+  color: #aaaabb;
+  margin: ${(props) => props.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}

+ 382 - 0
dashboard/src/components/MultiSaveButton.tsx

@@ -0,0 +1,382 @@
+import React, { Component, useState } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+import MultiSelect from "./porter-form/field-components/MultiSelect";
+import Description from "./Description";
+
+type MultiSelectOption = {
+  text: string;
+  onClick: () => void;
+  description: string;
+};
+
+type Props = {
+  options: MultiSelectOption[];
+
+  disabled?: boolean;
+  status?: string | null;
+  color?: string;
+  rounded?: boolean;
+  helper?: string | null;
+  saveText?: string | null;
+
+  // Makes flush with corner if not within a modal
+  makeFlush?: boolean;
+  clearPosition?: boolean;
+  statusPosition?: "right" | "left";
+  // Provide the classname to modify styles from other components
+  className?: string;
+  successText?: string;
+};
+
+const MultiSaveButton: React.FC<Props> = (props) => {
+  const [currOption, setCurrOption] = useState<MultiSelectOption>(
+    props.options[0]
+  );
+
+  const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
+
+  const renderStatus = () => {
+    if (props.status) {
+      if (props.status === "successful") {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={true}>
+            <i className="material-icons">done</i>
+            <StatusTextWrapper>
+              {props?.successText || "Successfully updated"}
+            </StatusTextWrapper>
+          </StatusWrapper>
+        );
+      } else if (props.status === "loading") {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <LoadingGif src={loading} />
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
+          </StatusWrapper>
+        );
+      } else if (props.status === "error") {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>Could not update</StatusTextWrapper>
+          </StatusWrapper>
+        );
+      } else {
+        return (
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>{props.status}</StatusTextWrapper>
+          </StatusWrapper>
+        );
+      }
+    } else if (props.helper) {
+      return (
+        <StatusWrapper position={props.statusPosition} successful={true}>
+          {props.helper}
+        </StatusWrapper>
+      );
+    }
+  };
+
+  const renderDropdown = () => {
+    if (isDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
+          <OptionWrapper
+            dropdownWidth="400px"
+            dropdownMaxHeight="300px"
+            onClick={() => setIsDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </OptionWrapper>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return props.options.map((option, i, originalArray) => {
+      return (
+        <Option
+          key={i}
+          selected={option.text === currOption.text}
+          onClick={() => setCurrOption(option)}
+          lastItem={i === originalArray.length - 1}
+        >
+          {option.text}
+          <OptionDescription margin="8px 0 0 0">
+            {option.description}
+          </OptionDescription>
+        </Option>
+      );
+    });
+  };
+
+  return (
+    <DropdownSelector>
+      <ButtonWrapper
+        makeFlush={props.makeFlush}
+        clearPosition={props.clearPosition}
+        className={props.className}
+      >
+        {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
+        <Button
+          rounded={props.rounded}
+          disabled={props.disabled}
+          onClick={currOption.onClick}
+          color={props.color || "#5561C0"}
+        >
+          {currOption.text}
+        </Button>
+        <DropdownButton
+          disabled={props.disabled}
+          color={props.color || "#5561C0"}
+          onClick={() => setIsDropdownExpanded(!isDropdownExpanded)}
+        >
+          <i className="material-icons expand-icon">
+            {isDropdownExpanded ? "expand_less" : "expand_more"}
+          </i>
+        </DropdownButton>
+        {props.statusPosition === "right" && <div>{renderStatus()}</div>}
+      </ButtonWrapper>
+      {renderDropdown()}
+    </DropdownSelector>
+  );
+};
+
+export default MultiSaveButton;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const StatusTextWrapper = styled.p`
+  display: -webkit-box;
+  line-clamp: 2;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 19px;
+  margin: 0;
+`;
+
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
+const StatusWrapper = styled.div<{
+  successful: boolean;
+  position: "right" | "left";
+}>`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
+  max-width: 500px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    float: left;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
+  }
+
+  animation-fill-mode: forwards;
+
+  @keyframes statusFloatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const ButtonWrapper = styled.div`
+  ${(props: { makeFlush: boolean; clearPosition?: boolean }) => {
+    const baseStyles = `
+      display: flex;
+      align-items: center;
+      z-index: 99;
+    `;
+
+    if (props.clearPosition) {
+      return baseStyles;
+    }
+
+    if (!props.makeFlush) {
+      return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
+        bottom: 25px;
+        right: 27px;
+        left: 27px;
+      `;
+    }
+    return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
+      bottom: 5px;
+      right: 0;
+    `;
+  }}
+`;
+
+const Button = styled.button<{
+  disabled: boolean;
+  color: string;
+  rounded: boolean;
+}>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: ${(props) => (props.rounded ? "100px" : "5px 0 0 5px")};
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    margin-left: -5px;
+    justify-content: center;
+  }
+`;
+
+const DropdownSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const DropdownLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const OptionWrapper = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  cursor: pointer;
+  padding: 10px;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const DropdownButton = styled.div<{
+  disabled: boolean;
+  color: string;
+}>`
+  height: 35px;
+  border-radius: 0 5px 5px 0;
+  color: white;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  margin-left: 1px;
+  padding: 9px;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;
+
+const OptionDescription = styled(Description)`
+  font-weight: 400;
+  line-height: 150%;
+`;

+ 844 - 169
dashboard/src/components/ProvisionerStatus.tsx

@@ -1,38 +1,32 @@
-import { Steps } from "main/home/onboarding/types";
-import React, { useState } from "react";
+import React, { useContext, useEffect, useRef, useState } from "react";
 import { integrationList } from "shared/common";
-
-import loading from "assets/loading.gif";
-
 import styled, { keyframes } from "styled-components";
+import { 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 { Context } from "shared/Context";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+import Description from "./Description";
 
 type Props = {
-  modules: TFModule[];
+  infras: Infrastructure[];
+  project_id: number;
+  setInfraStatus: (infra: Infrastructure) => void;
+  auto_expanded?: boolean;
+  can_delete?: boolean;
+  set_max_width?: 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)",
@@ -43,128 +37,758 @@ const nameMap: { [key: string]: string } = {
   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") {
+const ProvisionerStatus: React.FC<Props> = ({
+  infras,
+  project_id,
+  auto_expanded,
+  set_max_width,
+  can_delete,
+  setInfraStatus,
+}) => {
+  const renderV1Infra = (infra: Infrastructure) => {
+    return (
+      <V1InfraObject
+        key={infra.id}
+        infra={infra}
+        is_expanded={auto_expanded}
+        is_collapsible={!auto_expanded}
+        set_max_width={set_max_width}
+      />
+    );
+  };
+
+  const updateInfraStatus = (infra: Infrastructure) => {
+    // in order for this to propagate to parent, we check that all tracked infras (including
+    // the reported infra) are in a final state
+    setInfraStatus(infra);
+  };
+
+  const renderV2Infra = (infra: Infrastructure) => {
+    return (
+      <V2InfraObject
+        key={infra.id}
+        project_id={project_id}
+        infra={infra}
+        is_expanded={auto_expanded}
+        is_collapsible={!auto_expanded}
+        set_max_width={set_max_width}
+        can_delete={can_delete}
+        updateInfraStatus={updateInfraStatus}
+      />
+    );
+  };
+
+  const renderInfras = () => {
+    return infras.map((infra) => {
+      if (infra.api_version == "v2") {
+        return renderV2Infra(infra);
+      }
+
+      return renderV1Infra(infra);
+    });
+  };
+
+  return <StyledProvisionerStatus>{renderInfras()}</StyledProvisionerStatus>;
+};
+
+export default ProvisionerStatus;
+
+type V1InfraObjectProps = {
+  infra: Infrastructure;
+  is_expanded: boolean;
+  is_collapsible: boolean;
+  set_max_width?: boolean;
+};
+
+const V1InfraObject: React.FC<V1InfraObjectProps> = ({
+  infra,
+  is_expanded,
+  is_collapsible,
+  set_max_width,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(is_expanded);
+
+  const renderTimestampSection = () => {
+    let timestampLabel = "Started at";
+
+    switch (infra.status) {
+      case "created":
+        timestampLabel = "Created at";
+        break;
+      case "deleted":
+      case "destroyed":
+        timestampLabel = "Deleted at";
+        break;
+      case "errored":
+        timestampLabel = "Errored at";
+        break;
+    }
+
+    return (
+      <Timestamp>
+        {timestampLabel} {readableDate(infra.updated_at)}
+      </Timestamp>
+    );
+  };
+
+  const renderErrorSection = () => {
+    let errors: string[] = [];
+    if (infra.status == "destroyed" || infra.status == "deleted") {
+      errors.push("This infrastructure was destroyed.");
+    }
+    if (errors.length > 0) {
       return (
-        <StatusIcon>
-          <LoadingGif src={loading} />
-        </StatusIcon>
+        <>
+          <Description>
+            Encountered the following errors while provisioning:
+          </Description>
+          <ErrorWrapper>
+            {errors.map((error, index) => {
+              return <ExpandedError key={index}>{error}</ExpandedError>;
+            })}
+          </ErrorWrapper>
+        </>
       );
-    } else if (status === "error") {
+    }
+  };
+
+  const renderExpandedContents = () => {
+    if (isExpanded) {
+      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 (
-        <StatusIcon>
-          <i className="material-icons">error_outline</i>
-        </StatusIcon>
+        <StyledV1Card>
+          <Description>
+            Infrastructure is {infra.status}, last updated at{" "}
+            {readableDate(infra.updated_at)}
+          </Description>
+          {renderErrorSection()}
+        </StyledV1Card>
       );
     }
   };
 
-  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 (
+    <StyledInfraObject key={infra.id} set_max_width={set_max_width}>
+      <InfraHeader
+        is_clickable={is_collapsible}
+        onClick={() => {
+          if (is_collapsible) {
+            setIsExpanded((val) => {
+              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>
+  );
+};
+
+type V2InfraObjectProps = {
+  infra: Infrastructure;
+  project_id: number;
+  is_expanded: boolean;
+  is_collapsible: boolean;
+  set_max_width?: boolean;
+  can_delete?: boolean;
+  updateInfraStatus: (infra: Infrastructure) => void;
+};
+
+const V2InfraObject: React.FC<V2InfraObjectProps> = ({
+  infra,
+  project_id,
+  is_expanded,
+  is_collapsible,
+  set_max_width,
+  can_delete,
+  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 renderModules = () => {
-    return modules.map((val) => {
-      const totalResources = val.resources?.length;
-      const provisionedResources = val.resources?.filter((resource) => {
-        return resource.provisioned;
-      }).length;
+  const refreshInfra = (completed?: boolean, errored?: boolean) => {
+    setIsLoading(true);
 
-      let errors: string[] = [];
+    api
+      .getInfraByID(
+        "<token>",
+        {},
+        {
+          project_id: project_id,
+          infra_id: infra.id,
+        }
+      )
+      .then(({ data }) => {
+        let infra = data as Infrastructure;
+
+        if (completed && infra.latest_operation) {
+          if (errored) {
+            infra.latest_operation.status = "errored";
+          } else {
+            infra.latest_operation.status = "completed";
+          }
+        }
 
-      if (val.status == "destroyed") {
-        errors.push("Note: this infrastructure was automatically destroyed.");
-      }
+        setFullInfra(infra);
+        updateInfraStatus(infra);
+
+        // re-query for the infra state
+        refreshInfraState();
+
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  const renderExpandedContentsCreated = () => {
+    return (
+      <OperationDetails
+        infra={fullInfra}
+        can_delete={can_delete}
+        refreshInfra={refreshInfra}
+      />
+    );
+  };
+
+  const renderExpandedContents = () => {
+    if (!isExpanded) {
+      return null;
+    } else if (fullInfra) {
+      return renderExpandedContentsCreated();
+    }
+
+    return (
+      <ErrorWrapper>
+        <Placeholder>
+          <Loading />{" "}
+        </Placeholder>
+      </ErrorWrapper>
+    );
+  };
 
-      let hasError =
-        val.resources?.filter((resource) => {
-          if (resource.errored?.errored_out) {
-            errors.push(resource.errored?.error_context);
+  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} set_max_width={set_max_width}>
+      <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;
+  can_delete?: boolean;
+  refreshInfra: (completed?: boolean, errored?: boolean) => 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,
+  can_delete,
+  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 [deletedResources, setDeletedResources] = 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(true, erroredResources.length > 0);
+    } 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]) {
+          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);
+
+        if (!infraStateInitialized) {
+          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");
+      setDeletedResources(
+        Object.keys(infraState.resources)
+          .map((key) => {
+            if (infraState.resources[key].status == "deleted") {
+              return infraState.resources[key];
+            }
+
+            return null;
+          })
+          .filter((val) => val)
+      );
+
+      setPlannedResources(
+        Object.keys(infraState.resources)
+          .map((key) => {
+            if (
+              infraState.resources[key].status == "planned_create" ||
+              infraState.resources[key].status == "planned_delete"
+            ) {
+              return infraState.resources[key];
+            }
+
+            return null;
+          })
+          .filter((val) => val)
+      );
+    }
+  }, [infraState]);
+
+  if (isLoading || !infraState || !operation) {
+    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 deleteInfra = () => {
+    api
+      .deleteInfra(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          infra_id: infra.id,
+        }
+      )
+      .then(({ data }) => {
+        refreshInfra();
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  const getOperationAction = (status: OperationStatus) => {
+    if (can_delete && status == "errored") {
+      return (
+        <Button color="#b91133" onClick={deleteInfra}>
+          Delete Infra
+        </Button>
+      );
+    }
+  };
+
+  const renderLoadingBar = (
+    completedResourceCount: number,
+    plannedResourceCount: number
+  ) => {
+    let width = (100.0 * completedResourceCount) / plannedResourceCount;
+    let operationKind = "Created";
+    let count = `${completedResourceCount} / ${plannedResourceCount}`;
+
+    if (
+      infra.latest_operation.status == "completed" &&
+      (infra.latest_operation.type == "delete" ||
+        infra.latest_operation.type == "retry_delete")
+    ) {
+      width = 100.0;
+      count = "";
+    } else if (
+      infra.latest_operation.status != "completed" &&
+      plannedResourceCount == 0
+    ) {
+      // in the case when the planned resource count is 0, the state is still being computed, so
+      // render 0 width and "Planning..." message
+      width = 0;
+      operationKind = "Planning...";
+      count = "";
+    }
+
+    if (operationKind != "Planning...") {
+      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>{`${count} ${operationKind}`}</ResourceNumber>
+      </StatusContainer>
+    );
+  };
+
+  const renderErrorSection = () => {
+    if (erroredResources.length > 0 && infra?.latest_operation?.errored) {
       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 + deletedResources.length,
+        createdResources.length +
+          erroredResources.length +
+          plannedResources.length
+      )}
+      <Description>
+        {getOperationDescription(
+          operation.type,
+          operation.status,
+          operation.last_updated
+        )}
+      </Description>
+      {renderErrorSection()}
+      {getOperationAction(operation.status)}
+    </StyledCard>
+  );
 };
 
-export default ProvisionerStatus;
+const StyledCard = styled.div`
+  padding: 12px 20px;
+  max-height: 300px;
+  overflow-y: auto;
+`;
+
+const StyledV1Card = styled(StyledCard)`
+  padding: 0 20px 12px 20px;
+`;
 
 const Flex = styled.div`
   display: flex;
@@ -183,7 +807,6 @@ const Icon = styled.img`
 `;
 
 const ErrorWrapper = styled.div`
-  max-height: 150px;
   margin-top: 20px;
   overflow-y: auto;
   user-select: text;
@@ -201,6 +824,20 @@ const ExpandedError = styled.div`
   padding-bottom: 17px;
 `;
 
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+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;
@@ -211,74 +848,112 @@ 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<{ set_max_width?: boolean }>`
+  background: #ffffff1a;
+  border: 1px solid #aaaabb;
+  border-radius: 5px;
+  margin-bottom: 10px;
+  position: relative;
+  width: ${(props) => (props.set_max_width ? "580px" : "100%")};
+`;
+
+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`
+const ExpandIconContainer = styled.div<{ hidden: boolean }>`
+  width: 30px;
+  margin-left: 10px;
+  padding-top: 2px;
+  display: ${(props) => (props.hidden ? "none" : "inline")};
+`;
+
+const DeleteAction = styled.span`
+  height: 35px;
   font-size: 13px;
   font-weight: 500;
-  justify-content: space-between;
-  padding: 0 15px;
+  font-family: "Work Sans", sans-serif;
   display: flex;
   align-items: center;
+  justify-content: space-between;
+  padding: 6px 14px;
+  text-align: left;
+  border: 1px solid #ffffff55;
+  border-radius: 8px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+  margin-top: 20px;
+  max-width: 120px;
+`;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin: 10px 0;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
 `;

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

@@ -119,7 +119,6 @@ const Table: React.FC<TableProps> = ({
             >
               {/* 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) => {
-                console.log(cell.getCellProps());
                 return (
                   <StyledTd
                     {...cell.getCellProps()}

+ 90 - 0
dashboard/src/components/expanded-object/Header.tsx

@@ -0,0 +1,90 @@
+import DynamicLink from "components/DynamicLink";
+import React from "react";
+import styled from "styled-components";
+import backArrow from "assets/back_arrow.png";
+import TitleSection from "components/TitleSection";
+
+type Props = {
+  last_updated: string;
+  back_link: string;
+  name: string;
+  icon: string;
+  inline_title_items?: React.ReactNodeArray;
+};
+
+const Header: React.FunctionComponent<Props> = (props) => {
+  const { last_updated, back_link, icon, name, inline_title_items } = props;
+
+  return (
+    <HeaderWrapper>
+      <BackButton to={back_link}>
+        <BackButtonImg src={backArrow} />
+      </BackButton>
+      <Title icon={icon} iconWidth="25px">
+        {name}
+        <Flex>{inline_title_items}</Flex>
+      </Title>
+
+      <InfoWrapper>
+        <InfoText>Last updated {last_updated}</InfoText>
+      </InfoWrapper>
+    </HeaderWrapper>
+  );
+};
+
+export default Header;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+  margin-bottom: 10px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  width: auto;
+  justify-content: space-between;
+`;
+
+const InfoText = styled.span`
+  font-size: 13px;
+  color: #aaaabb66;
+`;
+
+const BackButton = styled(DynamicLink)`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const Title = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 4px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 10px 0;
+`;

+ 11 - 1
dashboard/src/components/porter-form/PorterForm.tsx

@@ -43,6 +43,8 @@ interface Props {
   setCurrentTab: (nt: string) => void;
   isLaunch?: boolean;
   hideSpacer?: boolean;
+  // The tab to redirect to after saving the form
+  redirectTabAfterSave?: string;
 }
 
 const PorterForm: React.FC<Props> = (props) => {
@@ -178,6 +180,14 @@ const PorterForm: React.FC<Props> = (props) => {
     return props.saveValuesStatus;
   };
 
+  const submit = () => {
+    onSubmit(() => {
+      if (props.redirectTabAfterSave != "") {
+        setCurrentTab(props.redirectTabAfterSave);
+      }
+    });
+  };
+
   return (
     <>
       <TabRegion
@@ -194,7 +204,7 @@ const PorterForm: React.FC<Props> = (props) => {
       {showSaveButton() && (
         <SaveButton
           text={props.saveButtonText || "Deploy"}
-          onClick={onSubmit}
+          onClick={submit}
           makeFlush={!props.isInModal}
           status={
             validationInfo.validated ? renderSaveStatus() : validationInfo.error

+ 28 - 9
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -7,17 +7,24 @@ import {
   PorterFormValidationInfo,
   PorterFormVariableList,
 } from "./types";
-import { ShowIf, ShowIfAnd, ShowIfNot, ShowIfOr } from "../../shared/types";
+import {
+  ShowIf,
+  ShowIfAnd,
+  ShowIfIs,
+  ShowIfNot,
+  ShowIfOr,
+} from "../../shared/types";
 import { getFinalVariablesForStringInput } from "./field-components/Input";
 import { getFinalVariablesForKeyValueArray } from "./field-components/KeyValueArray";
 import { Context } from "../../shared/Context";
 import { getFinalVariablesForArrayInput } from "./field-components/ArrayInput";
 import { getFinalVariablesForCheckbox } from "./field-components/Checkbox";
 import { getFinalVariablesForSelect } from "./field-components/Select";
+import api from "shared/api";
 
 interface Props {
   rawFormData: PorterFormData;
-  onSubmit: (vars: PorterFormVariableList) => void;
+  onSubmit: (vars: PorterFormVariableList, cb?: () => void) => void;
   initialVariables?: PorterFormVariableList;
   overrideVariables?: PorterFormVariableList;
   includeHiddenFields?: boolean;
@@ -28,7 +35,7 @@ interface Props {
 interface ContextProps {
   formData: PorterFormData;
   formState: PorterFormState;
-  onSubmit: () => void;
+  onSubmit: (cb?: () => void) => void;
   dispatchAction: (event: PorterFormAction) => void;
   validationInfo: PorterFormValidationInfo;
   getSubmitValues: () => PorterFormVariableList;
@@ -133,16 +140,23 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
         })
       )
     );
-    return {
-      ...ret,
-      ...{
+
+    let scopedVars = {};
+
+    if (data?.isClusterScoped) {
+      scopedVars = {
         "currentCluster.service.is_gcp":
           context.currentCluster?.service == "gke",
         "currentCluster.service.is_aws":
           context.currentCluster?.service == "eks",
         "currentCluster.service.is_do":
           context.currentCluster?.service == "doks",
-      },
+      };
+    }
+
+    return {
+      ...ret,
+      ...scopedVars,
     };
   };
 
@@ -193,6 +207,11 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     if (typeof vals == "string") {
       return !!variables[vals];
     }
+    if ((vals as ShowIfIs).is) {
+      vals = vals as ShowIfIs;
+      return vals.is == variables[vals.variable];
+    }
+
     if ((vals as ShowIfOr).or) {
       vals = vals as ShowIfOr;
       for (let i = 0; i < vals.or?.length; i++) {
@@ -439,8 +458,8 @@ export const PorterFormContextProvider: React.FC<Props> = (props) => {
     return Object.assign.apply({}, varList);
   };
 
-  const onSubmitWrapper = () => {
-    props.onSubmit(getSubmitValues());
+  const onSubmitWrapper = (cb?: () => void) => {
+    props.onSubmit(getSubmitValues(), cb);
   };
 
   if (props.doDebug) {

+ 3 - 0
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -21,6 +21,7 @@ type PropsType = {
   isLaunch?: boolean;
   includeHiddenFields?: boolean;
   hideBottomSpacer?: boolean;
+  redirectTabAfterSave?: string;
 };
 
 const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
@@ -40,6 +41,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
   isLaunch,
   includeHiddenFields,
   hideBottomSpacer,
+  redirectTabAfterSave,
 }) => {
   const hashCode = (s: string) => {
     return s?.split("").reduce(function (a, b) {
@@ -93,6 +95,7 @@ const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
           setCurrentTab={setCurrentTab}
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}
+          redirectTabAfterSave={redirectTabAfterSave}
         />
       </PorterFormContextProvider>
     </React.Fragment>

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

@@ -165,6 +165,7 @@ export interface PorterFormData {
   name: string;
   hasSource: boolean;
   includeHiddenFields: boolean;
+  isClusterScoped?: boolean;
   tabs: Tab[];
 }
 

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

@@ -27,6 +27,7 @@ import discordLogo from "../../assets/discord.svg";
 import Onboarding from "./onboarding/Onboarding";
 import ModalHandler from "./ModalHandler";
 import { NewProjectFC } from "./new-project/NewProject";
+import InfrastructureRouter from "./infrastructure/InfrastructureRouter";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -335,7 +336,7 @@ class Home extends Component<PropsType, StateType> {
 
         return api.destroyInfra(
           "<token>",
-          { name: cluster.name },
+          {},
           { project_id: currentProject.id, infra_id: cluster.infra_id }
         );
       });
@@ -434,6 +435,16 @@ class Home extends Component<PropsType, StateType> {
                 return <Onboarding />;
               }}
             />
+            <Route
+              path="/infrastructure"
+              render={() => {
+                return (
+                  <DashboardWrapper>
+                    <InfrastructureRouter />
+                  </DashboardWrapper>
+                );
+              }}
+            />
             <Route
               path="/dashboard"
               render={() => {

+ 1 - 10
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -12,6 +12,7 @@ import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
 import api from "shared/api";
+import { readableDate } from "shared/string_utils";
 
 type Props = {
   chart: ChartType;
@@ -71,16 +72,6 @@ const Chart: React.FunctionComponent<Props> = ({
     getControllerForChart(chart);
   }, [chart]);
 
-  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}`;
-  };
-
   const filteredControllers = useMemo(() => {
     let tmpControllers: any = {};
     chartControllers.forEach((uid: any) => {

+ 52 - 9
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -112,29 +112,34 @@ const JobRunTable: React.FC<Props> = ({
 }) => {
   const { currentCluster, currentProject } = useContext(Context);
   const [jobRuns, setJobRuns] = useState<JobRun[]>(null);
-  const [error, setError] = useState();
+  const [hasError, setHasError] = useState(false);
   const tmpJobRuns = useRef([]);
+  const lastStreamStatus = useRef("");
   const { openWebsocket, newWebsocket, closeAllWebsockets } = useWebsockets();
-  const { pushFiltered } = useRouting();
 
-  useEffect(() => {
+  const getJobRuns = () => {
     closeAllWebsockets();
     tmpJobRuns.current = [];
+    lastStreamStatus.current = "";
     setJobRuns(null);
-    const websocketId = "job-runs-for-all-charts-ws";
+    setHasError(false);
+    const websocketId = `job-runs-for-all-charts-ws`;
     const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/jobs/stream`;
 
     const config: NewWebsocketOptions = {
       onopen: console.log,
       onmessage: (message) => {
         const data = JSON.parse(message.data);
+
         if (data.streamStatus === "finished") {
+          setHasError(false);
           setJobRuns(tmpJobRuns.current);
+          lastStreamStatus.current = data.streamStatus;
           return;
         }
 
         if (data.streamStatus === "errored") {
-          setError(data.error);
+          setHasError(true);
           tmpJobRuns.current = [];
           setJobRuns([]);
           return;
@@ -142,21 +147,33 @@ const JobRunTable: React.FC<Props> = ({
 
         tmpJobRuns.current = [...tmpJobRuns.current, data];
       },
-      onclose: () => {
+      onclose: (event) => {
+        console.log(event);
         closeAllWebsockets();
       },
       onerror: (error) => {
+        setHasError(true);
         console.log(error);
         closeAllWebsockets();
       },
     };
     newWebsocket(websocketId, endpoint, config);
     openWebsocket(websocketId);
+  };
+
+  useEffect(() => {
+    if (!namespace) {
+      return;
+    }
+
+    getJobRuns();
+  }, [currentCluster, currentProject, namespace]);
 
+  useEffect(() => {
     return () => {
       closeAllWebsockets();
     };
-  }, [currentCluster, currentProject, namespace]);
+  }, []);
 
   const columns = useMemo<Column<JobRun>[]>(
     () => [
@@ -335,8 +352,13 @@ const JobRunTable: React.FC<Props> = ({
     return tmp;
   }, [jobRuns, lastRunStatus, sortType]);
 
-  if (error) {
-    return <>{error}</>;
+  if (hasError && lastStreamStatus.current !== "finished") {
+    return (
+      <ErrorWrapper>
+        Couldn't retrieve jobs, please try again.{" "}
+        <RetryButton onClick={() => getJobRuns()}>Retry</RetryButton>
+      </ErrorWrapper>
+    );
   }
 
   if (jobRuns === null) {
@@ -359,6 +381,27 @@ const JobRunTable: React.FC<Props> = ({
 
 export default JobRunTable;
 
+const RetryButton = styled.button`
+  margin-left: 10px;
+  border: none;
+  background: #5460c6;
+  color: white;
+  padding: 5px 10px;
+  border-radius: 25px;
+  min-height: 35px;
+  min-width: 65px;
+  cursor: pointer;
+`;
+
+const ErrorWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 300px;
+  width: 100%;
+  color: #ffffff88;
+`;
+
 const Status = styled.div<{ color: string }>`
   padding: 5px 10px;
   background: ${(props) => props.color};

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx

@@ -12,7 +12,7 @@ import api from "shared/api";
 import ChartList from "../../chart/ChartList";
 import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
-import { capitalize } from "./components/EnvironmentCard";
+import { capitalize } from "shared/string_utils";
 
 const EnvironmentDetail = () => {
   const { params } = useRouteMatch<{ namespace: string }>();

+ 1 - 14
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx

@@ -5,10 +5,7 @@ import pr_icon from "assets/pull_request_icon.svg";
 import { integrationList } from "shared/common";
 import { useRouteMatch } from "react-router";
 import DynamicLink from "components/DynamicLink";
-
-export const capitalize = (s: string) => {
-  return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
-};
+import { capitalize, readableDate } from "shared/string_utils";
 
 const EnvironmentCard: React.FC<{ deployment: PRDeployment }> = ({
   deployment,
@@ -18,16 +15,6 @@ const EnvironmentCard: React.FC<{ deployment: PRDeployment }> = ({
 
   let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
 
-  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 (
     <EnvironmentCardWrapper key={deployment.id}>
       <DataContainer>

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

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

+ 17 - 1
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -75,6 +75,18 @@ const DatabasesList = () => {
         }
       );
 
+      // call an endpoint for updating the database status
+      await api.updateDatabaseStatus(
+        "<token>",
+        {
+          status: "destroying",
+        },
+        {
+          project_id,
+          infra_id,
+        }
+      );
+
       setCurrentOverlay(null);
       pushQueryParams({ current_tab: "provisioner-status" });
     } catch (error) {
@@ -177,7 +189,11 @@ const DatabasesList = () => {
   return (
     <DatabasesListWrapper>
       <ControlRow>
-        <Button to={`${url}/provision-database`}>
+        <Button
+          to={`/infrastructure/provision/RDS?origin=${encodeURIComponent(
+            "/databases"
+          )}`}
+        >
           <i className="material-icons">add</i>
           Create database
         </Button>

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

@@ -7,6 +7,8 @@ export const mock_database_list: DatabaseObject[] = [
     instance_id: "my-id",
     instance_name: "instance-name",
     project_id: 3,
+    infra_id: 1,
+    status: "running",
   },
   {
     cluster_id: 1,
@@ -14,6 +16,8 @@ export const mock_database_list: DatabaseObject[] = [
     instance_id: "my-id",
     instance_name: "instance-name",
     project_id: 3,
+    infra_id: 2,
+    status: "running",
   },
   {
     cluster_id: 1,
@@ -21,5 +25,7 @@ export const mock_database_list: DatabaseObject[] = [
     instance_id: "my-id",
     instance_name: "instance-name",
     project_id: 3,
+    infra_id: 3,
+    status: "running",
   },
 ];

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

@@ -2,7 +2,6 @@ import React, { useContext, useEffect, useLayoutEffect } from "react";
 import { Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-import CreateDatabaseForm from "./CreateDatabaseForm";
 import DatabasesHome from "./DatabasesHome";
 
 const DatabasesRoutes = () => {
@@ -23,9 +22,6 @@ const DatabasesRoutes = () => {
   return (
     <>
       <Switch>
-        <Route path={`${url}/provision-database`}>
-          <CreateDatabaseForm />
-        </Route>
         <Route path={`${url}/`}>
           <DatabasesHome />
         </Route>

Некоторые файлы не были показаны из-за большого количества измененных файлов