Procházet zdrojové kódy

Merge pull request #2727 from porter-dev/staging

Capi provisioner
jusrhee před 3 roky
rodič
revize
116ef1bedf
100 změnil soubory, kde provedl 4808 přidání a 959 odebrání
  1. 1 0
      .gitignore
  2. 68 0
      api/server/authz/api_contract.go
  3. 13 10
      api/server/authz/cluster.go
  4. 2 0
      api/server/authz/policy.go
  5. 51 0
      api/server/handlers/api_contract/delete.go
  6. 59 0
      api/server/handlers/api_contract/list.go
  7. 112 0
      api/server/handlers/api_contract/update.go
  8. 73 0
      api/server/handlers/cluster/cluster_status.go
  9. 29 1
      api/server/handlers/cluster/get_kubeconfig.go
  10. 1 1
      api/server/handlers/infra/forms.go
  11. 2 1
      api/server/handlers/project/create.go
  12. 20 2
      api/server/handlers/project_integration/create_aws.go
  13. 35 3
      api/server/handlers/project_integration/list_aws.go
  14. 5 5
      api/server/handlers/release/create.go
  15. 29 0
      api/server/router/cluster.go
  16. 85 0
      api/server/router/project.go
  17. 4 0
      api/server/router/router.go
  18. 11 0
      api/server/shared/config/config.go
  19. 9 0
      api/server/shared/config/env/envconfs.go
  20. 22 0
      api/server/shared/config/loader/loader.go
  21. 28 0
      api/types/cluster.go
  22. 30 16
      api/types/policy.go
  23. 10 8
      api/types/project.go
  24. 1 0
      api/types/project_integration.go
  25. 15 14
      api/types/request.go
  26. 18 2
      cmd/migrate/main.go
  27. 1 0
      dashboard/.npmrc
  28. 586 161
      dashboard/package-lock.json
  29. 1 0
      dashboard/package.json
  30. binární
      dashboard/src/assets/add-circle.png
  31. binární
      dashboard/src/assets/creds.png
  32. binární
      dashboard/src/assets/lightning-square-contained.png
  33. binární
      dashboard/src/assets/lightning.png
  34. binární
      dashboard/src/assets/pencil.png
  35. 21 0
      dashboard/src/assets/settings-centered.svg
  36. 63 0
      dashboard/src/components/ClusterProvisioningPlaceholder.tsx
  37. 343 0
      dashboard/src/components/CredentialsForm.tsx
  38. 157 0
      dashboard/src/components/ProvisionerFlow.tsx
  39. 75 0
      dashboard/src/components/ProvisionerForm.tsx
  40. 341 0
      dashboard/src/components/ProvisionerSettings.tsx
  41. 1 1
      dashboard/src/components/SaveButton.tsx
  42. 22 33
      dashboard/src/components/Selector.tsx
  43. 3 3
      dashboard/src/components/TitleSection.tsx
  44. 2 0
      dashboard/src/components/form-components/SelectRow.tsx
  45. 90 0
      dashboard/src/components/porter/ExpandableSection.tsx
  46. 66 0
      dashboard/src/components/porter/LoadingBar.tsx
  47. 31 0
      dashboard/src/components/porter/Spacer.tsx
  48. 24 0
      dashboard/src/components/porter/TemplateComponent.tsx
  49. 42 0
      dashboard/src/components/porter/Text.tsx
  50. 2 2
      dashboard/src/components/repo-selector/ActionDetails.tsx
  51. 276 306
      dashboard/src/main/home/Home.tsx
  52. 2 2
      dashboard/src/main/home/ModalHandler.tsx
  53. 11 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  54. 19 24
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  55. 394 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterRevisionSelector.tsx
  56. 167 64
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  57. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx
  58. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  59. 126 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx
  60. 9 5
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  61. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  62. 7 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  63. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  64. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  65. 127 144
      dashboard/src/main/home/dashboard/ClusterList.tsx
  66. 1 1
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  67. 173 0
      dashboard/src/main/home/dashboard/ClusterSection.tsx
  68. 70 28
      dashboard/src/main/home/dashboard/Dashboard.tsx
  69. 264 0
      dashboard/src/main/home/dashboard/OldClusterList.tsx
  70. 1 1
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  71. 5 5
      dashboard/src/main/home/integrations/IntegrationList.tsx
  72. 2 1
      dashboard/src/main/home/integrations/Integrations.tsx
  73. 4 3
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  74. 48 3
      dashboard/src/main/home/launch/Launch.tsx
  75. 1 1
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  76. 2 2
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  77. 11 13
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  78. 46 1
      dashboard/src/main/home/onboarding/Onboarding.tsx
  79. 4 3
      dashboard/src/main/home/project-settings/InviteList.tsx
  80. 2 0
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  81. 2 2
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  82. 6 0
      dashboard/src/shared/Context.tsx
  83. 73 6
      dashboard/src/shared/api.tsx
  84. 7 5
      dashboard/src/shared/common.tsx
  85. 6 0
      dashboard/src/shared/types.tsx
  86. 0 0
      docker/Untitled-1
  87. 1 1
      docs/guides/linking-slack-integration.md
  88. 4 0
      ee/api/server/handlers/billing/webhook.go
  89. 1 0
      ee/billing/client.go
  90. 1 0
      ee/billing/types.go
  91. 14 8
      go.mod
  92. 31 16
      go.sum
  93. 3 6
      go.work.sum
  94. 7 0
      internal/helm/agent.go
  95. 99 4
      internal/kubernetes/config.go
  96. 34 0
      internal/models/api_contract_revision.go
  97. 34 0
      internal/models/aws_assume_role_chain.go
  98. 32 10
      internal/models/cluster.go
  99. 15 13
      internal/models/project.go
  100. 56 0
      internal/nats/nats.go

+ 1 - 0
.gitignore

@@ -16,6 +16,7 @@ staging.sh
 bin
 openapi.yaml
 .idea
+vendor
 
 # Local docs directories
 /docs/.obsidian

+ 68 - 0
api/server/authz/api_contract.go

@@ -0,0 +1,68 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/google/uuid"
+	"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 APIContractRevisionScopedFactory struct {
+	config *config.Config
+}
+
+func NewAPIContractRevisionScopedFactory(
+	config *config.Config,
+) *APIContractRevisionScopedFactory {
+	return &APIContractRevisionScopedFactory{config}
+}
+
+func (p *APIContractRevisionScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &APIContractRevisionMiddleware{next, p.config}
+}
+
+type APIContractRevisionMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (n *APIContractRevisionMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	reqScopes, _ := ctx.Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	apiContractRevisionID := reqScopes[types.APIContractRevisionScope].Resource.Name
+
+	uid, err := uuid.Parse(apiContractRevisionID)
+	if err != nil {
+		apierrors.HandleAPIError(n.config.Logger, n.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+
+	fmt.Println("STEFAN", uid)
+	rev, err := n.config.Repo.APIContractRevisioner().Get(ctx, uid)
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(n.config.Logger, n.config.Alerter, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("revision with id %s not found in project %d", apiContractRevisionID, proj.ID),
+			), true)
+			return
+		}
+		apierrors.HandleAPIError(n.config.Logger, n.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		return
+	}
+	fmt.Println("STEFANREV", rev)
+
+	r = r.Clone(NewAPIContractRevisionContext(ctx, rev))
+	n.next.ServeHTTP(w, r)
+}
+
+func NewAPIContractRevisionContext(ctx context.Context, apiContractRevision models.APIContractRevision) context.Context {
+	return context.WithValue(ctx, types.APIContractRevisionScope, apiContractRevision)
+}

+ 13 - 10
api/server/authz/cluster.go

@@ -86,20 +86,23 @@ func NewOutOfClusterAgentGetter(config *config.Config) KubernetesAgentGetter {
 
 func (d *OutOfClusterAgentGetter) GetOutOfClusterConfig(cluster *models.Cluster) *kubernetes.OutOfClusterConfig {
 	return &kubernetes.OutOfClusterConfig{
-		Repo:                      d.config.Repo,
-		DigitalOceanOAuth:         d.config.DOConf,
-		Cluster:                   cluster,
-		AllowInClusterConnections: d.config.ServerConf.InitInCluster,
+		Repo:                        d.config.Repo,
+		DigitalOceanOAuth:           d.config.DOConf,
+		Cluster:                     cluster,
+		AllowInClusterConnections:   d.config.ServerConf.InitInCluster,
+		CAPIManagementClusterClient: d.config.ClusterControlPlaneClient,
 	}
 }
 
 func (d *OutOfClusterAgentGetter) GetAgent(r *http.Request, cluster *models.Cluster, namespace string) (*kubernetes.Agent, error) {
-	// look for the agent in context
-	ctxAgentVal := r.Context().Value(KubernetesAgentCtxKey)
-
-	if ctxAgentVal != nil {
-		if agent, ok := ctxAgentVal.(*kubernetes.Agent); ok {
-			return agent, nil
+	// look for the agent in context if cluster isnt a capi cluster
+	if cluster.ProvisionedBy != "CAPI" {
+		ctxAgentVal := r.Context().Value(KubernetesAgentCtxKey)
+
+		if ctxAgentVal != nil {
+			if agent, ok := ctxAgentVal.(*kubernetes.Agent); ok {
+				return agent, nil
+			}
 		}
 	}
 

+ 2 - 0
api/server/authz/policy.go

@@ -136,6 +136,8 @@ func getRequestActionForEndpoint(
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInviteID)
 		case types.GitlabIntegrationScope:
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamIntegrationID)
+		case types.APIContractRevisionScope:
+			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamAPIContractRevisionID)
 		}
 
 		if reqErr != nil {

+ 51 - 0
api/server/handlers/api_contract/delete.go

@@ -0,0 +1,51 @@
+package api_contract
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/google/uuid"
+	"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 APIContractRevisionDeleteHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPIContractRevisionDeleteHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APIContractRevisionDeleteHandler {
+	return &APIContractRevisionDeleteHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ServeHTTP returns deletes a given project and cluster's contract revision
+func (c *APIContractRevisionDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	revision := ctx.Value(types.APIContractRevisionScope).(models.APIContractRevision)
+
+	if revision.ID == uuid.Nil {
+		e := errors.New("nil revision provided in path")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+
+	err := c.Config().Repo.APIContractRevisioner().Delete(ctx, proj.ID, 0, revision.ID)
+	if err != nil {
+		e := fmt.Errorf("error delete api contract revision: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 59 - 0
api/server/handlers/api_contract/list.go

@@ -0,0 +1,59 @@
+package api_contract
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"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 APIContractRevisionListHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPIContractRevisionListHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APIContractRevisionListHandler {
+	return &APIContractRevisionListHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ServeHTTP returns a list of Porter API contract revisions for a given project.
+// If clusterID is also given, it will list by project_id, cluster_id
+func (c *APIContractRevisionListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	clusterID := 0
+	clusterIDParam := chi.URLParam(r, "cluster_id")
+	if clusterIDParam != "" {
+		i, err := strconv.Atoi(clusterIDParam)
+		if err != nil {
+			e := fmt.Errorf("invalid cluster_id query param given: %w", err)
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+		clusterID = i
+	}
+
+	ctx := r.Context()
+
+	revisions, err := c.Config().Repo.APIContractRevisioner().List(ctx, proj.ID, uint(clusterID))
+	if err != nil {
+		e := fmt.Errorf("error listing api contract revision: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	c.WriteResult(w, r, revisions)
+}

+ 112 - 0
api/server/handlers/api_contract/update.go

@@ -0,0 +1,112 @@
+package api_contract
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+
+	"github.com/bufbuild/connect-go"
+	"github.com/google/uuid"
+	helpers "github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/internal/models"
+)
+
+type APIContractUpdateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPIContractUpdateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APIContractUpdateHandler {
+	return &APIContractUpdateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ServeHTTP parses the Porter API contract for validity, and forwards the requests for handling on to another service
+// For now, this handling cluster creation only, by inserting a row into the cluster table in order to create an ID for this cluster, as well as stores the raw request JSON for updating later
+func (c *APIContractUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	var apiContract porterv1.Contract
+
+	err := helpers.UnmarshalContractObjectFromReader(r.Body, &apiContract)
+	if err != nil {
+		e := fmt.Errorf("error parsing api contract: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+
+	if c.Config().DisableCAPIProvisioner {
+		// return dummy data if capi provisioner disabled
+		// TODO: remove this stub when we can spin up all services locally, easily
+		clusterID := apiContract.Cluster.ClusterId
+		if apiContract.Cluster.ClusterId == 0 {
+			dbcli := models.Cluster{
+				ProjectID:                         uint(apiContract.Cluster.ProjectId),
+				Status:                            "UPDATING_UNAVAILABLE",
+				ProvisionedBy:                     "CAPI",
+				CloudProvider:                     "AWS",
+				CloudProviderCredentialIdentifier: apiContract.Cluster.CloudProviderCredentialsId,
+				Name:                              apiContract.Cluster.GetEksKind().ClusterName,
+				VanityName:                        apiContract.Cluster.GetEksKind().ClusterName,
+			}
+			dbcl, err := c.Config().Repo.Cluster().CreateCluster(&dbcli)
+			if err != nil {
+				e := fmt.Errorf("error updating mock contract: %w", err)
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+				return
+			}
+			clusterID = int32(dbcl.ID)
+		}
+
+		by, err := helpers.MarshalContractObject(ctx, &apiContract)
+		if err != nil {
+			e := fmt.Errorf("error marshalling mock api contract: %w", err)
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+		b64Contract := base64.StdEncoding.EncodeToString([]byte(by))
+
+		revisionInput := models.APIContractRevision{
+			ID:             uuid.New(),
+			ClusterID:      int(clusterID),
+			ProjectID:      int(apiContract.Cluster.ProjectId),
+			Base64Contract: b64Contract,
+		}
+		revision, err := c.Config().Repo.APIContractRevisioner().Insert(ctx, revisionInput)
+		if err != nil {
+			e := fmt.Errorf("error updating mock contract: %w", err)
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+		resp := &porterv1.ContractRevision{
+			ClusterId:  int32(clusterID),
+			ProjectId:  apiContract.Cluster.ProjectId,
+			RevisionId: revision.ID.String(),
+		}
+		w.WriteHeader(http.StatusCreated)
+		c.WriteResult(w, r, resp)
+		return
+	}
+
+	updateRequest := connect.NewRequest(&porterv1.UpdateContractRequest{
+		Contract: &apiContract,
+	})
+	revision, err := c.Config().ClusterControlPlaneClient.UpdateContract(ctx, updateRequest)
+	if err != nil {
+		e := fmt.Errorf("error sending contract for update: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, revision.Msg)
+}

+ 73 - 0
api/server/handlers/cluster/cluster_status.go

@@ -0,0 +1,73 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/bufbuild/connect-go"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ClusterStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewClusterStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ClusterStatusHandler {
+	return &ClusterStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+type ClusterStatusResponse struct {
+	ProjectID             int    `json:"project_id"`
+	ClusterID             int    `json:"cluster_id"`
+	Phase                 string `json:"phase"`
+	IsInfrastructureReady bool   `json:"is_infrastructure_ready"`
+	IsControlPlaneReady   bool   `json:"is_control_plane_ready"`
+}
+
+func (c *ClusterStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	req := connect.NewRequest(&porterv1.ClusterStatusRequest{
+		ProjectId: int64(cluster.ProjectID),
+		ClusterId: int64(cluster.ID),
+	})
+	status, err := c.Config().ClusterControlPlaneClient.ClusterStatus(ctx, req)
+	if err != nil {
+		e := fmt.Errorf("unable to retrieve status for cluster: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+	if status.Msg == nil {
+		e := fmt.Errorf("unable to parse status for cluster: %w", err)
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+	statusResp := status.Msg
+
+	resp := ClusterStatusResponse{
+		ProjectID:             int(statusResp.ProjectId),
+		ClusterID:             int(statusResp.ClusterId),
+		Phase:                 statusResp.Phase,
+		IsInfrastructureReady: statusResp.InfrastructureStatus,
+		IsControlPlaneReady:   statusResp.ControlPlaneStatus,
+	}
+
+	c.WriteResult(w, r, resp)
+	w.WriteHeader(http.StatusOK)
+}

+ 29 - 1
api/server/handlers/cluster/get_kubeconfig.go

@@ -1,9 +1,13 @@
 package cluster
 
 import (
+	"context"
 	"errors"
+	"fmt"
 	"net/http"
 
+	"github.com/bufbuild/connect-go"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -36,11 +40,35 @@ func (c *GetTemporaryKubeconfigHandler) ServeHTTP(w http.ResponseWriter, r *http
 		))
 		return
 	}
+	ctx := r.Context()
 
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
 
 	outOfClusterConfig := c.GetOutOfClusterConfig(cluster)
 
+	if cluster.ProvisionedBy == "CAPI" {
+		kubeconfigResp, err := c.Config().ClusterControlPlaneClient.KubeConfigForCluster(context.Background(), connect.NewRequest(
+			&porterv1.KubeConfigForClusterRequest{
+				ProjectId: int64(cluster.ProjectID),
+				ClusterId: int64(cluster.ID),
+			},
+		))
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting temporary capi config: %w", err)))
+			return
+		}
+
+		if kubeconfigResp.Msg == nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error reading temporary capi config: %w", err)))
+			return
+		}
+		res := &types.GetTemporaryKubeconfigResponse{
+			Kubeconfig: []byte(kubeconfigResp.Msg.KubeConfig),
+		}
+		c.WriteResult(w, r, res)
+		return
+	}
+
 	kubeconfig, err := outOfClusterConfig.CreateRawConfigFromCluster()
 
 	if err != nil {

+ 1 - 1
api/server/handlers/infra/forms.go

@@ -1252,4 +1252,4 @@ tabs:
       required: true
       placeholder: my-cluster
       variable: cluster_name
-`
+`

+ 2 - 1
api/server/handlers/project/create.go

@@ -40,7 +40,8 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 
 	proj := &models.Project{
-		Name: request.Name,
+		Name:                   request.Name,
+		CapiProvisionerEnabled: true,
 	}
 
 	var err error

+ 20 - 2
api/server/handlers/project_integration/create_aws.go

@@ -1,8 +1,11 @@
 package project_integration
 
 import (
+	"fmt"
 	"net/http"
 
+	"github.com/bufbuild/connect-go"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"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"
@@ -29,9 +32,9 @@ func NewCreateAWSHandler(
 func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	ctx := r.Context()
 
 	request := &types.CreateAWSRequest{}
-
 	if ok := p.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
@@ -39,7 +42,6 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	aws := CreateAWSIntegration(request, project.ID, user.ID)
 
 	aws, err := p.Repo().AWSIntegration().CreateAWSIntegration(aws)
-
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -49,6 +51,22 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		AWSIntegration: aws.ToAWSIntegrationType(),
 	}
 
+	if !p.Config().DisableCAPIProvisioner {
+		credReq := porterv1.CreateAssumeRoleChainRequest{
+			ProjectId:       int64(project.ID),
+			SourceArn:       "arn:aws:iam::108458755588:role/CAPIManagement", // hard coded as this is the final hop for a CAPI cluster
+			TargetAccessId:  request.AWSAccessKeyID,
+			TargetSecretKey: request.AWSSecretAccessKey,
+		}
+		credResp, err := p.Config().ClusterControlPlaneClient.CreateAssumeRoleChain(ctx, connect.NewRequest(&credReq))
+		if err != nil {
+			e := fmt.Errorf("unable to create CAPI required credential: %w", err)
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+		res.CloudProviderCredentialIdentifier = credResp.Msg.TargetArn
+	}
+
 	p.WriteResult(w, r, res)
 }
 

+ 35 - 3
api/server/handlers/project_integration/list_aws.go

@@ -1,6 +1,7 @@
 package project_integration
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -24,18 +25,49 @@ func NewListAWSHandler(
 	}
 }
 
+// ListAWSAssumeRoleLink summarises the responses for AWS assume role chain links.
+// This is only intended for CAPI projects
+type ListAWSAssumeRoleLink struct {
+	// ID is the ID of the assume role chain in the db. UUID as a string
+	ID string `json:"id"`
+	// ARN is the target ARN in an AWS assume role chain
+	ARN string `json:"aws_arn"`
+	// ProjectID is the projec that this link belongs to
+	ProjectID int `json:"project_id"`
+}
+
 func (p *ListAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	ctx := r.Context()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	awsInts, err := p.Repo().AWSIntegration().ListAWSIntegrationsByProjectID(project.ID)
+	if project.CapiProvisionerEnabled {
+		dblinks, err := p.Repo().AWSAssumeRoleChainer().List(ctx, project.ID)
+		if err != nil {
+			e := fmt.Errorf("unable to find assume role chain links: %w", err)
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+
+		var links []ListAWSAssumeRoleLink
+		for _, link := range dblinks {
+			links = append(links, ListAWSAssumeRoleLink{
+				ID:        link.ID.String(),
+				ARN:       link.TargetARN,
+				ProjectID: link.ProjectID,
+			})
+		}
+		p.WriteResult(w, r, links)
+		w.WriteHeader(http.StatusOK)
+		return
+	}
 
+	awsInts, err := p.Repo().AWSIntegration().ListAWSIntegrationsByProjectID(project.ID)
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	var res types.ListAWSResponse = make([]*types.AWSIntegration, 0)
-
 	for _, awsInt := range awsInts {
 		res = append(res, awsInt.ToAWSIntegrationType())
 	}

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

@@ -63,7 +63,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	helmAgent, err := c.GetHelmAgent(r, cluster, "")
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
 		return
 	}
 
@@ -83,7 +83,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		hrs, err := c.Repo().HelmRepo().ListHelmReposByProjectID(cluster.ProjectID)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing helm repos for project : %w", err)))
 			return
 		}
 
@@ -106,14 +106,14 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	chart, err := loader.LoadChartPublic(request.RepoURL, request.TemplateName, request.TemplateVersion)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error loading public chart: %w", err)))
 		return
 	}
 
 	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing registries: %w", err)))
 		return
 	}
 
@@ -141,7 +141,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	k8sAgent, err := c.GetAgent(r, cluster, "")
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
 		return
 	}
 

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

@@ -1012,6 +1012,35 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/state -> cluster.NewClusterStatusHandler
+	clusterStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/state",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	clusterStatusHandler := cluster.NewClusterStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: clusterStatusEndpoint,
+		Handler:  clusterStatusHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/{kind}/status -> cluster.NewStreamStatusHandler
 	streamStatusEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 85 - 0
api/server/router/project.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/go-chi/chi"
+	apiContract "github.com/porter-dev/porter/api/server/handlers/api_contract"
 	"github.com/porter-dev/porter/api/server/handlers/api_token"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
@@ -1261,5 +1262,89 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/contract -> apiContract.NewAPIContractUpdateHandler
+	updateAPIContractEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/contract",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	updateAPIContractHandler := apiContract.NewAPIContractUpdateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateAPIContractEndpoint,
+		Handler:  updateAPIContractHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/contracts -> apiContract.NewAPIContractRevisionListHandler
+	listAPIContractRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/contracts",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listAPIContractRevisionHandler := apiContract.NewAPIContractRevisionListHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+	routes = append(routes, &router.Route{
+		Endpoint: listAPIContractRevisionsEndpoint,
+		Handler:  listAPIContractRevisionHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/contracts/{revision_id} -> apiContract.NewAPIContractUpdateHandler
+	deleteAPIContractRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/contracts/{%s}", relPath, types.URLParamAPIContractRevisionID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.APIContractRevisionScope,
+			},
+		},
+	)
+
+	deleteAPIContractRevisionHandler := apiContract.NewAPIContractRevisionDeleteHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteAPIContractRevisionsEndpoint,
+		Handler:  deleteAPIContractRevisionHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

@@ -235,6 +235,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// preview environment middleware to handle previw environments for a specific project-cluster pair
 	previewEnvFactory := authz.NewPreviewEnvironmentScopedFactory(config)
 
+	apiContractRevisionFactory := authz.NewAPIContractRevisionScopedFactory(config)
+
 	for _, route := range routes {
 		atomicGroup := route.Router.Group(nil)
 
@@ -276,6 +278,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(gitlabIntFactory.Middleware)
 			case types.PreviewEnvironmentScope:
 				atomicGroup.Use(previewEnvFactory.Middleware)
+			case types.APIContractRevisionScope:
+				atomicGroup.Use(apiContractRevisionFactory.Middleware)
 			}
 		}
 

+ 11 - 0
api/server/shared/config/config.go

@@ -2,6 +2,7 @@ package config
 
 import (
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
 	"github.com/porter-dev/porter/api/server/shared/websocket"
@@ -10,6 +11,7 @@ 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/nats"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
@@ -93,9 +95,18 @@ type Config struct {
 	// PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
 	PowerDNSClient *powerdns.Client
 
+	// ClusterControlPlaneClient is a client for ClusterControlPlane
+	ClusterControlPlaneClient porterv1connect.ClusterControlPlaneServiceClient
+
 	// CredentialBackend is the backend for credential storage, if external cred storage (like Vault)
 	// is used
 	CredentialBackend credentials.CredentialStorage
+
+	// NATS contains the required config for connecting to a NATS cluster for streaming
+	NATS nats.NATS
+
+	// DisableCAPIProvisioner disables checks for ClusterControlPlaneClient and NATS, if set to true
+	DisableCAPIProvisioner bool
 }
 
 type ConfigLoader interface {

+ 9 - 0
api/server/shared/config/env/envconfs.go

@@ -76,6 +76,9 @@ type ServerConf struct {
 	ProvisionerServerURL string `env:"PROVISIONER_SERVER_URL"`
 	ProvisionerToken     string `env:"PROVISIONER_TOKEN"`
 
+	// ClusterControlPlane settings
+	ClusterControlPlaneAddress string `env:"CLUSTER_CONTROL_PLANE_ADDRESS"`
+
 	SegmentClientKey string `env:"SEGMENT_CLIENT_KEY"`
 
 	// PowerDNS client API key and the host of the PowerDNS API server
@@ -119,6 +122,12 @@ type ServerConf struct {
 	// create a temporary kubeconfig file for a cluster. When set to true, the
 	// /api/projects/{project_id}/clusters/{cluster_id}/kubeconfig will be disabled.
 	DisableTemporaryKubeconfig bool `env:"DISABLE_TEMPORARY_KUBECONFIG,default=false"`
+
+	// NATSUrl is the URL of the NATS cluster
+	NATSUrl string `env:"NATS_URL"`
+
+	// DisableCAPIProvisioner disables checks for ClusterControlPlaneClient and NATS, if set to true
+	DisableCAPIProvisioner bool `env:"DISABLE_CAPI_PROVISIONER"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 22 - 0
api/server/shared/config/loader/loader.go

@@ -1,12 +1,14 @@
 package loader
 
 import (
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"net/http"
 	"strconv"
 
 	gorillaws "github.com/gorilla/websocket"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
@@ -58,6 +60,8 @@ func sharedInit() {
 }
 
 func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
+	// ctx := context.Background()
+
 	envConf := InstanceEnvConf
 	sc := envConf.ServerConf
 
@@ -226,6 +230,24 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.PowerDNSClient = powerdns.NewClient(sc.PowerDNSAPIServerURL, sc.PowerDNSAPIKey, sc.AppRootDomain)
 	}
 
+	res.DisableCAPIProvisioner = sc.DisableCAPIProvisioner
+	if !sc.DisableCAPIProvisioner {
+		if sc.ClusterControlPlaneAddress == "" {
+			return res, errors.New("must provide CLUSTER_CONTROL_PLANE_ADDRESS")
+		}
+		client := porterv1connect.NewClusterControlPlaneServiceClient(http.DefaultClient, sc.ClusterControlPlaneAddress)
+		res.ClusterControlPlaneClient = client
+
+		// if sc.NATSUrl == "" {
+		// 	return res, errors.New("must provide NATS_URL")
+		// }
+		// pnats, err := nats.NewConnection(ctx, nats.Config{URL: sc.NATSUrl})
+		// if err != nil {
+		// 	return res, fmt.Errorf("error setting up connection to NATS cluster: %w", err)
+		// }
+		// res.NATS = pnats
+	}
+
 	return res, nil
 }
 

+ 28 - 0
api/types/cluster.go

@@ -18,6 +18,9 @@ type Cluster struct {
 	// Name of the cluster
 	Name string `json:"name"`
 
+	// VanityName is the display name of the cluster
+	VanityName string `json:"vanity_name"`
+
 	// Server endpoint for the cluster
 	Server string `json:"server"`
 
@@ -38,6 +41,20 @@ type Cluster struct {
 
 	// Whether preview environments is enabled on this cluster
 	PreviewEnvsEnabled bool `json:"preview_envs_enabled"`
+
+	// Cluster provisioning status if managed by Porter
+	Status ClusterStatus `json:"status"`
+
+	// ProvisionedBy is used for identifing the provisioner used for the cluster. Accepted values: [CAPI, ]
+	ProvisionedBy string `json:"provisioned_by"`
+
+	// CloudProvider is the cloud provider that hosts the Kubernetes Cluster. Accepted values: [AWS, GCP, AZURE]
+	CloudProvider string `json:"cloud_provider"`
+
+	// CloudProviderCredentialIdentifier is a reference to find the credentials required for access the cluster's API.
+	// This was likely the credential that was used to create the cluster.
+	// For AWS EKS clusters, this will be an ARN for the final target role in the assume role chain.
+	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
 }
 
 type ClusterCandidate struct {
@@ -168,6 +185,17 @@ type ClusterGetResponse struct {
 	IngressError error `json:"ingress_error"`
 }
 
+// ClusterStatus to track provisioning state
+type ClusterStatus string
+
+const (
+	Ready    ClusterStatus = "READY"
+	Updating ClusterStatus = "UPDATING"
+
+	// For initial provisioning or for when the cluster is updating but not ready
+	UpdatingUnavailable ClusterStatus = "UPDATING_UNAVAILABLE"
+)
+
 type ClusterService string
 
 const (

+ 30 - 16
api/types/policy.go

@@ -5,21 +5,22 @@ import "time"
 type PermissionScope string
 
 const (
-	UserScope               PermissionScope = "user"
-	ProjectScope            PermissionScope = "project"
-	ClusterScope            PermissionScope = "cluster"
-	RegistryScope           PermissionScope = "registry"
-	InviteScope             PermissionScope = "invite"
-	HelmRepoScope           PermissionScope = "helm_repo"
-	InfraScope              PermissionScope = "infra"
-	OperationScope          PermissionScope = "operation"
-	GitInstallationScope    PermissionScope = "git_installation"
-	NamespaceScope          PermissionScope = "namespace"
-	SettingsScope           PermissionScope = "settings"
-	ReleaseScope            PermissionScope = "release"
-	StackScope              PermissionScope = "stack"
-	GitlabIntegrationScope  PermissionScope = "gitlab_integration"
-	PreviewEnvironmentScope PermissionScope = "preview_environment"
+	UserScope                PermissionScope = "user"
+	ProjectScope             PermissionScope = "project"
+	ClusterScope             PermissionScope = "cluster"
+	RegistryScope            PermissionScope = "registry"
+	InviteScope              PermissionScope = "invite"
+	HelmRepoScope            PermissionScope = "helm_repo"
+	InfraScope               PermissionScope = "infra"
+	OperationScope           PermissionScope = "operation"
+	GitInstallationScope     PermissionScope = "git_installation"
+	NamespaceScope           PermissionScope = "namespace"
+	SettingsScope            PermissionScope = "settings"
+	ReleaseScope             PermissionScope = "release"
+	StackScope               PermissionScope = "stack"
+	GitlabIntegrationScope   PermissionScope = "gitlab_integration"
+	PreviewEnvironmentScope  PermissionScope = "preview_environment"
+	APIContractRevisionScope PermissionScope = "contract_revision"
 )
 
 type NameOrUInt struct {
@@ -56,7 +57,8 @@ var ScopeHeirarchy = ScopeTree{
 		InfraScope: {
 			OperationScope: {},
 		},
-		SettingsScope: {},
+		SettingsScope:            {},
+		APIContractRevisionScope: {},
 	},
 }
 
@@ -91,6 +93,10 @@ var AdminPolicy = []*PolicyDocument{
 				Scope: SettingsScope,
 				Verbs: ReadWriteVerbGroup(),
 			},
+			APIContractRevisionScope: {
+				Scope: APIContractRevisionScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
 		},
 	},
 }
@@ -124,6 +130,10 @@ var DeveloperPolicy = []*PolicyDocument{
 				Scope: SettingsScope,
 				Verbs: ReadVerbGroup(),
 			},
+			APIContractRevisionScope: {
+				Scope: APIContractRevisionScope,
+				Verbs: ReadWriteVerbGroup(),
+			},
 		},
 	},
 }
@@ -157,6 +167,10 @@ var ViewerPolicy = []*PolicyDocument{
 				Scope: SettingsScope,
 				Verbs: []APIVerb{},
 			},
+			APIContractRevisionScope: {
+				Scope: APIContractRevisionScope,
+				Verbs: ReadVerbGroup(),
+			},
 		},
 	},
 }

+ 10 - 8
api/types/project.go

@@ -1,14 +1,15 @@
 package types
 
 type Project struct {
-	ID                  uint    `json:"id"`
-	Name                string  `json:"name"`
-	Roles               []*Role `json:"roles"`
-	PreviewEnvsEnabled  bool    `json:"preview_envs_enabled"`
-	RDSDatabasesEnabled bool    `json:"enable_rds_databases"`
-	ManagedInfraEnabled bool    `json:"managed_infra_enabled"`
-	APITokensEnabled    bool    `json:"api_tokens_enabled"`
-	StacksEnabled       bool    `json:"stacks_enabled"`
+	ID                     uint    `json:"id"`
+	Name                   string  `json:"name"`
+	Roles                  []*Role `json:"roles"`
+	PreviewEnvsEnabled     bool    `json:"preview_envs_enabled"`
+	RDSDatabasesEnabled    bool    `json:"enable_rds_databases"`
+	ManagedInfraEnabled    bool    `json:"managed_infra_enabled"`
+	APITokensEnabled       bool    `json:"api_tokens_enabled"`
+	StacksEnabled          bool    `json:"stacks_enabled"`
+	CapiProvisionerEnabled bool    `json:"capi_provisioner_enabled"`
 }
 
 type FeatureFlags struct {
@@ -16,6 +17,7 @@ type FeatureFlags struct {
 	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
 	StacksEnabled              string `json:"stacks_enabled,omitempty"`
 	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
+	CapiProvisionerEnabled     string `json:"capi_provisioner_enabled,omitempty"`
 }
 
 type CreateProjectRequest struct {

+ 1 - 0
api/types/project_integration.go

@@ -85,6 +85,7 @@ type CreateAWSRequest struct {
 
 type CreateAWSResponse struct {
 	*AWSIntegration
+	CloudProviderCredentialIdentifier string `json:"cloud_provider_credentials_id"`
 }
 
 type OverwriteAWSRequest struct {

+ 15 - 14
api/types/request.go

@@ -33,20 +33,21 @@ const (
 type URLParam string
 
 const (
-	URLParamProjectID         URLParam = "project_id"
-	URLParamClusterID         URLParam = "cluster_id"
-	URLParamRegistryID        URLParam = "registry_id"
-	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"
-	URLParamStackID           URLParam = "stack_id"
-	URLParamReleaseVersion    URLParam = "version"
-	URLParamWildcard          URLParam = "*"
-	URLParamIntegrationID     URLParam = "integration_id"
+	URLParamProjectID             URLParam = "project_id"
+	URLParamClusterID             URLParam = "cluster_id"
+	URLParamRegistryID            URLParam = "registry_id"
+	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"
+	URLParamStackID               URLParam = "stack_id"
+	URLParamReleaseVersion        URLParam = "version"
+	URLParamWildcard              URLParam = "*"
+	URLParamIntegrationID         URLParam = "integration_id"
+	URLParamAPIContractRevisionID URLParam = "contract_revision_id"
 )
 
 type Path struct {

+ 18 - 2
cmd/migrate/main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"errors"
+	"fmt"
 	"log"
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
@@ -37,12 +38,10 @@ func main() {
 	}
 
 	err = gorm.AutoMigrate(db, envConf.ServerConf.Debug)
-
 	if err != nil {
 		logger.Fatal().Err(err).Msg("gorm auto-migration failed")
 		return
 	}
-
 	if err := db.Raw("ALTER TABLE clusters DROP CONSTRAINT IF EXISTS fk_cluster_token_caches").Error; err != nil {
 		logger.Fatal().Err(err).Msg("failed to drop cluster token cache constraint")
 		return
@@ -52,6 +51,23 @@ func main() {
 		return
 	}
 
+	err = db.Transaction(func(tx *pgorm.DB) error {
+		if err := db.Exec("alter table aws_assume_role_chains DROP CONSTRAINT IF EXISTS fk_projects;").Error; err != nil {
+			return fmt.Errorf("failed to drop fk constraint for assume role chains: %w", err)
+		}
+		if err := db.Exec("alter table aws_assume_role_chains ADD CONSTRAINT fk_projects FOREIGN KEY(project_id) REFERENCES projects(id);").Error; err != nil {
+			return fmt.Errorf("failed to create fk constraint for assume role chains: %w", err)
+		}
+
+		if err := db.Exec("alter table aws_assume_role_chains ADD unique (project_id, source_arn, target_arn);").Error; err != nil {
+			return fmt.Errorf("failed to create unique constraint for assume role chains: %w", err)
+		}
+		return nil
+	})
+	if err != nil {
+		logger.Fatal().Err(err).Msg("error updating cluster control plane tables")
+	}
+
 	tx := db.Begin()
 
 	switch tx.Dialector.Name() {

+ 1 - 0
dashboard/.npmrc

@@ -0,0 +1 @@
+porter-dev:registry=https://npm.pkg.github.com

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 586 - 161
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -7,6 +7,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
+    "@porter-dev/api-contracts": "^0.0.41",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",

binární
dashboard/src/assets/add-circle.png


binární
dashboard/src/assets/creds.png


binární
dashboard/src/assets/lightning-square-contained.png


binární
dashboard/src/assets/lightning.png


binární
dashboard/src/assets/pencil.png


+ 21 - 0
dashboard/src/assets/settings-centered.svg

@@ -0,0 +1,21 @@
+<svg width="28" height="30" viewBox="0 0 28 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_4_2)">
+<g filter="url(#filter0_d_4_2)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M21.9024 16.58C22.26 16.77 22.536 17.07 22.7301 17.37C23.1083 17.99 23.0776 18.75 22.7097 19.42L21.9943 20.62C21.6162 21.26 20.9111 21.66 20.1855 21.66C19.8278 21.66 19.4292 21.56 19.1022 21.36C18.8365 21.19 18.5299 21.13 18.2029 21.13C17.1911 21.13 16.3429 21.96 16.3122 22.95C16.3122 24.1 15.372 25 14.1968 25H12.8069C11.6215 25 10.6813 24.1 10.6813 22.95C10.6608 21.96 9.81259 21.13 8.80085 21.13C8.4636 21.13 8.15702 21.19 7.90153 21.36C7.5745 21.56 7.16572 21.66 6.81825 21.66C6.08244 21.66 5.37729 21.26 4.99917 20.62L4.29402 19.42C3.91589 18.77 3.89545 17.99 4.27358 17.37C4.43709 17.07 4.74368 16.77 5.09115 16.58C5.37729 16.44 5.56125 16.21 5.73498 15.94C6.24596 15.08 5.93937 13.95 5.07071 13.44C4.05897 12.87 3.73194 11.6 4.31446 10.61L4.99917 9.43C5.5919 8.44 6.85913 8.09 7.88109 8.67C8.77019 9.15 9.92499 8.83 10.4462 7.98C10.6097 7.7 10.7017 7.4 10.6813 7.1C10.6608 6.71 10.7732 6.34 10.9674 6.04C11.3455 5.42 12.0302 5.02 12.7763 5H14.2172C14.9735 5 15.6582 5.42 16.0363 6.04C16.2203 6.34 16.3429 6.71 16.3122 7.1C16.2918 7.4 16.3838 7.7 16.5473 7.98C17.0685 8.83 18.2233 9.15 19.1226 8.67C20.1344 8.09 21.4118 8.44 21.9943 9.43L22.679 10.61C23.2718 11.6 22.9448 12.87 21.9228 13.44C21.0541 13.95 20.7475 15.08 21.2687 15.94C21.4323 16.21 21.6162 16.44 21.9024 16.58ZM10.6097 15.01C10.6097 16.58 11.9076 17.83 13.5121 17.83C15.1166 17.83 16.3838 16.58 16.3838 15.01C16.3838 13.44 15.1166 12.18 13.5121 12.18C11.9076 12.18 10.6097 13.44 10.6097 15.01Z" fill="white"/>
+</g>
+</g>
+<defs>
+<filter id="filter0_d_4_2" x="0" y="5" width="27" height="28" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dy="4"/>
+<feGaussianBlur stdDeviation="2"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4_2"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4_2" result="shape"/>
+</filter>
+<clipPath id="clip0_4_2">
+<rect width="28" height="30" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 63 - 0
dashboard/src/components/ClusterProvisioningPlaceholder.tsx

@@ -0,0 +1,63 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
+
+import loading from "assets/loading.gif";
+
+import { Context } from "shared/Context";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+
+type Props = {};
+
+const ClusterProvisioningPlaceholder: React.FC<RouteComponentProps> = (props) => {
+  const { currentCluster } = useContext(Context);
+
+  return (
+    <ClusterPlaceholder>
+      <Heading isAtTop>
+        <Img src={loading} /> Your cluster is being created
+      </Heading>
+      <Helper>
+        You can view the status of your cluster creation{" "}
+        <Link onClick={() => {
+          pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+            cluster: currentCluster.name,
+          });
+        }}>
+          here
+          <i className="material-icons">arrow_forward</i> 
+        </Link>
+      </Helper>
+    </ClusterPlaceholder>
+  );
+};
+
+export default withRouter(ClusterProvisioningPlaceholder);
+
+const Link = styled.a`
+  text-decoration: underline;
+  position: relative;
+  cursor: pointer;
+  > i {
+    color: #aaaabb;
+    font-size: 15px;
+    position: absolute;
+    right: -17px;
+    top: 1px;
+  }
+`;
+
+const Img = styled.img`
+  height: 15px;
+  margin-right: 15px;
+`;
+
+const ClusterPlaceholder = styled.div`
+  padding: 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  padding-bottom: 10px;
+`;

+ 343 - 0
dashboard/src/components/CredentialsForm.tsx

@@ -0,0 +1,343 @@
+import React, { useEffect, useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import aws from "assets/aws.png";
+import credsIcon from "assets/creds.png";
+import addCircle from "assets/add-circle.png";
+
+import { Context } from "shared/Context";
+
+import Heading from "components/form-components/Heading";
+import Helper from "./form-components/Helper";
+import InputRow from "./form-components/InputRow";
+import SaveButton from "./SaveButton";
+import Loading from "./Loading";
+
+type Props = {
+  goBack: () => void;
+  proceed: (x: any) => void;
+};
+
+type AWSCredential = {
+  created_at: string;
+  id: number;
+  user_id: number;
+  project_id: number;
+  aws_arn: string;
+};
+
+
+const CredentialsForm: React.FC<Props> = ({
+  goBack,
+  proceed,
+}) => {
+  const { currentProject } = useContext(Context);
+  const [awsCredentials, setAWSCredentials] = useState<AWSCredential[]>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [awsAccessKeyID, setAWSAccessKeyID] = useState("");
+  const [awsSecretAccessKey, setAWSSecretAccessKey] = useState("");
+  const [selectedCredentials, setSelectedCredentials] = useState<AWSCredential>(null);
+  const [showCreateForm, setShowCreateForm] = useState(false);
+  const [createStatus, setCreateStatus] = useState("");
+
+  useEffect(() => {
+    api
+      .getAWSIntegration(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        if (!Array.isArray(data)) {
+          setAWSCredentials([]);
+        } else {
+          setAWSCredentials(data);
+        }
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject]);
+
+  const createCreds = () => {
+    setCreateStatus("loading");
+
+    api
+      .createAWSIntegration(
+        "<token>",
+        {
+          // Hardcoded for backward-compatibility
+          // TODO: remove
+          aws_region: "us-east-f",
+
+          aws_access_key_id: awsAccessKeyID,
+          aws_secret_access_key: awsSecretAccessKey,
+          aws_assume_role_arn: "",
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setCreateStatus("successful");
+        proceed(data.cloud_provider_credentials_id);
+      })
+      .catch((err) => {
+        console.error(err);
+        setCreateStatus("Error creating credentials");
+      });
+  };
+
+  const renderContent = () => {
+    if (awsCredentials.length > 0 && !showCreateForm) {
+      return (
+        <>
+          <CredentialList>
+            {
+              awsCredentials.map((cred: AWSCredential, i: number) => {
+                return (
+                  <Credential
+                    key={cred.id}
+                    isSelected={cred.id === selectedCredentials?.id}
+                    onClick={() => {
+                      if (cred.id === selectedCredentials?.id) {
+                        setSelectedCredentials(null);
+                      } else {
+                        setSelectedCredentials(cred);
+                      }
+                    }}
+                  >
+                    <Icon src={credsIcon} />
+                    <Name>{cred.aws_arn || "n/a"}</Name>
+                  </Credential>
+                );
+              })
+            }
+            <CreateRow onClick={() => {
+              setShowCreateForm(true);
+              setSelectedCredentials(null);
+            }}>
+              <Icon src={addCircle} />
+              Add new AWS credentials
+            </CreateRow>
+          </CredentialList>
+          <Br height="34px" />
+          <SaveButton
+            disabled={!selectedCredentials && true}
+            onClick={() => proceed(selectedCredentials.id)}
+            clearPosition
+            text="Continue"
+          />
+        </>
+      );
+    }
+    return (
+      <>
+        <StyledForm>
+          {
+            awsCredentials.length > 0 && (
+              <CloseButton onClick={() => setShowCreateForm(false)}>
+                <i className="material-icons">close</i>
+              </CloseButton>
+            )
+          }
+          <InputRow
+            type="string"
+            value={awsAccessKeyID}
+            setValue={(e: string) => setAWSAccessKeyID(e)}
+            label="👤 AWS access ID"
+            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+            isRequired
+          />
+          <InputRow
+            type="password"
+            value={awsSecretAccessKey}
+            setValue={(e: string) => setAWSSecretAccessKey(e)}
+            label="🔒 AWS secret key"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            isRequired
+          />
+        </StyledForm>
+        <SaveButton
+          disabled={awsAccessKeyID === "" || awsSecretAccessKey === ""}
+          onClick={createCreds}
+          status={createStatus}
+          statusPosition="right"
+          clearPosition
+          text="Continue"
+        />
+      </>
+    );
+  }
+
+  return (
+    <>
+      <Heading isAtTop>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer />
+        <Img src={aws} />
+        Set AWS credentials
+      </Heading>
+      <Helper>
+        Select your credentials from the list below, or add a new set of credentials:
+      </Helper>
+      {
+        isLoading ? (
+          <Loading height="150px" />
+        ) : (
+          renderContent()
+        )
+      }
+    </>
+  );
+};
+
+export default CredentialsForm;
+
+const CloseButton = styled.div`
+  position: absolute;
+  top: 15px;
+  right: 15px;
+  padding: 5px;
+  border-radius: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+    > i {
+      color: #ffffff;
+    }
+  }
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const Spacer = styled.div`
+  height: 1px;
+  width: 17px;
+`;
+
+const Icon = styled.img`
+  width: 15px;
+  margin-right: 15px;
+`;
+
+const CreateRow = styled.div`
+  height: 50px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  font-size: 13px;
+  padding: 20px;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff18; 
+  }
+`;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "20px"};
+`;
+
+const Img = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const BackArrow = styled.div`
+  width: 30px;
+  height: 30px;
+  margin-left: -5px;
+  margin-right: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 10px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const Name = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+`;
+
+const Credential = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
+  height: 50px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 20px;
+  border-bottom: ${props => props.isLast ? "" : "1px solid #7a7b80"};
+  background: ${props => props.isSelected ? "#ffffff33" : "#ffffff11"};
+
+  :hover {
+    background: ${props => props.isSelected ? "" : "#ffffff18"}; 
+  }
+`;
+
+const CredentialList = styled.div`
+  width: 100%;
+  border: 1px solid #7a7b80;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding: 15px 30px 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+`;

+ 157 - 0
dashboard/src/components/ProvisionerFlow.tsx

@@ -0,0 +1,157 @@
+import React, { useState, useContext, useMemo } from "react";
+import styled from "styled-components";
+
+import { integrationList } from "shared/common";
+import { Context } from "shared/Context";
+
+import ProvisionerForm from "components/ProvisionerForm";
+import CredentialsForm from "components/CredentialsForm";
+import Helper from "components/form-components/Helper";
+
+const providers = ["aws", "gcp", "azure"];
+
+type Props = {
+};
+
+const ProvisionerFlow: React.FC<Props> = ({
+}) => {
+  const { usage, hasBillingEnabled } = useContext(Context);
+  const [currentStep, setCurrentStep] = useState("cloud");
+  const [credentialId, setCredentialId] = useState("");
+
+  const isUsageExceeded = useMemo(() => {
+    if (!hasBillingEnabled) {
+      return false;
+    }
+    return usage?.current.clusters >= usage?.limit.clusters;
+  }, [usage]);
+
+  if (currentStep === "cloud") {
+    return (
+      <StyledProvisionerFlow>
+        <Helper>
+          Select your hosting backend:
+        </Helper>
+        <BlockList>
+          {providers.map((provider: string, i: number) => {
+            let providerInfo = integrationList[provider];
+            return (
+              <Block
+                key={i}
+                disabled={isUsageExceeded || provider === "gcp" || provider === "azure"}
+                onClick={() => {
+                  if (!(isUsageExceeded || provider === "gcp" || provider === "azure")) {
+                    setCurrentStep("credentials");
+                  }
+                }}
+              >
+                <Icon src={providerInfo.icon} />
+                <BlockTitle>{providerInfo.label}</BlockTitle>
+                <BlockDescription>{providerInfo.tagline || "Hosted in your own cloud"}</BlockDescription>
+              </Block>
+            );
+          })}
+        </BlockList>
+      </StyledProvisionerFlow>
+    );
+  } else if (currentStep === "credentials") {
+    return (
+      <CredentialsForm 
+        goBack={() => setCurrentStep("cloud")}
+        proceed={(id) => {
+          setCredentialId(id);
+          setCurrentStep("cluster");
+        }}
+      />
+    );
+  } else if (currentStep === "cluster") {
+    return (
+      <ProvisionerForm
+        goBack={() => setCurrentStep("credentials")}
+        credentialId={credentialId}
+      />
+    );
+  }
+};
+
+export default ProvisionerFlow;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Icon = styled.img<{ bw?: boolean }>`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: 400;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  height: 170px;
+  filter: ${({ disabled }) => (disabled ? "brightness(0.8) grayscale(1)" : "")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: ${(props) => (props.disabled ? "" : "1px solid #7a7b80")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledProvisionerFlow = styled.div`
+  margin-top: -24px;
+`;

+ 75 - 0
dashboard/src/components/ProvisionerForm.tsx

@@ -0,0 +1,75 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import aws from "assets/aws.png";
+
+import Heading from "components/form-components/Heading";
+import Helper from "./form-components/Helper";
+import ProvisionerSettings from "./ProvisionerSettings";
+
+type Props = {
+  goBack: () => void;
+  credentialId: string;
+};
+
+const ProvisionerForm: React.FC<Props> = ({
+  goBack,
+  credentialId,
+}) => {
+  return (
+    <>
+      <Heading isAtTop>
+        <BackButton width="155px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Set credentials
+        </BackButton>
+        <Spacer />
+        <Img src={aws} />
+        Configure settings
+      </Heading>
+      <Helper>
+        Configure settings for your new cluster. 
+      </Helper>
+      <ProvisionerSettings credentialId={credentialId} />
+    </>
+  );
+};
+
+export default ProvisionerForm;
+
+const Spacer = styled.div`
+  height: 1px;
+  width: 17px;
+`;
+
+const Img = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;

+ 341 - 0
dashboard/src/components/ProvisionerSettings.tsx

@@ -0,0 +1,341 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { OFState } from "main/home/onboarding/state";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
+
+import SelectRow from "components/form-components/SelectRow";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "./form-components/InputRow";
+import SaveButton from "./SaveButton";
+import { Contract, EnumKubernetesKind, EnumCloudProvider, NodeGroupType, EKSNodeGroup, EKS, Cluster } from "@porter-dev/api-contracts";
+import { ClusterType } from "shared/types";
+
+const regionOptions = [
+  { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
+  { value: "us-east-2", label: "US East (Ohio) us-east-2" },
+  { value: "us-west-1", label: "US West (N. California) us-west-1" },
+  { value: "us-west-2", label: "US West (Oregon) us-west-2" },
+  { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
+  { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
+  { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
+  { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
+  { value: "ap-southeast-2", label: "Asia Pacific (Sydney) ap-southeast-2" },
+  { value: "ap-northeast-1", label: "Asia Pacific (Tokyo) ap-northeast-1" },
+  { value: "ca-central-1", label: "Canada (Central) ca-central-1" },
+  { value: "eu-central-1", label: "Europe (Frankfurt) eu-central-1" },
+  { value: "eu-west-1", label: "Europe (Ireland) eu-west-1" },
+  { value: "eu-west-2", label: "Europe (London) eu-west-2" },
+  { value: "eu-south-1", label: "Europe (Milan) eu-south-1" },
+  { value: "eu-west-3", label: "Europe (Paris) eu-west-3" },
+  { value: "eu-north-1", label: "Europe (Stockholm) eu-north-1" },
+  { value: "me-south-1", label: "Middle East (Bahrain) me-south-1" },
+  { value: "sa-east-1", label: "South America (São Paulo) sa-east-1" },
+];
+
+const machineTypeOptions = [
+  { value: "t3.medium", label: "t3.medium" },
+  { value: "t3.large", label: "t3.large" },
+  { value: "t3.xlarge", label: "t3.xlarge" },
+  { value: "t3.2xlarge", label: "t3.2xlarge" },
+];
+
+type Props = RouteComponentProps & {
+  selectedClusterVersion?: Contract;
+  credentialId: string;
+  clusterId?: number;
+};
+
+const ProvisionerSettings: React.FC<Props> = props => {
+  const {
+    currentProject,
+    currentCluster,
+    setCurrentCluster,
+    setShouldRefreshClusters,
+    setHasFinishedOnboarding,
+  } = useContext(Context);
+  const [createStatus, setCreateStatus] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [awsRegion, setAwsRegion] = useState("us-east-1");
+  const [machineType, setMachineType] = useState("t3.xlarge");
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [minInstances, setMinInstances] = useState(1);
+  const [maxInstances, setMaxInstances] = useState(10);
+  const [cidrRange, setCidrRange] = useState("172.0.0.0/16");
+  const [isReadOnly, setIsReadOnly] = useState(false);
+
+  const createCluster = async () => {
+    var data = new Contract({
+      cluster: new Cluster({
+        projectId: currentProject.id,
+        kind: EnumKubernetesKind.EKS,
+        cloudProvider: EnumCloudProvider.AWS,
+        cloudProviderCredentialsId: String(props.credentialId),
+        kindValues: {
+          case: "eksKind",
+          value: new EKS({
+            clusterName,
+            clusterVersion: "v1.24.0",
+            cidrRange: cidrRange || "172.0.0.0/16",
+            region: awsRegion,
+            nodeGroups: [
+              new EKSNodeGroup({
+                instanceType: "t3.medium",
+                minInstances: 1,
+                maxInstances: 5,
+                nodeGroupType: NodeGroupType.SYSTEM,
+                isStateful: false,
+              }),
+              new EKSNodeGroup({
+                instanceType: "t3.large",
+                minInstances: 1,
+                maxInstances: 5,
+                nodeGroupType: NodeGroupType.MONITORING,
+                isStateful: false,
+              }),
+              new EKSNodeGroup({
+                instanceType: machineType,
+                minInstances: minInstances || 1,
+                maxInstances: maxInstances || 10,
+                nodeGroupType: NodeGroupType.APPLICATION,
+                isStateful: false,
+              })
+            ]
+          })
+        },
+      })
+    });
+
+    if (props.clusterId) {
+      data["cluster"]["clusterId"] = props.clusterId;
+    }
+
+    console.log(0);
+    try {
+      const res = await api.createContract(
+        "<token>",
+        data,
+        { project_id: currentProject.id }
+      );
+
+      console.log("res is:", res);
+      console.log("cluster id is:", res.data.contract_revision?.cluster_id);
+
+      // Only refresh and set clusters on initial create
+      if (!props.clusterId) {
+        console.log(1);
+        setShouldRefreshClusters(true);
+        api.getClusters(
+          "<token>",
+          {},
+          { id: currentProject.id },
+        )
+          .then(({ data }) => {
+            console.log(2);
+            data.forEach((cluster: ClusterType) => {
+              console.log("cluster id:", cluster.id)
+              if (cluster.id === res.data.contract_revision?.cluster_id) {
+                // setHasFinishedOnboarding(true);
+                console.log(3);
+                setCurrentCluster(cluster);
+                OFState.actions.goTo("clean_up");
+                pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              }
+            });
+          })
+          .catch((err) => {
+            console.error(err);
+          });
+      }
+    } catch (err) {
+      console.log(err);
+    }
+  }
+
+  useEffect(() => {
+    setIsReadOnly(
+      props.clusterId && (
+        currentCluster.status === "UPDATING" ||
+        currentCluster.status === "UPDATING_UNAVAILABLE"
+      )
+    );
+  }, []);
+
+  useEffect(() => {
+    const contract = props.selectedClusterVersion as any;
+    if (contract?.cluster) {
+      contract.cluster.eksKind.nodeGroups.map((nodeGroup: any) => {
+        if (nodeGroup.nodeGroupType === "NODE_GROUP_TYPE_APPLICATION") {
+          setMachineType(nodeGroup.instanceType);
+          setMinInstances(nodeGroup.minInstances);
+          setMaxInstances(nodeGroup.maxInstances);
+        }
+      });
+      setCreateStatus("");
+      setClusterName(contract.cluster.eksKind.clusterName);
+      setAwsRegion(contract.cluster.eksKind.region);
+      setCidrRange(contract.cluster.eksKind.cidrRange);
+    }
+  }, [props.selectedClusterVersion]);
+
+  const renderForm = () => {
+    
+    // Render simplified form if initial create
+    if (!props.clusterId) {
+      return (
+        <>
+          <Heading isAtTop>Cluster configuration</Heading>
+          <Helper>
+            Porter will create a new cluster for your applications in the specified region.
+          </Helper>
+          <InputRow
+            width="350px"
+            isRequired
+            disabled={isReadOnly}
+            type="string"
+            value={clusterName}
+            setValue={(x: string) => setClusterName(x)}
+            label="🏷️ Cluster name"
+            placeholder="ex: total-perspective-vortex"
+          />
+          <SelectRow
+            options={regionOptions}
+            width="350px"
+            disabled={isReadOnly}
+            value={awsRegion}
+            scrollBuffer={true}
+            dropdownMaxHeight="240px"
+            setActiveValue={setAwsRegion}
+            label="📍 Select an AWS region"
+          />
+        </>
+      )
+    }
+
+    // If settings, update full form
+    return (
+      <>
+        <Heading isAtTop>EKS configuration</Heading>
+        <InputRow
+          width="350px"
+          isRequired
+          disabled={isReadOnly}
+          type="string"
+          value={clusterName}
+          setValue={(x: string) => setClusterName(x)}
+          label="🏷️ Cluster name"
+          placeholder="ex: total-perspective-vortex"
+        />
+        <SelectRow
+          options={regionOptions}
+          width="350px"
+          disabled={isReadOnly}
+          value={awsRegion}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setAwsRegion}
+          label="📍 AWS region"
+        />
+        <SelectRow
+          options={machineTypeOptions}
+          width="350px"
+          disabled={isReadOnly}
+          value={machineType}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setMachineType}
+          label="⚙️ Machine type"
+        />
+
+        <Heading>
+          <ExpandHeader
+            onClick={() => setIsExpanded(!isExpanded)}
+            isExpanded={isExpanded}
+          >
+            <i className="material-icons">arrow_drop_down</i>
+            Advanced settings
+          </ExpandHeader>
+        </Heading>
+        {
+          isExpanded && (
+            <>
+              <InputRow
+                width="350px"
+                type="number"
+                disabled={isReadOnly}
+                value={minInstances}
+                setValue={(x: number) => setMinInstances(x)}
+                label="Minimum number of application EC2 instances"
+                placeholder="ex: 1"
+              />
+              <InputRow
+                width="350px"
+                type="number"
+                disabled={isReadOnly}
+                value={maxInstances}
+                setValue={(x: number) => setMaxInstances(x)}
+                label="Minimum number of application EC2 instances"
+                placeholder="ex: 1"
+              />
+              <InputRow
+                width="350px"
+                type="string"
+                disabled={isReadOnly}
+                value={cidrRange}
+                setValue={(x: string) => setCidrRange(x)}
+                label="VPC CIDR range"
+                placeholder="ex: 172.0.0.0/16"
+              />
+            </>
+          )
+        }
+      </>
+    )
+  }
+
+  return (
+    <>
+      <StyledForm>
+        {renderForm()}
+      </StyledForm>
+      <SaveButton
+        disabled={(!clusterName && true) || isReadOnly}
+        onClick={createCluster}
+        clearPosition
+        text="Provision"
+        statusPosition="right"
+        status={isReadOnly && "Provisioning is still in progress"}
+      />
+    </>
+  );
+};
+
+export default withRouter(ProvisionerSettings);
+
+const ExpandHeader = styled.div<{ isExpanded: boolean }>`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  > i {
+    margin-right: 7px;
+    margin-left: -7px;
+    transform: ${(props) => props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+  }
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding: 30px 30px 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+`;

+ 1 - 1
dashboard/src/components/SaveButton.tsx

@@ -200,7 +200,7 @@ const Button = styled.button<{
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
   user-select: none;
   :focus {
     outline: 0;

+ 22 - 33
dashboard/src/components/Selector.tsx

@@ -10,6 +10,7 @@ export type SelectorPropsType = {
   setActiveValue: (x: string) => void;
   width: string;
   height?: string;
+  disabled?: boolean;
   dropdownLabel?: string;
   dropdownWidth?: string;
   dropdownMaxHeight?: string;
@@ -162,11 +163,14 @@ export default class Selector extends Component<SelectorPropsType, StateType> {
       <StyledSelector width={this.props.width}>
         <MainSelector
           ref={this.parentRef}
+          disabled={this.props.disabled}
           onClick={() => {
-            if (this.props.refreshOptions) {
-              this.props.refreshOptions();
+            if (!this.props.disabled) {
+              if (this.props.refreshOptions) {
+                this.props.refreshOptions();
+              }
+              this.setState({ expanded: !this.state.expanded });
             }
-            this.setState({ expanded: !this.state.expanded });
           }}
           expanded={this.state.expanded}
           width={this.props.width}
@@ -306,15 +310,6 @@ const Option = styled.div`
   }
 `;
 
-const CloseOverlay = styled.div`
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 999;
-`;
-
 const Dropdown = styled.div`
   background: #26282f;
   width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
@@ -333,40 +328,34 @@ const StyledSelector = styled.div<{ width: string }>`
   width: ${(props) => props.width};
 `;
 
-const MainSelector = styled.div`
-  width: ${(props: { expanded: boolean; width: string; height?: string }) =>
-    props.width};
-  height: ${(props: { expanded: boolean; width: string; height?: string }) =>
-    props.height ? props.height : "35px"};
+const MainSelector = styled.div<{ 
+  disabled?: boolean;
+  expanded: boolean;
+  width: string;
+  height?: string;
+}>`
+  width: ${props => props.width};
+  height: ${props => props.height ? props.height : "35px"};
   border: 1px solid #ffffff55;
   font-size: 13px;
   padding: 5px 10px;
   padding-left: 15px;
   border-radius: 3px;
   display: flex;
+  color: ${props => props.disabled ? "#ffffff44" : "#ffffff"};
   justify-content: space-between;
   align-items: center;
-  cursor: pointer;
-  background: ${(props: {
-    expanded: boolean;
-    width: string;
-    height?: string;
-  }) => (props.expanded ? "#ffffff33" : "#ffffff11")};
+  cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
+  background: ${props => props.expanded ? "#ffffff33" : "#ffffff11"};
   :hover {
-    background: ${(props: {
-      expanded: boolean;
-      width: string;
-      height?: string;
-    }) => (props.expanded ? "#ffffff33" : "#ffffff22")};
+    background: ${props => props.expanded ? "#ffffff33" : (
+      props.disabled ? "#ffffff11" : "#ffffff22"
+    )};
   }
 
   > i {
     font-size: 20px;
-    transform: ${(props: {
-      expanded: boolean;
-      width: string;
-      height?: string;
-    }) => (props.expanded ? "rotate(180deg)" : "")};
+    transform: ${props => props.expanded ? "rotate(180deg)" : ""};
   }
 `;
 

+ 3 - 3
dashboard/src/components/TitleSection.tsx

@@ -57,7 +57,7 @@ const BackButton = styled.div`
   > i {
     cursor: pointer;
     font-size: 24px;
-    color: #969fbbaa;
+    color: #aaaabb;
     margin-right: 10px;
     padding: 3px;
     margin-left: 0px;
@@ -69,15 +69,15 @@ const BackButton = styled.div`
 `;
 
 const StyledTitleSection = styled.div`
-  margin-bottom: 15px;
   display: flex;
   align-items: center;
-  height: 35px;
 `;
 
 const Icon = styled.img<{ width: string }>`
   width: ${(props) => props.width || "25px"};
   margin-right: 16px;
+  display: flex;
+  align-items: center;
 `;
 
 const MaterialIcon = styled.span<{ width: string }>`

+ 2 - 0
dashboard/src/components/form-components/SelectRow.tsx

@@ -13,6 +13,7 @@ type PropsType = {
   dropdownMaxHeight?: string;
   scrollBuffer?: boolean;
   doc?: string;
+  disabled?: boolean;
   selectorProps?: Partial<SelectorPropsType>;
 };
 
@@ -32,6 +33,7 @@ export default class SelectRow extends Component<PropsType, StateType> {
         </Wrapper>
         <SelectWrapper>
           <Selector
+            disabled={this.props.disabled}
             scrollBuffer={this.props.scrollBuffer}
             activeValue={this.props.value}
             setActiveValue={this.props.setActiveValue}

+ 90 - 0
dashboard/src/components/porter/ExpandableSection.tsx

@@ -0,0 +1,90 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  isInitiallyExpanded?: boolean;
+  Header: any;
+  ExpandedSection: any;
+  color?: any;
+};
+
+const ExpandableSection: React.FC<Props> = ({
+  isInitiallyExpanded,
+  Header,
+  ExpandedSection,
+  color,
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+  useEffect(() => {
+    setIsExpanded(isInitiallyExpanded);
+  }, [isInitiallyExpanded]);
+
+  return (
+    <StyledExpandableSection isExpanded={isExpanded}>
+      <HeaderRow 
+        isExpanded={isExpanded}
+        onClick={() => setIsExpanded(!isExpanded)}
+        color={color}
+      >
+        <i className="material-icons">arrow_drop_down</i> 
+        {Header}
+      </HeaderRow>
+      {
+        isExpanded && (
+          ExpandedSection
+        )
+      }
+    </StyledExpandableSection>
+  );
+};
+
+export default ExpandableSection;
+
+const HeaderRow = styled.div<{ 
+  isExpanded: boolean;
+  color?: string;
+}>`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  margin-top: -1px;
+  padding-left: 10px;
+  cursor: pointer;
+  :hover {
+    background: ${props => props.isExpanded && "#ffffff18"};
+  }
+
+  > i {
+    margin-right: 8px;
+    color: ${props => props.color || "#ffffff66"};
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${props => props.isExpanded ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const StyledExpandableSection = styled.div<{ isExpanded: boolean }>`
+  width: 100%;
+  height: ${props => props.isExpanded ? "" : "40px"};
+  max-height: 255px;
+  overflow: hidden;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: ${props => props.isExpanded ? "expandRevisions 0.3s" : ""};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;

+ 66 - 0
dashboard/src/components/porter/LoadingBar.tsx

@@ -0,0 +1,66 @@
+import React, { useEffect, useState } from "react";
+import styled, { keyframes } from "styled-components";
+
+type Props = {
+  percent?: number;
+  completed?: number;
+  total?: number;
+  color?: string;
+};
+
+const LoadingBar: React.FC<Props> = ({
+  percent,
+  completed,
+  total,
+  color,
+}) => {
+  const getColor = () => {
+    switch (color) {
+      case "failed":
+        return "#cc3d42";
+      default:
+        return color;
+    }
+  };
+  
+  return (
+    <StyledLoadingBar>
+      <LoadingFill
+        color={getColor()}
+        percent={(percent || ((100.0 * completed) / total)) + "%"}
+      />
+    </StyledLoadingBar>
+  );
+};
+
+export default LoadingBar;
+
+const StyledLoadingBar = styled.div`
+  background: #ffffff22;
+  width: 100%;
+  height: 8px;
+  overflow: hidden;
+  border-radius: 100px;
+`;
+
+const movingGradient = keyframes`
+  0% {
+    background-position: left bottom;
+  }
+  100% {
+    background-position: right bottom;
+  }
+`;
+
+const LoadingFill = styled.div<{ 
+  percent: string;
+  color?: string;
+}>`
+  width: ${props => props.percent};
+  background: ${props => props.color || "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;
+`;

+ 31 - 0
dashboard/src/components/porter/Spacer.tsx

@@ -0,0 +1,31 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  height?: string;
+  y?: number;
+  inline?: boolean;
+};
+
+const Spacer: React.FC<Props> = ({
+  height,
+  y,
+  inline,
+}) => {
+  const getCalcHeight = () => {
+    return 25 * y;
+  };
+  
+  return (
+    <StyledSpacer
+      height={height || (getCalcHeight() + "px")}
+    />
+  );
+};
+
+export default Spacer;
+
+const StyledSpacer = styled.div<{ height: string }>`
+  height: ${props => props.height};
+  width: ${props => props.height ? "100%" : ""};
+`;

+ 24 - 0
dashboard/src/components/porter/TemplateComponent.tsx

@@ -0,0 +1,24 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+};
+
+const TemplateComponent: React.FC<Props> = ({
+}) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useEffect(() => {
+    // Do something
+  }, []);
+
+  return (
+    <StyledTemplateComponent>
+    </StyledTemplateComponent>
+  );
+};
+
+export default TemplateComponent;
+
+const StyledTemplateComponent = styled.div`
+`;

+ 42 - 0
dashboard/src/components/porter/Text.tsx

@@ -0,0 +1,42 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+  size?: number;
+  color?: string;
+  children: any;
+};
+
+const Text: React.FC<Props> = ({
+  size,
+  color,
+  children
+}) => {
+  const getColor = () => {
+    switch (color) {
+      case "helper":
+        return "#aaaabb";
+      default:
+        return color;
+    }
+  };
+  
+  return (
+    <StyledText
+      size={size}
+      color={getColor()}
+    >
+      {children}
+    </StyledText>
+  );
+};
+
+export default Text;
+
+const StyledText = styled.div<{ 
+  size?: number; 
+  color?: string 
+}>`
+  color: ${props => props.color || "#ffffff"};
+  font-size: ${props => props.size || 13}px;
+`;

+ 2 - 2
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -153,7 +153,7 @@ const ActionDetails: React.FC<PropsType> = (props) => {
               onClick={() => setShowBuildpacksConfig((prev) => !prev)}
               isExpanded={showBuildpacksConfig}
             >
-              Buildpacks Settings
+              Buildpacks settings
               <i className="material-icons">arrow_drop_down</i>
             </ExpandHeader>
           </Heading>
@@ -217,7 +217,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
   cursor: pointer;
   > i {
     margin-left: 10px;
-    transform: ${(props) => (props.isExpanded ? "" : "rotate(180deg)")};
+    transform: ${(props) => (props.isExpanded ? "rotate(180deg)" : "")};
   }
 `;
 

+ 276 - 306
dashboard/src/main/home/Home.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useEffect, useState, useContext, useRef } from "react";
 import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
 import styled from "styled-components";
 
@@ -45,67 +45,78 @@ const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
   "delete",
 ])(Integrations);
 
-type PropsType = RouteComponentProps &
-  WithAuthProps & {
-    logOut: () => void;
-    currentProject: ProjectType;
-    currentCluster: ClusterType;
-    currentRoute: PorterUrl;
-  };
-
-type StateType = {
-  forceSidebar: boolean;
-  showWelcome: boolean;
-  handleDO: boolean; // Trigger DO infra calls after oauth flow if needed
-  ghRedirect: boolean;
-  forceRefreshClusters: boolean; // For updating ClusterSection from modal on deletion
-
-  // Track last project id for refreshing clusters on project change
-  prevProjectId: number | null;
-  showWelcomeForm: boolean;
+type Props = RouteComponentProps & WithAuthProps & {
+  logOut: () => void;
+  currentProject: ProjectType;
+  currentCluster: ClusterType;
+  currentRoute: PorterUrl;
 };
 
-// TODO: Handle cluster connected but with some failed infras (no successful set)
-// TODO: Set up current view / sidebar tab as dynamic Routes
-class Home extends Component<PropsType, StateType> {
-  state = {
-    forceSidebar: true,
-    showWelcome: false,
-    prevProjectId: null as number | null,
-    forceRefreshClusters: false,
-    sidebarReady: false,
-    handleDO: false,
-    ghRedirect: false,
-    showWelcomeForm: true,
+const Home: React.FC<Props> = props => {
+  const {
+    user,
+    projects,
+    currentCluster,
+    currentProject,
+    currentModal,
+    currentOverlay,
+    hasFinishedOnboarding,
+    shouldRefreshClusters,
+    setProjects,
+    setCurrentProject,
+    setCapabilities,
+    setCanCreateProject,
+    setHasFinishedOnboarding,
+    setCurrentError,
+    setCurrentModal,
+    setHasBillingEnabled,
+    setUsage,
+    setShouldRefreshClusters,
+  } = useContext(Context);
+
+  const [showWelcome, setShowWelcome] = useState(false);
+  const [prevProjectId, setPrevProjectId] = useState<number | null>(null);
+  const [forceRefreshClusters, setForceRefreshClusters] = useState(false);
+  const [sidebarReady, setSidebarReady] = useState(false);
+  const [handleDO, setHandleDO] = useState(false);
+  const [ghRedirect, setGhRedirect] = useState(false);
+  const [showWelcomeForm, setShowWelcomeForm] = useState(true);
+  const [forceSidebar, setForceSidebar] = useState(true);
+
+  const redirectToNewProject = () => {
+    pushFiltered(props, "/new-project", ["project_id"]);
   };
 
-  getMetadata = () => {
+  const redirectToOnboarding = () => {
+    pushFiltered(props, "/onboarding", []);
+  };
+
+  const getMetadata = () => {
     api
       .getMetadata("<token>", {}, {})
-      .then((res) => {
-        this.context.setCapabilities(res.data);
+      .then(res => {
+        setCapabilities(res.data);
       })
       .catch((err) => {
         console.log(err);
       });
   };
 
-  getProjects = (id?: number) => {
-    let { user, setProjects, setCurrentProject } = this.context;
-    let { currentProject } = this.props;
+  const getProjects = (id?: number) => {
+    let { currentProject } = props;
     let queryString = window.location.search;
     let urlParams = new URLSearchParams(queryString);
     let projectId = urlParams.get("project_id");
     if (!projectId && currentProject?.id) {
-      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
+      pushQueryParams(props, { project_id: currentProject.id.toString() });
     }
 
     api
       .getProjects("<token>", {}, { id: user.userId })
-      .then((res) => {
+      .then(res => {
         if (res.data) {
           if (res.data.length === 0) {
-            this.redirectToNewProject();
+            redirectToNewProject();
           } else if (res.data.length > 0 && !currentProject) {
             setProjects(res.data);
 
@@ -135,28 +146,47 @@ class Home extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
-  checkIfCanCreateProject = () => {
+  const checkIfCanCreateProject = () => {
     api
       .getCanCreateProject("<token>", {}, {})
       .then((res) => {
         if (res.status === 403) {
-          this.context.setCanCreateProject(false);
+          setCanCreateProject(false);
           return;
         }
-        this.context.setCanCreateProject(true);
+        setCanCreateProject(true);
       })
       .catch((err) => {
-        this.context.setCanCreateProject(false);
+        setCanCreateProject(false);
         console.error(err);
       });
   };
 
-  componentDidMount() {
-    this.checkOnboarding();
-    this.checkIfCanCreateProject();
-    let { match } = this.props;
+  const checkOnboarding = async () => {
+    try {
+      const project_id = currentProject?.id;
+      if (!project_id) {
+        return;
+      }
+      const res = await api.getOnboardingState("<token>", {}, { project_id });
 
-    let { user } = this.context;
+      if (res.status === 404) {
+        setHasFinishedOnboarding(true);
+        return;
+      }
+
+      if (res?.data && res?.data.current_step !== "clean_up") {
+        setHasFinishedOnboarding(false);
+      } else {
+        setHasFinishedOnboarding(true);
+      }
+    } catch (error) {}
+  }
+
+  useEffect(() => {
+    checkOnboarding();
+    checkIfCanCreateProject();
+    let { match } = props;
 
     // Handle redirect from DO
     let queryString = window.location.search;
@@ -164,30 +194,38 @@ class Home extends Component<PropsType, StateType> {
 
     let err = urlParams.get("error");
     if (err) {
-      this.context.setCurrentError(err);
+      setCurrentError(err);
     }
 
     let defaultProjectId = parseInt(urlParams.get("project_id"));
 
-    this.setState({ ghRedirect: urlParams.get("gh_oauth") !== null });
+    setGhRedirect(urlParams.get("gh_oauth") !== null);
     urlParams.delete("gh_oauth");
-    this.getProjects(defaultProjectId);
-    this.getMetadata();
+    getProjects(defaultProjectId);
+    getMetadata();
 
     if (
-      !this.context.hasFinishedOnboarding &&
-      this.props.history.location.pathname &&
-      !this.props.history.location.pathname.includes("onboarding")
+      !hasFinishedOnboarding &&
+      props.history.location.pathname &&
+      !props.history.location.pathname.includes("onboarding")
     ) {
-      this.context.setCurrentModal("RedirectToOnboardingModal");
+      setCurrentModal("RedirectToOnboardingModal");
     }
-  }
 
-  componentWillUnmount(): void {
-    this.context.setCanCreateProject(false);
-  }
+    return () => {
+      setCanCreateProject(false);
+    }
+  }, []);
 
-  async checkIfProjectHasBilling(projectId: number) {
+  // Hacky legacy shim for remote cluster refresh until Context is properly split
+  useEffect(() => {
+    if (shouldRefreshClusters) {
+      setForceRefreshClusters(true);
+      setShouldRefreshClusters(false);
+    }
+  }, [shouldRefreshClusters]);
+
+  const checkIfProjectHasBilling = async (projectId: number) => {
     if (!projectId) {
       return false;
     }
@@ -197,117 +235,96 @@ class Home extends Component<PropsType, StateType> {
         {},
         { project_id: projectId }
       );
-      this.context.setHasBillingEnabled(res.data?.has_billing);
+      setHasBillingEnabled(res.data?.has_billing);
       return res?.data?.has_billing;
     } catch (error) {
       console.log(error);
     }
   }
 
-  async checkOnboarding() {
-    try {
-      const project_id = this.context?.currentProject?.id;
-      if (!project_id) {
-        return;
-      }
-      const res = await api.getOnboardingState("<token>", {}, { project_id });
-
-      if (res.status === 404) {
-        this.context.setHasFinishedOnboarding(true);
-        return;
-      }
-
-      if (res?.data && res?.data.current_step !== "clean_up") {
-        this.context.setHasFinishedOnboarding(false);
-      } else {
-        this.context.setHasFinishedOnboarding(true);
-      }
-    } catch (error) {}
-  }
+  useEffect(() => {
+    getMetadata();
+    checkOnboarding();
+    if (!process.env.DISABLE_BILLING) {
+      checkIfProjectHasBilling(currentProject?.id)
+        .then((isBillingEnabled) => {
+          if (isBillingEnabled) {
+            api
+              .getUsage(
+                "<token>",
+                {},
+                { project_id: currentProject?.id }
+              )
+              .then((res) => {
+                const usage = res.data;
+                setUsage(usage);
+                if (usage.exceeded) {
+                  setCurrentModal("UsageWarningModal", { usage });
+                }
+              })
+              .catch(console.log);
+          }
+        })
+        .catch(console.log);
+    }
+  }, [props.currentProject?.id])
 
-  // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
-  // 1. Make sure clicking cluster in drawer shows cluster-dashboard
-  // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
-  // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
-  componentDidUpdate(prevProps: PropsType) {
+  useEffect(() => {
     if (
-      !this.context.hasFinishedOnboarding &&
-      prevProps.match.url !== this.props.match.url &&
-      this.props.history.location.pathname &&
-      !this.props.history.location.pathname.includes("onboarding") &&
-      !this.props.history.location.pathname.includes("new-project") &&
-      !this.props.history.location.pathname.includes("project-settings")
+      !hasFinishedOnboarding &&
+      props.history.location.pathname &&
+      !props.history.location.pathname.includes("onboarding") &&
+      !props.history.location.pathname.includes("new-project") &&
+      !props.history.location.pathname.includes("project-settings")
     ) {
-      this.context.setCurrentModal("RedirectToOnboardingModal");
+      setCurrentModal("RedirectToOnboardingModal");
     }
+  }, [props.match.url]);
 
-    if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
-      this.checkOnboarding();
-
-      if (!process.env.DISABLE_BILLING) {
-        this.checkIfProjectHasBilling(this?.context?.currentProject?.id)
-          .then((isBillingEnabled) => {
-            if (isBillingEnabled) {
-              api
-                .getUsage(
-                  "<token>",
-                  {},
-                  { project_id: this.context?.currentProject?.id }
-                )
-                .then((res) => {
-                  const usage = res.data;
-                  this.context.setUsage(usage);
-                  if (usage.exceeded) {
-                    this.context.setCurrentModal("UsageWarningModal", {
-                      usage,
-                    });
-                  }
-                })
-                .catch(console.log);
-            }
-          })
-          .catch(console.log);
-      }
+  const prevCurrentCluster: any = useRef();
+  useEffect(() => {
+    if (!prevCurrentCluster.current && props.currentCluster) {
+      getMetadata();
     }
 
-    if (
-      prevProps.currentProject !== this.props.currentProject ||
-      (!prevProps.currentCluster && this.props.currentCluster)
-    ) {
-      this.getMetadata();
-    }
-  }
+    // Store previous value (legacy retrofit)
+    prevCurrentCluster.current = props.currentCluster;
+  }, [props.currentCluster]);
 
-  projectOverlayCall = async () => {
-    let { user, setProjects, setCurrentProject } = this.context;
+  const projectOverlayCall = async () => {
     try {
-      const res = await api.getProjects("<token>", {}, { id: user.userId });
+      const res = await api.getProjects(
+        "<token>",
+        {},
+        { id: user.userId }
+      );
       if (!res.data) {
-        this.context.setCurrentModal(null, null);
+        setCurrentModal(null, null);
         return;
       }
 
       setProjects(res.data);
       if (!res.data.length) {
-        setCurrentProject(null, () => this.redirectToNewProject());
+        setCurrentProject(null, () => redirectToNewProject());
       } else {
         setCurrentProject(res.data[0]);
       }
-      this.context.setCurrentModal(null, null);
+      setCurrentModal(null, null);
     } catch (error) {
-      /** @todo Centralize with error handler */
       console.log(error);
     }
   };
 
-  handleDelete = async () => {
-    let { setCurrentModal, currentProject } = this.context;
+  const handleDelete = async () => {
     localStorage.removeItem(currentProject.id + "-cluster");
     try {
-      await api.deleteProject("<token>", {}, { id: currentProject?.id });
-      this.projectOverlayCall();
+      await api.deleteProject(
+        "<token>",
+        {},
+        { id: currentProject?.id }
+      );
+      projectOverlayCall();
     } catch (error) {
-      /** @todo Centralize with error handler */
       console.log(error);
     }
 
@@ -336,189 +353,142 @@ class Home extends Component<PropsType, StateType> {
       console.log(error);
     }
     setCurrentModal(null, null);
-    pushFiltered(this.props, "/dashboard", []);
+    pushFiltered(props, "/dashboard", []);
   };
 
-  redirectToNewProject = () => {
-    pushFiltered(this.props, "/new-project", ["project_id"]);
-  };
-
-  redirectToOnboarding = () => {
-    pushFiltered(this.props, "/onboarding", []);
-  };
-
-  render() {
-    let {
-      currentModal,
-      setCurrentModal,
-      currentProject,
-      currentOverlay,
-      projects,
-    } = this.context;
-
-    const { cluster, baseRoute } = this.props.match.params as any;
-    return (
-      <StyledHome>
-        <ModalHandler
-          setRefreshClusters={(x) => this.setState({ forceRefreshClusters: x })}
+  const { cluster, baseRoute } = props.match.params as any;
+  return (
+    <StyledHome>
+      <ModalHandler setRefreshClusters={setForceRefreshClusters} />
+      {currentOverlay && (
+        <ConfirmOverlay
+          show={true}
+          message={currentOverlay.message}
+          onYes={currentOverlay.onYes}
+          onNo={currentOverlay.onNo}
+        />
+      )}
+
+      {/* Render sidebar when there's at least one project */}
+      {projects?.length > 0 && baseRoute !== "new-project" ? (
+        <Sidebar
+          key="sidebar"
+          forceSidebar={forceSidebar}
+          setWelcome={setShowWelcome}
+          currentView={props.currentRoute}
+          forceRefreshClusters={forceRefreshClusters}
+          setRefreshClusters={setForceRefreshClusters}
+        />
+      ) : (
+        <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+          <Icon src={discordLogo} />
+          Join Our Discord
+        </DiscordButton>
+      )}
+
+      <ViewWrapper id="HomeViewWrapper">
+        <Navbar
+          logOut={props.logOut}
+          currentView={props.currentRoute} // For form feedback
         />
-        {currentOverlay && (
-          <ConfirmOverlay
-            show={true}
-            message={currentOverlay.message}
-            onYes={currentOverlay.onYes}
-            onNo={currentOverlay.onNo}
-          />
-        )}
-
-        {/* Render sidebar when there's at least one project */}
-        {projects?.length > 0 && baseRoute !== "new-project" ? (
-          <Sidebar
-            key="sidebar"
-            forceSidebar={this.state.forceSidebar}
-            setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
-            currentView={this.props.currentRoute}
-            forceRefreshClusters={this.state.forceRefreshClusters}
-            setRefreshClusters={(x: boolean) =>
-              this.setState({ forceRefreshClusters: x })
-            }
-          />
-        ) : (
-          <>
-            <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
-              <Icon src={discordLogo} />
-              Join Our Discord
-            </DiscordButton>
-            {/* This should only be shown on the first render of the app */}
-            {/* this.state.showWelcomeForm &&
-              localStorage.getItem("welcomed") != "true" &&
-              projects?.length === 0 && (
-                <>
-                  <WelcomeForm
-                    closeForm={() => this.setState({ showWelcomeForm: false })}
-                  />
-                  <Navbar
-                    logOut={this.props.logOut}
-                    currentView={this.props.currentRoute} // For form feedback
-                  />
-                </>
-              ) */}
-          </>
-        )}
-
-        <ViewWrapper id="HomeViewWrapper">
-          <Navbar
-            logOut={this.props.logOut}
-            currentView={this.props.currentRoute} // For form feedback
-          />
 
-          <Switch>
-            <Route
-              path="/new-project"
-              render={() => {
-                return <NewProjectFC />;
-              }}
-            ></Route>
-            <Route
-              path="/onboarding"
-              render={() => {
-                return <Onboarding />;
-              }}
-            />
-            {this.context?.user?.isPorterUser ||
-            overrideInfraTabEnabled({
-              projectID: this.context?.currentProject?.id,
-            }) ? (
-              <Route
-                path="/infrastructure"
-                render={() => {
-                  return (
-                    <DashboardWrapper>
-                      <InfrastructureRouter />
-                    </DashboardWrapper>
-                  );
-                }}
-              />
-            ) : null}
+        <Switch>
+          <Route
+            path="/new-project"
+            render={() => {
+              return <NewProjectFC />;
+            }}
+          ></Route>
+          <Route
+            path="/onboarding"
+            render={() => {
+              return <Onboarding />;
+            }}
+          />
+          {user?.isPorterUser || overrideInfraTabEnabled({
+            projectID: currentProject?.id,
+          }) ? (
             <Route
-              path="/dashboard"
+              path="/infrastructure"
               render={() => {
                 return (
                   <DashboardWrapper>
-                    <Dashboard
-                      projectId={this.context.currentProject?.id}
-                      setRefreshClusters={(x: boolean) =>
-                        this.setState({ forceRefreshClusters: x })
-                      }
-                    />
+                    <InfrastructureRouter />
                   </DashboardWrapper>
                 );
               }}
             />
-            <Route
-              path={[
-                "/cluster-dashboard",
-                "/applications",
-                "/jobs",
-                "/env-groups",
-                "/databases",
-                "/preview-environments",
-                "/stacks",
-              ]}
-              render={() => {
-                let { currentCluster } = this.context;
-                if (currentCluster?.id === -1) {
-                  return <Loading />;
-                } else if (!currentCluster || !currentCluster.name) {
-                  return (
-                    <DashboardWrapper>
-                      <PageNotFound />
-                    </DashboardWrapper>
-                  );
-                }
+          ) : null}
+          <Route
+            path="/dashboard"
+            render={() => {
+              return (
+                <DashboardWrapper>
+                  <Dashboard
+                    projectId={currentProject?.id}
+                    setRefreshClusters={setForceRefreshClusters}
+                  />
+                </DashboardWrapper>
+              );
+            }}
+          />
+          <Route
+            path={[
+              "/cluster-dashboard",
+              "/applications",
+              "/jobs",
+              "/env-groups",
+              "/databases",
+              "/preview-environments",
+              "/stacks",
+            ]}
+            render={() => {
+              if (currentCluster?.id === -1) {
+                return <Loading />;
+              } else if (!currentCluster || !currentCluster.name) {
                 return (
                   <DashboardWrapper>
-                    <ClusterDashboard
-                      currentCluster={currentCluster}
-                      setSidebar={(x: boolean) =>
-                        this.setState({ forceSidebar: x })
-                      }
-                      currentView={this.props.currentRoute}
-                      // setCurrentView={(x: string) => this.setState({ currentView: x })}
-                    />
+                    <PageNotFound />
                   </DashboardWrapper>
                 );
-              }}
-            />
-            <Route
-              path={"/integrations"}
-              render={() => <GuardedIntegrations />}
-            />
-            <Route
-              path={"/project-settings"}
-              render={() => <GuardedProjectSettings />}
-            />
-            <Route path={"*"} render={() => <LaunchWrapper />} />
-          </Switch>
-        </ViewWrapper>
-
-        <ConfirmOverlay
-          show={currentModal === "UpdateProjectModal"}
-          message={
-            currentProject
-              ? `Are you sure you want to delete ${currentProject.name}?`
-              : ""
-          }
-          onYes={this.handleDelete}
-          onNo={() => setCurrentModal(null, null)}
-        />
-      </StyledHome>
-    );
-  }
+              }
+              return (
+                <DashboardWrapper>
+                  <ClusterDashboard
+                    currentCluster={currentCluster}
+                    setSidebar={setForceSidebar}
+                    currentView={props.currentRoute}
+                  />
+                </DashboardWrapper>
+              );
+            }}
+          />
+          <Route
+            path={"/integrations"}
+            render={() => <GuardedIntegrations />}
+          />
+          <Route
+            path={"/project-settings"}
+            render={() => <GuardedProjectSettings />}
+          />
+          <Route path={"*"} render={() => <LaunchWrapper />} />
+        </Switch>
+      </ViewWrapper>
+
+      <ConfirmOverlay
+        show={currentModal === "UpdateProjectModal"}
+        message={
+          currentProject
+            ? `Are you sure you want to delete ${currentProject.name}?`
+            : ""
+        }
+        onYes={handleDelete}
+        onNo={() => setCurrentModal(null, null)}
+      />
+    </StyledHome>
+  );
 }
 
-Home.contextType = Context;
-
 export default withRouter(withAuth(Home));
 
 const ViewWrapper = styled.div`

+ 2 - 2
dashboard/src/main/home/ModalHandler.tsx

@@ -108,9 +108,9 @@ const ModalHandler: React.FC<{
         modal === "UpdateClusterModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
-            width="565px"
+            width="600px"
             height="275px"
-            title="Cluster Settings"
+            title="Delete cluster"
           >
             <UpdateClusterModal
               setRefreshClusters={(x: boolean) => setRefreshClusters(x)}

+ 11 - 1
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -2,6 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
+import loading from "assets/loading.gif";
 import { Route, Switch } from "react-router-dom";
 
 import { Context } from "shared/Context";
@@ -31,6 +32,7 @@ import Loading from "components/Loading";
 import JobRunTable from "./chart/JobRunTable";
 import TagFilter from "./TagFilter";
 import ExpandedEnvGroupDashboard from "./env-groups/ExpandedEnvGroupDashboard";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 
 // @ts-ignore
 const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
@@ -186,6 +188,10 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       ["get", "create"]
     );
 
+    if (currentCluster.status === "UPDATING_UNAVAILABLE") {
+      return <ClusterProvisioningPlaceholder />
+    }
+
     return (
       <>
         <ControlRow>
@@ -228,9 +234,13 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       ["get", "create"]
     );
 
+    if (currentCluster.status === "UPDATING_UNAVAILABLE") {
+      return <ClusterProvisioningPlaceholder />
+    }
+
     return (
       <>
-        <ControlRow style={{ marginTop: "35px" }}>
+        <ControlRow>
           <FilterWrapper>
             <LastRunStatusSelector
               lastRunStatus={this.state.lastRunStatus}

+ 19 - 24
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -4,11 +4,12 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 
 import TitleSection from "components/TitleSection";
+import Spacer from "components/porter/Spacer";
 
 type PropsType = {
   image?: any;
   title: any;
-  description?: string;
+  description?: any;
   materialIconClass?: string;
   disableLineBreak?: boolean;
   capitalize?: boolean;
@@ -28,20 +29,20 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
           {this.props.title}
         </TitleSection>
 
-        <Br />
-
         {this.props.description && (
-          <InfoSection>
-            <TopRow>
-              <InfoLabel>
-                <i className="material-icons">info</i> Info
-              </InfoLabel>
-            </TopRow>
-            <Description>{this.props.description}</Description>
-          </InfoSection>
+          <>
+            <Spacer height="35px" />
+            <InfoSection>
+              <TopRow>
+                <InfoLabel>
+                  <i className="material-icons">info</i> Info
+                </InfoLabel>
+              </TopRow>
+              <Description>{this.props.description}</Description>
+            </InfoSection>
+          </>
         )}
-
-        {!this.props.disableLineBreak && <LineBreak />}
+        <Spacer height="35px" />
       </>
     );
   }
@@ -49,11 +50,6 @@ export default class DashboardHeader extends Component<PropsType, StateType> {
 
 DashboardHeader.contextType = Context;
 
-const Br = styled.div`
-  width: 100%;
-  height: 1px;
-`;
-
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 1px;
@@ -68,9 +64,9 @@ const TopRow = styled.div`
 `;
 
 const Description = styled.div`
-  color: #8b949f;
+  color: #aaaabb;
   margin-top: 13px;
-  margin-left: 2px;
+  margin-left: 1px;
   font-size: 13px;
 `;
 
@@ -79,20 +75,19 @@ const InfoLabel = styled.div`
   height: 20px;
   display: flex;
   align-items: center;
-  color: #8b949f;
+  color: #aaaabb;
   font-size: 13px;
   > i {
-    color: #8b949f;
+    color: #aaaabb;
     font-size: 18px;
     margin-right: 5px;
   }
 `;
 
 const InfoSection = styled.div`
-  margin-top: 15px;
+
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
-  margin-bottom: 35px;
 `;
 
 const ClusterLabel = styled.div`

+ 394 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterRevisionSelector.tsx

@@ -0,0 +1,394 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import loading from "assets/loading.gif";
+import warning from "assets/warning.png";
+
+import { readableDate } from "shared/string_utils";
+import { Context } from "shared/Context";
+import ExpandableSection from "components/porter/ExpandableSection";
+import { 
+  Contract, 
+  Cluster, 
+  EKS, 
+  NodeGroupType, 
+  EnumKubernetesKind, 
+  EnumCloudProvider 
+} from "@porter-dev/api-contracts";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+  selectedClusterVersion: any;
+  setSelectedClusterVersion: any;
+  setShowProvisionerStatus: any;
+  setProvisionFailureReason: any;
+};
+
+const ClusterRevisionSelector: React.FC<Props> = ({
+  selectedClusterVersion,
+  setSelectedClusterVersion,
+  setShowProvisionerStatus,
+  setProvisionFailureReason,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [versions, setVersions] = useState<any[]>(null);
+  const [selectedId, setSelectedId] = useState(null);
+  const [pendingContract, setPendingContract] = useState(null);
+  const [failedContractId, setFailedContractId] = useState("");
+  const [hideSelector, setHideSelector] = useState(false);
+
+  const processVersions = (data: any) => {
+    setFailedContractId("");
+    setProvisionFailureReason("");
+    data.sort((a: any, b: any) => {
+      return Date.parse(a.CreatedAt) > Date.parse(b.CreatedAt) ? -1 : 1;
+    });
+    let activeCandidate;
+    if (data[0].condition !== "SUCCESS") {
+      activeCandidate = data[0];
+      setPendingContract(activeCandidate);
+
+      if (data[0].condition !== "") {
+        setFailedContractId(data[0].id);
+        setProvisionFailureReason(data[0].condition);
+      }
+    }
+
+    const successes = data.filter((x: any) => {
+      return x.condition === "SUCCESS";
+    });
+
+    // Handle active provisioning attempt
+    if (activeCandidate) {
+      setSelectedClusterVersion(JSON.parse(atob(activeCandidate.base64_contract)));
+      setSelectedId(-1);
+      setShowProvisionerStatus(true);
+    } else {
+      setSelectedClusterVersion(JSON.parse(atob(successes[0].base64_contract)));
+      setSelectedId(0);
+      setShowProvisionerStatus(false);
+    }
+    setVersions(successes);
+  }
+
+  const updateContracts = () => {
+    api.getContracts(
+      "<token>",
+      {},
+      { project_id: currentProject.id },
+    )
+      .then(({ data }) => {
+        const filtered_data = data.filter((x: any) => {
+          return x.cluster_id === currentCluster.id;
+        });
+        if (filtered_data.length === 0) {
+          setHideSelector(true);
+        } else {
+          setHideSelector(false);
+          processVersions(filtered_data);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  useEffect(() => {
+    updateContracts();
+  }, [currentCluster]);
+  
+  const createContract = () => {
+    if (false) {
+      api.createContract(
+        "<token>",
+        selectedClusterVersion,
+        { project_id: currentProject.id }
+      )
+        .then(() => {
+        })
+        .catch((err) => {
+          console.log(err);
+        });
+    }
+  };
+
+const deleteContract = () => {
+    api.deleteContract(
+      "<token>",
+      {},
+      { 
+        project_id: currentProject.id,
+        revision_id: failedContractId,
+      }
+    )
+      .then(() => {
+        updateContracts();
+      })
+      .catch((err) => {
+        console.log(err);
+      });
+  };
+
+  const renderVersionList = () => {
+    return versions?.map((version: any, i: number) => {
+      return (
+        <Tr
+          key={i}
+          onClick={() => {
+            setSelectedClusterVersion(JSON.parse(atob(version.base64_contract)));
+            setSelectedId(i);
+            setShowProvisionerStatus(false);
+          }}
+          selected={selectedId === i}
+        >
+          <Td>{versions.length - i}</Td>
+          <Td>{readableDate(version.CreatedAt)}</Td>
+          {/*
+          <Td>
+            <RollbackButton
+              disabled={i === 0}
+              onClick={createContract}
+            >
+              {i === 0 ? "Current" : "Revert"}
+            </RollbackButton>
+          </Td>
+          */}
+        </Tr>
+      );
+    });
+  };
+
+  const renderActiveAttempt = () => {
+    return (
+      <Tr
+        onClick={() => {
+          setSelectedClusterVersion(JSON.parse(atob(pendingContract.base64_contract)));
+          setSelectedId(-1);
+          setShowProvisionerStatus(true);
+        }}
+        selected={selectedId === -1}
+      >
+        <Td>
+          {
+            failedContractId ? (
+              <Failed>Update failed</Failed>
+            ) : (
+              <Flex><Img src={loading} /> Updating</Flex>
+            )
+          }
+        </Td>
+        <Td>{readableDate(pendingContract.CreatedAt)}</Td>
+        {
+          failedContractId && (
+            <DeleteButton>
+              <i 
+                className="material-icons-outlined"
+                onClick={deleteContract}
+              >
+                close
+              </i>
+            </DeleteButton>
+          )
+        }
+        {/*
+        <Td>
+          <RollbackButton
+            disabled={i === 0}
+            onClick={createContract}
+          >
+            {i === 0 ? "Current" : "Revert"}
+          </RollbackButton>
+        </Td>
+        */}
+      </Tr>
+    );
+  };
+
+  return (
+    <>
+      {
+        hideSelector ? (
+          <></>
+        ) : (
+          <>
+            <StyledClusterRevisionSelector>
+              <ExpandableSection
+                isInitiallyExpanded={false}
+                color={selectedId <= 0 ? "#ffffff66" : "#f5cb42"}
+                Header={(
+                  <>
+                    <Label isCurrent={selectedId <= 0}>
+                      {
+                        selectedId === 0 ? (
+                          "Current version -"
+                        ) : (
+                          selectedId === -1 ? (
+                            failedContractId ? (
+                              ""
+                            ) : (
+                              "In progress -"
+                            )
+                          ) : (
+                            "Previewing version (not deployed) -"
+                          )
+                        )
+                      }
+                    </Label>
+                    {
+                      selectedId === -1 ? (
+                        failedContractId ? (
+                          <><WarningIcon src={warning} /> Last update failed</>
+                        ) : (
+                          <><Img src={loading} /> Updating</>
+                        )
+                      ) : (
+                        `No. ${versions?.length - selectedId}`
+                      )
+                    }
+                  </>
+                )}
+                ExpandedSection={(
+                  <TableWrapper>
+                    <RevisionsTable>
+                      <tbody>
+                        <Tr disableHover={true}>
+                          <Th>Version no.</Th>
+                          <Th>Created</Th>
+                          {/* <Th>Rollback</Th> */}
+                        </Tr>
+                        {(pendingContract || failedContractId) && renderActiveAttempt()}
+                        {renderVersionList()}
+                      </tbody>
+                    </RevisionsTable>
+                  </TableWrapper>
+                )}
+              />
+            </StyledClusterRevisionSelector>
+            <Spacer y={1} />
+          </>
+        )
+      }
+    </>
+  );
+};
+
+export default ClusterRevisionSelector;
+
+const DeleteButton = styled.div`
+  position: absolute;
+  right: 10px;
+  top: 0px;
+  height: 100%;
+  display: flex;
+  align-items: center;
+
+  > i {
+    font-size: 16px;
+    padding: 5px;
+    :hover {
+      background: #ffffff22;
+      border-radius: 40px;
+    }
+  }
+`;
+
+const WarningIcon = styled.img`
+  height: 18px;
+  margin-right: 10px;
+  margin-left: -8px;
+`;
+
+const Failed = styled.div`
+  background: #cc3d42;
+  width: 100px;
+  border-radius: 3px;
+  color: white;
+  height: 22px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  color: #aaaabb;
+`;
+
+const Img = styled.img`
+  height: 15px;
+  margin-right: 7px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  cursor: not-allowed;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#405eddbb"};
+  }
+`;
+
+const Tr = styled.tr`
+  height: 40px;
+  position: relative;
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+  width: 100%;
+  font-size: 13px;
+  overflow-y: auto;
+  max-height: 200px;
+`;
+
+const Label = styled.div<{ isCurrent?: boolean }>`
+  color: ${props => props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  margin-right: 5px;
+`;
+
+const StyledClusterRevisionSelector = styled.div`
+`;

+ 167 - 64
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,48 +1,43 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
+import { useLocation } from "react-router";
+import settings from "assets/settings-centered.svg";
 
-import { Context } from "shared/Context";
-import TabSelector from "components/TabSelector";
-import Heading from "components/form-components/Heading";
-import TitleSection from "components/TitleSection";
 import api from "shared/api";
+import { DetailedIngressError } from "shared/types";
+import { getQueryParam } from "shared/routing";
+import useAuth from "shared/auth/useAuth";
+import { Context } from "shared/Context";
 
+import ClusterRevisionSelector from "./ClusterRevisionSelector";
+import DashboardHeader from "../DashboardHeader";
+import TabSelector from "components/TabSelector";
+import ProvisionerSettings from "components/ProvisionerSettings";
+import ProvisionerStatus from "./ProvisionerStatus";
 import NodeList from "./NodeList";
-
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
-import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import { useLocation } from "react-router";
-import { getQueryParam } from "shared/routing";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import Loading from "components/Loading";
-
-import { DetailedIngressError } from "shared/types";
-import SelectRow from "components/form-components/SelectRow";
+import Spacer from "components/porter/Spacer";
 
 type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents" | "configuration";
 
-const tabOptions: {
+var tabOptions: {
   label: string;
   value: TabEnum;
-}[] = [
-  // { label: "Configuration", value: "configuration" },
-  { label: "Nodes", value: "nodes" },
-  /*
-  { label: "Incidents", value: "incidents" },
-  */
-  { label: "Metrics", value: "metrics" },
-  { label: "Namespaces", value: "namespaces" },
-  { label: "Settings", value: "settings" },
-];
+}[] = [{ label: "Additional settings", value: "settings" }];
 
 export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
   const location = useLocation();
+  const [selectedClusterVersion, setSelectedClusterVersion] = useState(null);
+  const [showProvisionerStatus, setShowProvisionerStatus] = useState(false);
+  const [provisionFailureReason, setProvisionFailureReason] = useState("");
   const [ingressIp, setIngressIp] = useState(null);
   const [ingressError, setIngressError] = useState(null);
 
@@ -55,30 +50,41 @@ export const Dashboard: React.FunctionComponent = () => {
         return <Metrics />;
       case "namespaces":
         return <NamespaceList />;
-      /*
       case "configuration":
         return (
-          <FormWrapper>
-            <Heading isAtTop>
-              Cluster configuration
-            </Heading>
-            <SelectRow
-              value={"us-east-1"}
-              width="150px"
-              options={[
-                { label: "us-east-1", value: "us-east-1" }
-              ]}
-              setActiveValue={(option) => null}
-              label="AWS region"
+          <>
+            <Br />
+            <ProvisionerSettings
+              selectedClusterVersion={selectedClusterVersion}
+              clusterId={context.currentCluster.id}
+              credentialId={context.currentCluster.cloud_provider_credential_identifier}
             />
-          </FormWrapper>
+            <Div />
+          </>
         );
-      */
       default:
         return <NodeList />;
     }
   };
 
+  useEffect(() => {
+    if (
+      context.currentCluster.status !== "UPDATING_UNAVAILABLE" &&
+      !tabOptions.find((tab) => tab.value === "nodes")
+    ) {      
+      tabOptions.unshift({ label: "Namespaces", value: "namespaces" });
+      tabOptions.unshift({ label: "Metrics", value: "metrics" });
+      tabOptions.unshift({ label: "Nodes", value: "nodes" }); 
+    }
+    
+    if (
+      context.currentProject.capi_provisioner_enabled &&
+      !tabOptions.find((tab) => tab.value === "configuration")
+    ) {
+      tabOptions.unshift({ value: "configuration", label: "Configuration" });
+    } 
+  }, []);
+
   useEffect(() => {
     setCurrentTabOptions(
       tabOptions.filter((option) => {
@@ -99,7 +105,12 @@ export const Dashboard: React.FunctionComponent = () => {
 
   // Need to reset tab to reset views that don't auto-update on cluster switch (esp namespaces + settings)
   useEffect(() => {
-    setCurrentTab("nodes");
+    setShowProvisionerStatus(false);
+    if (context.currentProject.capi_provisioner_enabled) {
+      setCurrentTab("configuration");
+    } else {
+      setCurrentTab("nodes");
+    }
   }, [context.currentCluster]);
 
   const renderIngressIp = (
@@ -170,34 +181,127 @@ export const Dashboard: React.FunctionComponent = () => {
     updateClusterWithDetailedData();
   }, []);
 
+  const renderContents = () => {
+    if (context.currentProject.capi_provisioner_enabled) {
+      return (
+        <>
+          <ClusterRevisionSelector
+            selectedClusterVersion={selectedClusterVersion}
+            setSelectedClusterVersion={setSelectedClusterVersion}
+            setShowProvisionerStatus={setShowProvisionerStatus}
+            setProvisionFailureReason={setProvisionFailureReason}
+          />
+          {(
+            showProvisionerStatus && (
+              context.currentCluster.status === "UPDATING" ||
+              context.currentCluster.status === "UPDATING_UNAVAILABLE"
+            )
+          ) && (
+            <>
+              <ProvisionerStatus
+                provisionFailureReason={provisionFailureReason}
+              />
+              <Spacer y={1} />
+            </>
+          )}
+          <TabSelector
+            options={currentTabOptions}
+            currentTab={currentTab}
+            setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+          />
+          {renderTab()}
+        </>
+      );
+    } else {
+      return (
+        <>
+          <TabSelector
+            options={currentTabOptions}
+            currentTab={currentTab}
+            setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+          />
+          {renderTab()}
+        </>
+      );
+    }
+  };
+
   return (
     <>
-      <TitleSection>
-        <DashboardIcon>
-          <i className="material-icons">device_hub</i>
-        </DashboardIcon>
-        {context.currentCluster.name}
-      </TitleSection>
-
-      <InfoSection>
-        <TopRow>
-          <InfoLabel>
-            <i className="material-icons">info</i> Info
-          </InfoLabel>
-        </TopRow>
-        <Description>{renderIngressIp(ingressIp, ingressError)}</Description>
-      </InfoSection>
-
-      <TabSelector
-        options={currentTabOptions}
-        currentTab={currentTab}
-        setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+      <DashboardHeader
+        image={settings}
+        title={context.currentCluster.vanity_name || context.currentCluster.name}
+        description={
+          ingressIp ? (
+            <>{renderIngressIp(ingressIp, ingressError)}</>
+          ) : (
+            `Cluster settings and status for ${context.currentCluster.vanity_name || context.currentCluster.name}.`
+          )
+        }
+        disableLineBreak
+        capitalize={false}
       />
-      {renderTab()}
+
+      {renderContents()}
     </>
   );
 };
 
+const Div = styled.div`
+  width: 100%;
+  height: 50px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 15px;
+  cursor: pointer;
+  :hover {
+    background: ${props => props.showRevisions && "#ffffff18"};
+    > div > i {
+      background: ${props => props.showRevisions && "#ffffff22"};
+    }
+  }
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  margin-top: 25px;
+  margin-bottom: 22px;
+
+  > div > i {
+    margin-left: 12px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "#ffffff18" : ""};
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "rotate(180deg)" : ""};
+  }
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const DashboardIcon = styled.div`
   height: 35px;
   min-width: 35px;
@@ -241,10 +345,9 @@ const InfoLabel = styled.div`
 `;
 
 const InfoSection = styled.div`
-  margin-top: 36px;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 0px;
-  margin-bottom: 30px;
+  margin-top: -20px;
+  font-size: 13px;
+  margin-bottom: 25px;
 `;
 
 const Url = styled.a`
@@ -267,7 +370,7 @@ const Url = styled.a`
 `;
 
 const Bolded = styled.span`
-  color: #8b949f;
+  color: #aaaabb;
   margin-right: 6px;
   white-space: nowrap;
 `;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Metrics.tsx

@@ -566,7 +566,7 @@ const StyledMetricsSection = styled.div`
   flex-direction: column;
   position: relative;
   font-size: 13px;
-  border-radius: 8px;
+  border-radius: 5px;
   border: 1px solid #ffffff33;
   padding: 18px 22px;
   animation: floatIn 0.3s;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -155,7 +155,7 @@ const StyledChart = styled.div`
   :not(:last-child) {
     margin-bottom: 25px;
   }
-  border-radius: 8px;
+  border-radius: 5px;
   background: #26292e;
   border: 1px solid #494b4f;
 `;

+ 126 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx

@@ -0,0 +1,126 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+
+import aws from "assets/aws.png";
+import api from "shared/api";
+import loading from "assets/loading.gif";
+
+import { Context } from "shared/Context";
+import ExpandableSection from "components/porter/ExpandableSection";
+import LoadingBar from "components/porter/LoadingBar";
+import Spacer from "components/porter/Spacer";
+import Helper from "components/form-components/Helper";
+import Text from "components/porter/Text";
+
+type Props = {
+  provisionFailureReason: string;
+};
+
+const ProvisionerStatus: React.FC<Props> = ({
+  provisionFailureReason,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [progress, setProgress] = useState(1);
+
+  // Continuously poll provisioning status
+  const pollProvisioningStatus = async () => {
+    try {
+      const res = await api.getClusterStatus(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      const { status } = res.data;
+      switch (status) {
+        case status["BOOTSTRAP_READY"]:
+          setProgress(2);
+          break;
+        case status["CONTROL_PLANE_READY"]:
+          setProgress(3);
+          break;
+        case status["INFRASTRUCTURE_READY"]:
+          setProgress(4);
+          break;
+        default:
+          setProgress(1);
+      }
+    } catch (error) {}
+  };
+
+  return (
+    <StyledProvisionerStatus>
+      <HeaderSection>
+        <Flex>
+          <Icon src={aws} />
+          AWS provisioning status
+        </Flex>
+        <Spacer height="18px" />
+        <LoadingBar
+          color={provisionFailureReason && "failed"}
+          completed={progress} 
+          total={5} 
+        />
+        <Spacer height="18px" />
+        <Text color="#aaaabb">
+          Setup can take up to 20 minutes. You can close this window and come back later. 
+        </Text>
+      </HeaderSection>
+      {
+        provisionFailureReason && (
+          <DummyLogs>Error: {provisionFailureReason}</DummyLogs>
+        )
+      }
+    </StyledProvisionerStatus>
+  );
+};
+
+export default ProvisionerStatus;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderSection = styled.div`
+  padding: 15px;
+  padding-bottom: 18px;
+`;
+
+const DummyLogs = styled.div`
+  padding: 15px;
+  width: 100%;
+  display: flex;
+  font-size: 13px;
+  background: #101420;
+  font-family: monospace;
+`;
+
+const Icon = styled.img`
+  height: 16px;
+  margin-right: 10px;
+  margin-bottom: -1px;
+`;
+
+const Img = styled.img`
+  height: 15px;
+  margin-right: 7px;
+`;
+
+const Status = styled.div`
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+  margin-left: 15px;
+`;
+
+const StyledProvisionerStatus = styled.div`
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  width: 100%;
+  overflow: hidden;
+`;

+ 9 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -2,6 +2,7 @@ import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import sliders from "assets/sliders.svg";
+import loading from "assets/loading.gif";
 
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
@@ -13,12 +14,9 @@ import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
-import { getQueryParam, pushQueryParams } from "shared/routing";
+import { getQueryParam, pushQueryParams, pushFiltered } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { useQuery } from "@tanstack/react-query";
-import api from "shared/api";
-import Loading from "components/Loading";
-import Placeholder from "components/Placeholder";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -46,6 +44,8 @@ const EnvGroupDashboard = (props: PropsType) => {
       : "Newest",
   });
 
+  const { currentCluster } = useContext(Context);
+
   const setNamespace = (namespace: string) => {
     setState((state) => ({ ...state, namespace }));
     pushQueryParams(props, {
@@ -79,6 +79,10 @@ const EnvGroupDashboard = (props: PropsType) => {
   };
 
   const renderBody = () => {
+    if (props.currentCluster.status === "UPDATING_UNAVAILABLE") {
+      return <ClusterProvisioningPlaceholder />
+    }
+
     const goBack = () =>
       setState((state) => ({ ...state, createEnvMode: false }));
 

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -575,7 +575,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    if (currentChart?.git_action_config?.git_repo && !isStack) {
+    //if (currentChart?.git_action_config?.git_repo && !isStack) {
+    if (true) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",

+ 7 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -257,12 +257,12 @@ class RevisionSection extends Component<PropsType, StateType> {
           <RevisionsTable>
             <tbody>
               <Tr disableHover={true}>
-                <Th>Revision No.</Th>
+                <Th>Revision no.</Th>
                 <Th>Timestamp</Th>
                 <Th>
                   {this.props.chart.git_action_config ? "Commit" : "Image Tag"}
                 </Th>
-                <Th>Template Version</Th>
+                <Th>Template version</Th>
                 <Th>Rollback</Th>
               </Tr>
               {this.renderRevisionList()}
@@ -323,11 +323,11 @@ class RevisionSection extends Component<PropsType, StateType> {
           }}
         >
           <RevisionPreview>
+            <i className="material-icons">arrow_drop_down</i>
             {isCurrent
-              ? `Current revision`
+              ? `Current version`
               : `Previewing revision (not deployed)`}{" "}
             - <Revision>No. {this.props.chart.version}</Revision>
-            <i className="material-icons">arrow_drop_down</i>
           </RevisionPreview>
           {this.props.shouldUpdate && isCurrent && (
             <div>
@@ -470,24 +470,19 @@ const RevisionHeader = styled.div`
   height: 40px;
   font-size: 13px;
   width: 100%;
-  padding-left: 15px;
+  padding-left: 10px;
   cursor: pointer;
   :hover {
     background: ${props => props.showRevisions && "#ffffff18"};
-    > div > i {
-      background: ${props => props.showRevisions && "#ffffff22"};
-    }
   }
 
   > div > i {
-    margin-left: 12px;
+    margin-right: 8px;
     font-size: 20px;
     cursor: pointer;
     border-radius: 20px;
-    background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "#ffffff18" : ""};
     transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
-      props.showRevisions ? "rotate(180deg)" : ""};
+      props.showRevisions ? "" : "rotate(-90deg)"};
   }
 `;
 

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

@@ -136,7 +136,7 @@ const ControlRow = styled.div`
   margin-left: auto;
   justify-content: space-between;
   align-items: center;
-  margin: 35px 0 30px;
+  margin-bottom: 30px;
   padding-left: 0px;
 `;
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -148,7 +148,7 @@ const _RevisionList = ({
         >
           <RevisionPreview>
             {currentRevision.id === latestRevision.id
-              ? `Current revision`
+              ? `Current version`
               : `Previewing revision (not deployed)`}{" "}
             - <Revision>No. {currentRevision.id}</Revision>
             <i className="material-icons">arrow_drop_down</i>

+ 127 - 144
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -1,68 +1,43 @@
-import React, { Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ClusterType, DetailedClusterType } from "shared/types";
-import Helper from "components/form-components/Helper";
 import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
 
-import { RouteComponentProps, withRouter } from "react-router";
+import api from "shared/api";
+import loading from "assets/loading.gif";
+import Loading from "components/Loading";
 
-import Modal from "../modals/Modal";
+import { Context } from "shared/Context";
 import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-};
-
-type StateType = {
-  loading: boolean;
-  error: string;
-  clusters: DetailedClusterType[];
-  showErrorModal?: {
-    clusterId: number;
-    show: boolean;
-  };
-};
-
-class Templates extends Component<PropsType, StateType> {
-  state: StateType = {
-    loading: true,
-    error: "",
-    clusters: [],
-    showErrorModal: undefined,
-  };
-
-  componentDidMount() {
-    this.updateClusterList();
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.currentCluster?.name != this.props.currentCluster?.name) {
-      this.updateClusterList();
-    }
-  }
-
-  updateClusterList = async () => {
-    try {
-      const res = await api.getClusters(
-        "<token>",
-        {},
-        { id: this.context.currentProject.id }
-      );
-
-      if (res.data) {
-        this.setState({ clusters: res.data, loading: false, error: "" });
-      } else {
-        this.setState({ loading: false, error: "Response data missing" });
-      }
-    } catch (err) {
-      this.setState(err);
-    }
-  };
-
-  renderIcon = () => {
+type Props = {};
+
+const ClusterList: React.FC<Props> = ({}) => {
+  const { currentProject, setCurrentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [clusters, setClusters] = useState(null);
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    api.getClusters(
+      "<token>",
+      {},
+      { id: currentProject.id },
+    )
+      .then(({ data }) => {
+        console.log(data);
+        setClusters(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        setIsLoading(false);
+      });
+  }, [currentProject]);
+
+  const renderIcon = () => {
     return (
       <DashboardIcon>
         <svg
@@ -123,85 +98,96 @@ class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderClusters = () => {
-    return this.state.clusters.map(
-      (cluster: DetailedClusterType, i: number) => {
-        return (
-          <TemplateBlock
-            onClick={() => {
-              this.context.setCurrentCluster(cluster);
-              pushFiltered(this.props, "/applications", ["project_id"], {
-                cluster: cluster.name,
-              });
-            }}
-            key={i}
-          >
-            {this.renderIcon()}
-            <TemplateTitle>{cluster.name}</TemplateTitle>
-          </TemplateBlock>
-        );
+  return (
+    <>
+      {
+        isLoading ? (
+          <LoadingWrapper><Loading /></LoadingWrapper>
+        ) : (
+          <>
+            {
+              clusters.length === 0 && (
+                <Placeholder>
+                  <Heading isAtTop>No clusters found</Heading>
+                  <Helper>
+                    Create a cluster to deploy new applications.
+                  </Helper>
+                </Placeholder>
+              )
+            }
+            <StyledClusterList>
+              {clusters.map((cluster: any) => {
+                return (
+                  <ClusterRow
+                    key={cluster.id}
+                    onClick={() => {
+                      setCurrentCluster(cluster);
+                      pushFiltered({ location, history }, "/applications", ["project_id"], {
+                        cluster: cluster.name,
+                      });
+                    }}
+                  >
+                    {renderIcon()}
+                    {cluster.vanity_name || cluster.name}
+                    {
+                      (
+                        cluster.status === "UPDATING" || cluster.status === "UPDATING_UNAVAILABLE"
+                      ) && (
+                        <Status
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            setCurrentCluster(cluster);
+                            pushFiltered({ location, history }, "/cluster-dashboard", ["project_id"], {
+                              cluster: cluster.name,
+                            });
+                          }}
+                        >
+                          <Img src={loading} /> Updating
+                        </Status>
+                      )
+                    }
+                  </ClusterRow>
+                )
+              })}
+            </StyledClusterList>
+          </>
+        )
       }
-    );
-  };
-
-  renderErrorModal = () => {
-    const clusterError =
-      this.state.showErrorModal?.show &&
-      this.state.clusters.find(
-        (c) => c.id === this.state.showErrorModal?.clusterId
-      );
-    const ingressError = clusterError?.ingress_error;
-    return (
-      <>
-        {clusterError && (
-          <Modal
-            onRequestClose={() => this.setState({ showErrorModal: undefined })}
-            width="665px"
-            height="min-content"
-          >
-            Porter encountered an error. Full error log:
-            <CodeBlock>{ingressError.error}</CodeBlock>
-          </Modal>
-        )}
-      </>
-    );
-  };
-
-  render() {
-    return (
-      <StyledClusterList>
-        {/* <Heading isAtTop>Connected clusters</Heading> */}
-        <TemplateList>{this.renderClusters()}</TemplateList>
-        {this.renderErrorModal()}
-      </StyledClusterList>
-    );
-  }
-}
-
-Templates.contextType = Context;
+    </>
+  );
+};
 
-export default withRouter(Templates);
+export default ClusterList;
 
-const CodeBlock = styled.span`
-  display: block;
-  background-color: #1b1d26;
-  color: white;
+const Placeholder = styled.div`
+  padding: 25px;
   border-radius: 5px;
-  font-family: monospace;
-  user-select: text;
-  max-height: 400px;
-  width: 90%;
-  margin-left: 5%;
-  margin-top: 20px;
-  overflow-y: auto;
-  padding: 10px;
-  overflow-wrap: break-word;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  padding-bottom: 10px;
 `;
 
-const StyledClusterList = styled.div`
-  margin-top: -7px;
-  padding-left: 2px;
-  overflow: visible;
+const Img = styled.img`
+  height: 15px;
+  margin-right: 7px;
+`;
+
+const Status = styled.div`
+  margin-left: 15px;
+  border-radius: 50px;
+  padding: 5px 10px;
+  background: #ffffff11;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    background: #ffffff22;
+    border: 1px solid #7a7b80;
+    margin-top: -1px;
+    margin-bottom: -1px;
+    margin-left: 14px;
+  }
 `;
 
 const DashboardIcon = styled.div`
@@ -221,15 +207,7 @@ const DashboardIcon = styled.div`
   }
 `;
 
-const TemplateTitle = styled.div`
-  text-align: center;
-  white-space: nowrap;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const TemplateBlock = styled.div`
+const ClusterRow = styled.div`
   align-items: center;
   user-select: none;
   display: flex;
@@ -259,7 +237,12 @@ const TemplateBlock = styled.div`
   }
 `;
 
-const TemplateList = styled.div`
-  overflow-y: auto;
-  overflow: visible;
+const StyledClusterList = styled.div`
 `;
+
+const LoadingWrapper = styled.div`
+  height: calc(100vh - 450px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -4,7 +4,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
-import ClusterList from "./ClusterList";
+import ClusterList from "./OldClusterList";
 import Loading from "components/Loading";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 

+ 173 - 0
dashboard/src/main/home/dashboard/ClusterSection.tsx

@@ -0,0 +1,173 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+
+import Banner from "components/Banner";
+
+import ProvisionerFlow from "components/ProvisionerFlow";
+import ClusterList from "./ClusterList";
+import TitleSection from "components/TitleSection";
+
+type Props = {
+};
+
+const ClusterSection = (props: Props) => {
+  const { usage } = useContext(Context);
+
+  const [currentStep, setCurrentStep] = useState("");
+
+  if (currentStep === "cloud") {
+    return (
+      <>
+        <TitleSection handleNavBack={() => setCurrentStep("")}>
+          <Title>
+            <ClusterIcon>
+              <svg
+                width="19"
+                height="19"
+                viewBox="0 0 19 19"
+                fill="none"
+                xmlns="http://www.w3.org/2000/svg"
+              >
+                <path
+                  d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+                <path
+                  fillRule="evenodd"
+                  clipRule="evenodd"
+                  d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+                  stroke="white"
+                  strokeWidth="1.5"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                />
+              </svg>
+            </ClusterIcon>
+            Provision a new cluster
+          </Title>
+        </TitleSection>
+        <Br height="7px" />
+        <Banner>
+          You have currently provisioned {usage?.current.cluster || "0"} out of {usage?.limit.clusters || "0"} clusters for this project.
+        </Banner>
+        <Br />
+        <ProvisionerFlow />
+      </>
+    );
+  }
+  return (
+    <>
+      <Button onClick={() => setCurrentStep("cloud")}>
+        <i className="material-icons">add</i> Create a cluster
+      </Button>
+      <ClusterList />
+    </>
+  );
+};
+
+export default ClusterSection;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "30px"};
+`;
+
+const ClusterIcon = styled.div`
+  > svg {
+    width: 20px;
+    display: flex;
+    align-items: center;
+    margin-bottom: -1x;
+    margin-right: 15px;
+    color: #ffffff;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 5px;
+  border-radius: 2px;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  width: 147px;
+  margin-bottom: 30px;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 70 - 28
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -6,18 +6,20 @@ import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 import api from "shared/api";
 
+import { RouteComponentProps, withRouter } from "react-router";
+
 import ProvisionerSettings from "../provisioner/ProvisionerSettings";
 import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
-import { RouteComponentProps, withRouter } from "react-router";
 import TabRegion from "components/TabRegion";
-import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
+import ClusterSection from "./ClusterSection";
+import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
+import Banner from "components/Banner";
 
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
-import Banner from "components/Banner";
+import Spacer from "components/porter/Spacer";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -25,9 +27,6 @@ type PropsType = RouteComponentProps &
     setRefreshClusters: (x: boolean) => void;
   };
 
-// TODO: rethink this list, should be coupled with tabOptions
-const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
-
 type StateType = {
   infras: InfraType[];
   pressingCtrl: boolean;
@@ -93,16 +92,8 @@ class Dashboard extends Component<PropsType, StateType> {
     if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
       this.refreshInfras();
     }
-
-    if (!tabOptionStrings.includes(this.currentTab())) {
-      this.setCurrentTab("overview");
-    }
   }
 
-  onShowProjectSettings = () => {
-    pushFiltered(this.props, "/project-settings", ["project_id"]);
-  };
-
   currentTab = () => new URLSearchParams(this.props.location.search).get("tab");
 
   renderTabContents = () => {
@@ -138,10 +129,12 @@ class Dashboard extends Component<PropsType, StateType> {
     }
   };
 
-  setCurrentTab = (x: string) => {
-    pushQueryParams(this.props, { tab: x });
+  onShowProjectSettings = () => {
+    pushFiltered(this.props, "/project-settings", ["project_id"]);
   };
 
+  setCurrentTab = (x: string) => pushQueryParams(this.props, { tab: x });
+
   render() {
     let { currentProject, capabilities } = this.context;
     let { onShowProjectSettings } = this;
@@ -187,7 +180,7 @@ class Dashboard extends Component<PropsType, StateType> {
                     </i>
                   )}
                 </TitleSection>
-                <Br />
+                <Spacer height="15px" />
 
                 <InfoSection>
                   <TopRow>
@@ -200,13 +193,19 @@ class Dashboard extends Component<PropsType, StateType> {
                     .
                   </Description>
                 </InfoSection>
-                <TabRegion
-                  currentTab={this.currentTab()}
-                  setCurrentTab={this.setCurrentTab}
-                  options={tabOptions}
-                >
-                  {this.renderTabContents()}
-                </TabRegion>
+                {
+                  currentProject.capi_provisioner_enabled ? (
+                    <ClusterSection />
+                  ) : (
+                    <TabRegion
+                      currentTab={this.currentTab()}
+                      setCurrentTab={this.setCurrentTab}
+                      options={tabOptions}
+                    >
+                      {this.renderTabContents()}
+                    </TabRegion>
+                  )
+                }
               </>
             )}
           </DashboardWrapper>
@@ -220,6 +219,49 @@ Dashboard.contextType = Context;
 
 export default withRouter(withAuth(Dashboard));
 
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  width: 147px;
+  margin-bottom: 30px;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  padding-right: 13px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 1px;
@@ -252,7 +294,7 @@ const TopRow = styled.div`
 `;
 
 const Description = styled.div`
-  color: #8b949f;
+  color: #aaaabb;
   margin-top: 13px;
   margin-left: 2px;
   font-size: 13px;
@@ -263,10 +305,10 @@ const InfoLabel = styled.div`
   height: 20px;
   display: flex;
   align-items: center;
-  color: #8b949f;
+  color: #aaaabb;
   font-size: 13px;
   > i {
-    color: #8b949f;
+    color: #aaaabb;
     font-size: 18px;
     margin-right: 5px;
   }

+ 264 - 0
dashboard/src/main/home/dashboard/OldClusterList.tsx

@@ -0,0 +1,264 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ClusterType, DetailedClusterType } from "shared/types";
+import Helper from "components/form-components/Helper";
+import { pushFiltered } from "shared/routing";
+
+import { RouteComponentProps, withRouter } from "react-router";
+
+import Modal from "../modals/Modal";
+import Heading from "components/form-components/Heading";
+
+type PropsType = RouteComponentProps & {
+  currentCluster: ClusterType;
+};
+
+type StateType = {
+  loading: boolean;
+  error: string;
+  clusters: DetailedClusterType[];
+  showErrorModal?: {
+    clusterId: number;
+    show: boolean;
+  };
+};
+
+class Templates extends Component<PropsType, StateType> {
+  state: StateType = {
+    loading: true,
+    error: "",
+    clusters: [],
+    showErrorModal: undefined,
+  };
+
+  componentDidMount() {
+    this.updateClusterList();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentCluster?.name != this.props.currentCluster?.name) {
+      this.updateClusterList();
+    }
+  }
+
+  updateClusterList = async () => {
+    try {
+      const res = await api.getClusters(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      );
+
+      if (res.data) {
+        this.setState({ clusters: res.data, loading: false, error: "" });
+      } else {
+        this.setState({ loading: false, error: "Response data missing" });
+      }
+    } catch (err) {
+      this.setState(err);
+    }
+  };
+
+  renderIcon = () => {
+    return (
+      <DashboardIcon>
+        <svg
+          width="16"
+          height="16"
+          viewBox="0 0 19 19"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <path
+            d="M15.207 12.4403C16.8094 12.4403 18.1092 11.1414 18.1092 9.53907C18.1092 7.93673 16.8094 6.63782 15.207 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M3.90217 12.4403C2.29983 12.4403 1 11.1414 1 9.53907C1 7.93673 2.29983 6.63782 3.90217 6.63782"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M9.54993 13.4133C7.4086 13.4133 5.69168 11.6964 5.69168 9.55417C5.69168 7.41284 7.4086 5.69592 9.54993 5.69592C11.6913 5.69592 13.4082 7.41284 13.4082 9.55417C13.4082 11.6964 11.6913 13.4133 9.54993 13.4133Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 15.207C6.66895 16.8094 7.96787 18.1092 9.5702 18.1092C11.1725 18.1092 12.4715 16.8094 12.4715 15.207"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            d="M6.66895 3.90217C6.66895 2.29983 7.96787 1 9.5702 1C11.1725 1 12.4715 2.29983 12.4715 3.90217"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M5.69591 9.54996C5.69591 7.40863 7.41283 5.69171 9.55508 5.69171C11.6964 5.69171 13.4133 7.40863 13.4133 9.54996C13.4133 11.6913 11.6964 13.4082 9.55508 13.4082C7.41283 13.4082 5.69591 11.6913 5.69591 9.54996Z"
+            stroke="white"
+            strokeWidth="1.5"
+            strokeLinecap="round"
+            stroke-linejoin="round"
+          />
+        </svg>
+      </DashboardIcon>
+    );
+  };
+
+  renderClusters = () => {
+    return this.state.clusters.map(
+      (cluster: DetailedClusterType, i: number) => {
+        return (
+          <TemplateBlock
+            onClick={() => {
+              this.context.setCurrentCluster(cluster);
+              pushFiltered(this.props, "/applications", ["project_id"], {
+                cluster: cluster.name,
+              });
+            }}
+            key={i}
+          >
+            {this.renderIcon()}
+            <TemplateTitle>{cluster.vanity_name || cluster.name}</TemplateTitle>
+          </TemplateBlock>
+        );
+      }
+    );
+  };
+
+  renderErrorModal = () => {
+    const clusterError =
+      this.state.showErrorModal?.show &&
+      this.state.clusters.find(
+        (c) => c.id === this.state.showErrorModal?.clusterId
+      );
+    const ingressError = clusterError?.ingress_error;
+    return (
+      <>
+        {clusterError && (
+          <Modal
+            onRequestClose={() => this.setState({ showErrorModal: undefined })}
+            width="665px"
+            height="min-content"
+          >
+            Porter encountered an error. Full error log:
+            <CodeBlock>{ingressError.error}</CodeBlock>
+          </Modal>
+        )}
+      </>
+    );
+  };
+
+  render() {
+    return (
+      <StyledClusterList>
+        {/* <Heading isAtTop>Connected clusters</Heading> */}
+        <TemplateList>{this.renderClusters()}</TemplateList>
+        {this.renderErrorModal()}
+      </StyledClusterList>
+    );
+  }
+}
+
+Templates.contextType = Context;
+
+export default withRouter(Templates);
+
+const CodeBlock = styled.span`
+  display: block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  max-height: 400px;
+  width: 90%;
+  margin-left: 5%;
+  margin-top: 20px;
+  overflow-y: auto;
+  padding: 10px;
+  overflow-wrap: break-word;
+`;
+
+const StyledClusterList = styled.div`
+  padding-left: 2px;
+  overflow: visible;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 25px;
+  min-width: 25px;
+  width: 25px;
+  border-radius: 200px;
+  margin-right: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 1px solid #8e94aa;
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TemplateTitle = styled.div`
+  text-align: center;
+  white-space: nowrap;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 15px;
+  margin-bottom: 20px;
+  align-item: center;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TemplateList = styled.div`
+  overflow-y: auto;
+  overflow: visible;
+`;

+ 1 - 1
dashboard/src/main/home/infrastructure/InfrastructureList.tsx

@@ -178,7 +178,7 @@ const InfrastructureList = () => {
         <DashboardIcon>
           <i className="material-icons">build_circle</i>
         </DashboardIcon>
-        Managed Infrastructure
+        Managed infrastructure (legacy)
       </StyledTitleSection>
       <InfoSection>
         <Description>

+ 5 - 5
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -148,7 +148,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
         );
       });
     }
-    return <Placeholder>No integrations set up yet.</Placeholder>;
+    return <Placeholder>No Docker integrations set up yet.</Placeholder>;
   };
 
   collapseAllButton = () => (
@@ -259,14 +259,14 @@ const Placeholder = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   justify-content: center;
-  margin-top: 30px;
-  background: #ffffff11;
-  color: #ffffff44;
+  margin-top: 40px;
+  color: #aaaabb;
   border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
 `;
 
 const StyledIntegrationList = styled.div`
-  margin-top: 30px;
   margin-bottom: 80px;
 `;
 

+ 2 - 1
dashboard/src/main/home/integrations/Integrations.tsx

@@ -10,6 +10,7 @@ import IntegrationCategories from "./IntegrationCategories";
 import IntegrationList from "./IntegrationList";
 import TitleSection from "components/TitleSection";
 import { Context } from "shared/Context";
+import Spacer from "components/porter/Spacer";
 
 type PropsType = RouteComponentProps;
 
@@ -75,7 +76,7 @@ const Integrations: React.FC<PropsType> = (props) => {
         <Route>
           <div>
             <TitleSection>Integrations</TitleSection>
-
+            <Spacer y={1} />
             <IntegrationList
               currentCategory={""}
               integrations={IntegrationCategoryStrings}

+ 4 - 3
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -105,10 +105,11 @@ const Placeholder = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   justify-content: center;
-  margin-top: 30px;
-  background: #ffffff11;
-  color: #ffffff44;
+  margin-top: 40px;
+  color: #aaaabb;
   border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
 `;
 
 const Label = styled.div`

+ 48 - 3
dashboard/src/main/home/launch/Launch.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ChartTypeWithExtendedConfig, PorterTemplate } from "shared/types";
+import { ChartTypeWithExtendedConfig, PorterTemplate, ClusterType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
@@ -11,12 +11,14 @@ import Loading from "components/Loading";
 import LaunchFlow from "./launch-flow/LaunchFlow";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 import TitleSection from "components/TitleSection";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 
 import semver from "semver";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, getQueryParams } from "shared/routing";
 import TemplateList from "./TemplateList";
 import { capitalize } from "lodash";
+import Spacer from "components/porter/Spacer";
 
 const initialTabOptions = [
   { label: "New application", value: "porter" },
@@ -43,6 +45,7 @@ type StateType = {
   isOnLaunchFlow: boolean;
   clonedChart: ChartTypeWithExtendedConfig;
   tabOptions: TabOption[];
+  readyClusterStatus: string;
 };
 class Templates extends Component<PropsType, StateType> {
   private previousContext: any;
@@ -58,6 +61,7 @@ class Templates extends Component<PropsType, StateType> {
     isOnLaunchFlow: false,
     clonedChart: null as ChartTypeWithExtendedConfig,
     tabOptions: initialTabOptions,
+    readyClusterStatus: "checking",
   };
 
   componentDidMount() {
@@ -82,6 +86,32 @@ class Templates extends Component<PropsType, StateType> {
       return;
     }
 
+    // Block launch tab on initial provisioning
+    api.getClusters(
+      "<token>",
+      {},
+      { id: this.context.currentProject.id },
+    )
+      .then(({ data }) => {
+        let numUnavailable = 0;
+        data.forEach((cluster: ClusterType) => {
+          if (cluster.status === "UPDATING_UNAVAILABLE") {
+            numUnavailable += 1;
+          }
+        });
+
+        if (data.length === 0) {
+          this.setState({ readyClusterStatus: "onboarding" });
+        } else if (numUnavailable === data.length) {
+          this.setState({ readyClusterStatus: "none-ready" });
+        } else {
+          this.setState({ readyClusterStatus: "has-ready" });
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+
     let default_addon_helm_repo_url = this.context?.capabilities
       ?.default_addon_helm_repo_url;
     let default_app_helm_repo_url = this.context?.capabilities
@@ -336,7 +366,16 @@ class Templates extends Component<PropsType, StateType> {
   };
 
   renderContents = () => {
-    if (this.context.currentCluster) {
+    if (this.state.readyClusterStatus === "checking") {
+      return <Loading height="300px" />;
+    } else if (this.state.readyClusterStatus === "none-ready") {
+      return (
+        <>
+          <Br />
+          <ClusterProvisioningPlaceholder />
+        </>
+      )
+    } else if (this.context.currentCluster) {
       return (
         <>
           <TabSelector
@@ -353,7 +392,7 @@ class Templates extends Component<PropsType, StateType> {
         </>
       );
     } else if (this.context.currentCluster?.id === -1) {
-      return <Loading />;
+      return <Loading height="300px" />;
     } else if (!this.context.currentCluster) {
       return (
         <>
@@ -383,6 +422,7 @@ class Templates extends Component<PropsType, StateType> {
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>
+          <Spacer height="20px" />
           {this.renderContents()}
         </TemplatesWrapper>
       );
@@ -405,6 +445,11 @@ Templates.contextType = Context;
 
 export default withRouter(Templates);
 
+const Br = styled.div`
+  width: 100%;
+  height: 10px;
+`;
+
 const Placeholder = styled.div`
   padding-top: 200px;
   width: 100%;

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -74,7 +74,7 @@ class SettingsPage extends Component<PropsType, StateType> {
         let clusterOptions: { label: string; value: string }[] = [];
         let clusterMap: { [clusterId: string]: ClusterType } = {};
         res.data.forEach((cluster: ClusterType, i: number) => {
-          clusterOptions.push({ label: cluster.name, value: cluster.name });
+          clusterOptions.push({ label: cluster.vanity_name || cluster.name, value: cluster.name });
           clusterMap[cluster.name] = cluster;
         });
         if (res.data.length > 0) {

+ 2 - 2
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -48,7 +48,7 @@ const AccountSettingsModal = () => {
       />
 
       <Heading>
-        <GitIcon src={github} /> Github
+        <GitIcon src={github} /> GitHub
       </Heading>
       {accessLoading ? (
         <LoadingWrapper>
@@ -144,7 +144,7 @@ const List = styled.div`
   border-radius: 5px;
   margin-top: 20px;
   border: 1px solid #ffffff44;
-  max-height: 200px;
+  max-height: 120px;
   overflow-y: auto;
 `;
 

+ 11 - 13
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -85,9 +85,7 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
     if (!currentCluster?.infra_id || !currentCluster.service) {
       return (
         <Warning highlight={true}>
-          ⚠️ Since this cluster was not provisioned by Porter, deleting the
-          cluster will only detach this cluster from your project. To delete the
-          cluster itself, you must do so manually.
+          ⚠️ Deleting the cluster will only detach this cluster from your project. To delete resources you must do so manually.
         </Warning>
       );
     }
@@ -115,7 +113,7 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
             value={this.state.clusterName}
             setValue={(x: string) => this.setState({ clusterName: x })}
             placeholder="ex: perspective-vortex"
-            width="470px"
+            width="490px"
           />
         </InputWrapper>
 
@@ -125,11 +123,11 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
           href="https://docs.getporter.dev/docs/deleting-dangling-resources"
           target="_blank"
         >
-          <i className="material-icons">help_outline</i> Help
+          <i className="material-icons">help_outline</i> How to delete resources
         </Help>
 
         <SaveButton
-          text="Delete Cluster"
+          text="Delete cluster"
           color="#b91133"
           onClick={() => this.setState({ showDeleteOverlay: true })}
           status={this.state.status}
@@ -156,6 +154,7 @@ const Help = styled.a`
   bottom: 35px;
   display: flex;
   align-items: center;
+  z-index: 999;
   justify-content: center;
   color: #ffffff55;
   font-size: 13px;
@@ -164,8 +163,8 @@ const Help = styled.a`
   }
 
   > i {
-    margin-right: 9px;
-    font-size: 16px;
+    margin-right: 5px;
+    font-size: 14px;
   }
 `;
 
@@ -175,7 +174,6 @@ const Warning = styled.div`
   border-radius: 3px;
   width: calc(100%);
   margin-top: 10px;
-  margin-left: 2px;
   line-height: 1.4em;
   align-items: center;
   color: white;
@@ -188,15 +186,15 @@ const Warning = styled.div`
 `;
 
 const DashboardIcon = styled.div`
-  width: 25px;
-  min-width: 25px;
-  height: 25px;
+  width: 35px;
+  min-width: 35px;
+  height: 35px;
   border-radius: 3px;
   overflow: hidden;
   position: relative;
   margin-right: 10px;
   font-weight: 400;
-  margin-top: 14px;
+  margin-top: 8px;
   display: flex;
   align-items: center;
   justify-content: center;

+ 46 - 1
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -1,4 +1,5 @@
 import Loading from "components/Loading";
+import ProvisionerFlow from "components/ProvisionerFlow";
 import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -9,6 +10,10 @@ import { OFState } from "./state";
 import { useSteps } from "./state/StepHandler";
 import { Onboarding as OnboardingSaveType } from "./types";
 
+import lightning from "assets/lightning.png";
+
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+
 const Onboarding = () => {
   const context = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
@@ -147,13 +152,53 @@ const Onboarding = () => {
     checkIfUserHasClusters();
   }, [context?.currentProject?.id]);
 
+  const renderOnboarding = () => {
+    if (context?.currentProject?.capi_provisioner_enabled) {
+      return (
+        <Wrapper>
+          <DashboardHeader
+            image={lightning}
+            title="Getting started"
+            description="Create a new cluster in your own cloud provider to get started with Porter."
+            disableLineBreak
+            capitalize={false}
+          />
+          <Br />
+          <ProvisionerFlow />
+          <Div />
+        </Wrapper>
+      )
+    } else {
+      return (
+        <StyledOnboarding>
+          {isLoading ? <Loading /> : <Routes />}
+        </StyledOnboarding>
+      )
+    }
+  };
+
   return (
-    <StyledOnboarding>{isLoading ? <Loading /> : <Routes />}</StyledOnboarding>
+    <>{renderOnboarding()}</>
   );
 };
 
 export default Onboarding;
 
+const Div = styled.div`
+  width: 100%;
+  height: 100px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+  margin-top: -1px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+`;
+
 const ViewWrapper = styled.div`
   width: 100%;
   overflow-y: auto;

+ 4 - 3
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -509,10 +509,11 @@ const Placeholder = styled.div`
   align-items: center;
   margin-top: 23px;
   justify-content: center;
-  background: #ffffff11;
-  border-radius: 5px;
-  color: #ffffff44;
   font-size: 13px;
+  color: #aaaabb;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
 `;
 
 const ButtonWrapper = styled.div`

+ 2 - 0
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -14,6 +14,7 @@ import { getQueryParam } from "shared/routing";
 import BillingPage from "./BillingPage";
 import APITokensSection from "./APITokensSection";
 import _ from "lodash";
+import Spacer from "components/porter/Spacer";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 
@@ -185,6 +186,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
     return (
       <StyledProjectSettings>
         <TitleSection>Project settings</TitleSection>
+        <Spacer height="20px" />
         <TabRegion
           currentTab={this.state.currentTab}
           setCurrentTab={(x: string) => this.setState({ currentTab: x })}

+ 2 - 2
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -134,7 +134,7 @@ export const ClusterSection: React.FC<Props> = ({
               window.location.pathname.startsWith("/cluster-dashboard")
             }
           >
-            <Icon className="material-icons">device_hub</Icon>
+            <Img enlarge={true} src={settings} />
             Cluster settings
           </NavButton>
         </Relative>
@@ -216,7 +216,7 @@ export const ClusterSection: React.FC<Props> = ({
             </svg>
           </ClusterIcon>
           <Tooltip title={cluster?.name}>
-            <ClusterName>{cluster?.name}</ClusterName>
+            <ClusterName>{cluster?.vanity_name || cluster?.name}</ClusterName>
           </Tooltip>
           <I isExpanded={isExpanded} className="material-icons">
             arrow_drop_down

+ 6 - 0
dashboard/src/shared/Context.tsx

@@ -64,6 +64,8 @@ export interface GlobalContextType {
   setCanCreateProject: (canCreateProject: boolean) => void;
   enableGitlab: boolean;
   setEnableGitlab: (enableGitlab: boolean) => void;
+  shouldRefreshClusters: boolean;
+  setShouldRefreshClusters: (shouldRefreshClusters: boolean) => void;
 }
 
 /**
@@ -196,6 +198,10 @@ class ContextProvider extends Component<PropsType, StateType> {
     setEnableGitlab: (enableGitlab) => {
       this.setState({ enableGitlab });
     },
+    shouldRefreshClusters: false,
+    setShouldRefreshClusters: (shouldRefreshClusters) => {
+      this.setState({ shouldRefreshClusters });
+    },
   };
 
   render() {

+ 73 - 6
dashboard/src/shared/api.tsx

@@ -7,6 +7,7 @@ import {
   CreateStackBody,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
+import { Contract } from "@porter-dev/api-contracts";
 
 /**
  * Generic api call format
@@ -748,6 +749,16 @@ const getCluster = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
+const getClusterStatus = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/status`;
+});
+
 const getClusterNodes = baseApi<
   {},
   {
@@ -854,6 +865,61 @@ const getInfraTemplate = baseApi<
   return `/api/projects/${project_id}/infras/templates/${name}/${version}`;
 });
 
+const provisionCluster = baseApi<
+  {
+    project_id: number,
+    cluster_id?: number,
+    cloud_provider: string,
+    cloud_provider_credentials_id: string,
+    cluster_settings: {
+      cluster_name: string,
+      cluster_version: string,
+      cidr_range: string,
+      region: string,
+      node_groups: [
+        {
+          instance_type: string,
+          min_instances: number,
+          max_instances: number,
+          node_group_type: number
+        },
+        {
+          instance_type: string,
+          min_instances: number,
+          max_instances: number,
+          node_group_type: number
+        }
+      ]
+    }
+  },
+  {
+    project_id: number;
+  }
+>("POST", ({ project_id }) => {
+  return `/api/projects/${project_id}/provision/cluster`;
+});
+
+const createContract = baseApi<
+  Contract,
+  { project_id: number }
+>("POST", ({ project_id }) => {
+  return `/api/projects/${project_id}/contract`;
+});
+
+const getContracts = baseApi<
+  { cluster_id?: number },
+  { project_id: number }
+>("GET", ({ project_id }) => {
+  return `/api/projects/${project_id}/contracts`;
+});
+
+const deleteContract = baseApi<
+  {},
+  { project_id: number, revision_id: string }
+>("DELETE", ({ project_id, revision_id }) => {
+  return `/api/projects/${project_id}/contracts/${revision_id}`;
+});
+
 const provisionInfra = baseApi<
   {
     kind: string;
@@ -872,9 +938,7 @@ const provisionInfra = baseApi<
 });
 
 const updateInfra = baseApi<
-  {
-    values?: any;
-  },
+  { values?: any },
   {
     project_id: number;
     infra_id: number;
@@ -901,9 +965,7 @@ const retryCreateInfra = baseApi<
 });
 
 const retryDeleteInfra = baseApi<
-  {
-    values?: any;
-  },
+  { values?: any },
   {
     project_id: number;
     infra_id: number;
@@ -2379,6 +2441,7 @@ export default {
   getCluster,
   getClusterNodes,
   getClusterNode,
+  getClusterStatus,
   getConfigMap,
   getPRDeploymentList,
   getPRDeploymentByID,
@@ -2392,6 +2455,7 @@ export default {
   listInfraTemplates,
   getInfraTemplate,
   getInfra,
+  provisionCluster,
   provisionInfra,
   deleteInfra,
   updateInfra,
@@ -2510,6 +2574,9 @@ export default {
   listIncidents,
   getIncident,
   getIncidentEvents,
+  createContract,
+  getContracts,
+  deleteContract,
   // STACKS
   listStacks,
   getStack,

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

@@ -2,6 +2,7 @@ import aws from "../assets/aws.png";
 import digitalOcean from "../assets/do.png";
 import gcp from "../assets/gcp.png";
 import github from "../assets/github.png";
+import azure from "assets/azure.png";
 
 export const infraNames: any = {
   ecr: "Elastic Container Registry (ECR)",
@@ -28,7 +29,7 @@ export const integrationList: any = {
     icon:
       "https://user-images.githubusercontent.com/5147537/54070671-0a173780-4263-11e9-8946-09ac0e37d8c6.png",
     label: "Slack",
-    buttonText: "Install Application",
+    buttonText: "Install application",
   },
   registry: {
     icon:
@@ -101,11 +102,12 @@ export const integrationList: any = {
   gcp: {
     icon: gcp,
     label: "GCP",
+    tagline: "Coming soon"
   },
-  gar: {
-    icon:
-      "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
-    label: "Google Artifact Registry (GAR)",
+  azure: {
+    icon: azure,
+    label: "Azure",
+    tagline: "Coming soon"
   },
   do: {
     icon: digitalOcean,

+ 6 - 0
dashboard/src/shared/types.tsx

@@ -3,6 +3,7 @@ import ValuesYaml from "main/home/cluster-dashboard/expanded-chart/ValuesYaml";
 export interface ClusterType {
   id: number;
   name: string;
+  vanity_name?: string;
   server: string;
   service_account_id: number;
   agent_integration_enabled: boolean;
@@ -11,6 +12,8 @@ export interface ClusterType {
   aws_integration_id?: number;
   aws_cluster_id?: string;
   preview_envs_enabled?: boolean;
+  cloud_provider_credential_identifier?: string;
+  status?: string;
 }
 
 export interface DetailedClusterType extends ClusterType {
@@ -261,6 +264,7 @@ export interface ProjectType {
   preview_envs_enabled: boolean;
   enable_rds_databases: boolean;
   managed_infra_enabled: boolean;
+  capi_provisioner_enabled: boolean;
   api_tokens_enabled: boolean;
   stacks_enabled: boolean;
   roles: {
@@ -370,6 +374,8 @@ export interface ContextProps {
   setCanCreateProject: (canCreateProject: boolean) => void;
   enableGitlab: boolean;
   setEnableGitlab: (enableGitlab: boolean) => void;
+  shouldRefreshClusters: boolean;
+  setShouldRefreshClusters: (shouldRefreshClusters: boolean) => void;
 }
 
 export enum JobStatusType {

+ 0 - 0
docker/Untitled-1


+ 1 - 1
docs/guides/linking-slack-integration.md

@@ -4,7 +4,7 @@ Porter has a Slack application that you can install into a channel of any worksp
 
 ## Installing Application
 
-To install the Slack application, navigate to the **Integrations** section on the left and click **Slack**. Then, click **Install Application** in the top right:
+To install the Slack application, navigate to the **Integrations** section on the left and click **Slack**. Then, click **Install application** in the top right:
 
 ![image](https://user-images.githubusercontent.com/25856165/128559944-d14cb6f9-8bfd-4294-8ed1-5455f3c3304d.png)
 

+ 4 - 0
ee/api/server/handlers/billing/webhook.go

@@ -107,6 +107,10 @@ func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		project.PreviewEnvsEnabled = previewEnvsEnabled
 	}
 
+	if capiProvisionerEnabled, err := strconv.ParseBool(features.CapiProvisionerEnabled); err == nil {
+		project.CapiProvisionerEnabled = capiProvisionerEnabled
+	}
+
 	_, err = c.Repo().Project().UpdateProject(project)
 
 	if err != nil {

+ 1 - 0
ee/billing/client.go

@@ -289,5 +289,6 @@ func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.Project
 			ManagedInfraEnabled:        usageData.ManagedInfraEnabled,
 			StacksEnabled:              usageData.StacksEnabled,
 			ManagedDatabasesEnabled:    usageData.ManagedDatabasesEnabled,
+			CapiProvisionerEnabled:     usageData.CapiProvisionerEnabled,
 		}, nil
 }

+ 1 - 0
ee/billing/types.go

@@ -27,6 +27,7 @@ type APIWebhookRequest struct {
 	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
 	StacksEnabled              string `json:"stacks_enabled,omitempty"`
 	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
+	CapiProvisionerEnabled     string `json:"capi_provisioner_enabled,omitempty"`
 }
 
 type CreateBillingCookieRequest struct {

+ 14 - 8
go.mod

@@ -46,13 +46,13 @@ require (
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.0
 	github.com/stretchr/testify v1.8.1
-	golang.org/x/crypto v0.4.0
-	golang.org/x/net v0.4.0
+	golang.org/x/crypto v0.6.0
+	golang.org/x/net v0.6.0
 	golang.org/x/oauth2 v0.3.0
 	google.golang.org/api v0.103.0
 	google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd
 	google.golang.org/grpc v1.50.1
-	google.golang.org/protobuf v1.28.1
+	google.golang.org/protobuf v1.29.0
 	gorm.io/gorm v1.24.2
 	k8s.io/api v0.26.0
 	k8s.io/apimachinery v0.26.0
@@ -70,8 +70,11 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v0.23.1
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v0.5.0
 	github.com/briandowns/spinner v1.18.1
+	github.com/bufbuild/connect-go v1.5.2
 	github.com/glebarez/sqlite v1.6.0
+	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
+	github.com/porter-dev/api-contracts v0.0.43
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
 	github.com/xanzy/go-gitlab v0.68.0
@@ -120,6 +123,9 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/nats-io/nats-server/v2 v2.9.15 // indirect
+	github.com/nats-io/nkeys v0.3.0 // indirect
+	github.com/nats-io/nuid v1.0.1 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
@@ -220,7 +226,7 @@ require (
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
-	github.com/klauspost/compress v1.15.7 // indirect
+	github.com/klauspost/compress v1.16.0 // indirect
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -286,10 +292,10 @@ require (
 	go.starlark.net v0.0.0-20220328144851-d1966c6b9fcd // indirect
 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
 	golang.org/x/sync v0.1.0 // indirect
-	golang.org/x/sys v0.3.0 // indirect
-	golang.org/x/term v0.3.0 // indirect
-	golang.org/x/text v0.5.0 // indirect
-	golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
+	golang.org/x/sys v0.5.0 // indirect
+	golang.org/x/term v0.5.0 // indirect
+	golang.org/x/text v0.7.0 // indirect
+	golang.org/x/time v0.3.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/ini.v1 v1.66.2 // indirect

+ 31 - 16
go.sum

@@ -299,6 +299,8 @@ github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gL
 github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
+github.com/bufbuild/connect-go v1.5.2 h1:G4EZd5gF1U1ZhhbVJXplbuUnfKpBZ5j5izqIwu2g2W8=
+github.com/bufbuild/connect-go v1.5.2/go.mod h1:GmMJYR6orFqD0Y6ZgX8pwQ8j9baizDrIQMm1/a6LnHk=
 github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
 github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng=
@@ -1125,8 +1127,8 @@ github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY
 github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
 github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
 github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.15.7 h1:7cgTQxJCU/vy+oP/E3B9RGbQTgbiVzIJWIKOLoAsPok=
-github.com/klauspost/compress v1.15.7/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
+github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -1265,6 +1267,7 @@ github.com/miekg/dns v1.1.35/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7
 github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg=
 github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
 github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
 github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
 github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
@@ -1344,9 +1347,18 @@ github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007/go.mod
 github.com/mwitkow/go-proto-validators v0.2.0/go.mod h1:ZfA1hW+UH/2ZHOWvQ3HnQaU0DtnpXu850MZiy+YUgcc=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
+github.com/nats-io/jwt v0.3.0 h1:xdnzwFETV++jNc4W1mw//qFyJGb2ABOombmZJQS4+Qo=
 github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
+github.com/nats-io/jwt/v2 v2.3.0 h1:z2mA1a7tIf5ShggOFlR1oBPgd6hGqcDYsISxZByUzdI=
+github.com/nats-io/nats-server/v2 v2.9.15 h1:MuwEJheIwpvFgqvbs20W8Ish2azcygjf4Z0liVu2I4c=
+github.com/nats-io/nats-server/v2 v2.9.15/go.mod h1:QlCTy115fqpx4KSOPFIxSV7DdI6OxtZsGOL1JLdeRlE=
 github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
+github.com/nats-io/nats.go v1.24.0 h1:CRiD8L5GOQu/DcfkmgBcTTIQORMwizF+rPk6T0RaHVQ=
+github.com/nats-io/nats.go v1.24.0/go.mod h1:dVQF+BK3SzUZpwyzHedXsvH3EO38aVKuOPkkHlv5hXA=
 github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
+github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8=
+github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
+github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
 github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8=
 github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=
@@ -1454,6 +1466,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
+github.com/porter-dev/api-contracts v0.0.43 h1:X+LWp19k/NR2/BJxmA8xJ3mEmmORI4MeJJpypkArU6Q=
+github.com/porter-dev/api-contracts v0.0.43/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQvJq6TgkKyEWP95dyU=
 github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935 h1:hfb3nt3AJXIBbevu6ARTg9SdOkMP6WLbKBiG5hT5rcc=
 github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -1849,6 +1863,7 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
 golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -1858,8 +1873,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
-golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
+golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1966,8 +1981,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2129,8 +2144,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -2139,8 +2154,8 @@ golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9sn
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
-golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
+golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2151,8 +2166,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -2160,8 +2175,8 @@ golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
-golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -2441,8 +2456,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
-google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.29.0 h1:44S3JjaKmLEE4YIkjzexaP+NzZsudE3Zin5Njn/pYX0=
+google.golang.org/protobuf v1.29.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 3 - 6
go.work.sum

@@ -5,17 +5,14 @@ github.com/containerd/stargz-snapshotter v0.11.3 h1:D3PoF563XmOBdtfx2G6AkhbHueqw
 github.com/emicklei/go-restful v2.9.5+incompatible h1:spTtZBk5DYEvbxMVutUuTyh1Ao2r4iyvLdACqsl/Ljk=
 github.com/go-redis/redis v6.15.8+incompatible h1:BKZuG6mCnRj5AOaWJXoCgf6rqTYnYJLe4en2hxT7r9o=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-github/v50 v50.0.0 h1:gdO1AeuSZZK4iYWwVbjni7zg8PIQhp7QfmPunr016Jk=
-github.com/google/go-github/v50 v50.0.0/go.mod h1:Ev4Tre8QoKiolvbpOSG3FIi4Mlon3S2Nt9W5JYqKiwA=
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
 github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
-github.com/porter-dev/porter v0.44.0/go.mod h1:GoIoc3h08jxGcgCwsTq+C6dt6jv6mO9OQRdZBrt8iR4=
+github.com/nats-io/nats.go v1.9.1 h1:ik3HbLhZ0YABLto7iX80pZLPw/6dx3T+++MZJwLnMrQ=
+github.com/nats-io/nkeys v0.1.0 h1:qMd4+pRHgdr1nAClu+2h/2a5F2TmKcCzjCDazVgRoX4=
+github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
 github.com/tchap/go-patricia v2.2.6+incompatible h1:JvoDL7JSoIP2HDE8AbDH3zC8QBPxmzYe32HHy5yQ+Ck=
-golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
 golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
 golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

+ 7 - 0
internal/helm/agent.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"runtime/debug"
 	"strconv"
 	"strings"
 	"time"
@@ -405,6 +406,12 @@ func (a *Agent) InstallChart(
 	doAuth *oauth2.Config,
 	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
+	defer func() {
+		if r := recover(); r != nil {
+			fmt.Println("stacktrace from panic: \n" + string(debug.Stack()))
+		}
+	}()
+
 	cmd := action.NewInstall(a.ActionConfig)
 
 	if cmd.Version == "" && cmd.Devel {

+ 99 - 4
internal/kubernetes/config.go

@@ -1,13 +1,18 @@
 package kubernetes
 
 import (
+	"context"
 	"errors"
 	"fmt"
+	"os"
 	"path/filepath"
 	"regexp"
 	"strings"
 	"time"
 
+	"github.com/bufbuild/connect-go"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
@@ -62,19 +67,92 @@ func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
 		return GetAgentInClusterConfig(conf.DefaultNamespace)
 	}
 
-	restConf, err := conf.ToRESTConfig()
+	var restConf *rest.Config
+
+	if conf.Cluster.ProvisionedBy == "CAPI" {
+		rc, err := restConfigForCAPICluster(context.Background(), conf.CAPIManagementClusterClient, *conf.Cluster)
+		if err != nil {
+			return nil, err
+		}
+		restConf = rc
+	} else {
+		rc, err := conf.ToRESTConfig()
+		if err != nil {
+			return nil, err
+		}
+		restConf = rc
+	}
+
+	if restConf == nil {
+		return nil, fmt.Errorf("error getting rest config for cluster %s", conf.Cluster.ProvisionedBy)
+	}
 
+	clientset, err := kubernetes.NewForConfig(restConf)
 	if err != nil {
 		return nil, err
 	}
 
-	clientset, err := kubernetes.NewForConfig(restConf)
+	return &Agent{conf, clientset}, nil
+}
 
+// restConfigForCAPICluster gets the kubernetes rest API client for a CAPI cluster
+func restConfigForCAPICluster(ctx context.Context, mgmtClusterConnection porterv1connect.ClusterControlPlaneServiceClient, cluster models.Cluster) (*rest.Config, error) {
+	kc, err := kubeConfigForCAPICluster(ctx, mgmtClusterConnection, cluster)
 	if err != nil {
 		return nil, err
 	}
 
-	return &Agent{conf, clientset}, nil
+	rc, err := writeKubeConfigToFileAndRestClient([]byte(kc))
+	if err != nil {
+		return nil, err
+	}
+	return rc, nil
+}
+
+// kubeConfigForCAPICluster grabs the raw kube config for a capi cluster
+func kubeConfigForCAPICluster(ctx context.Context, mgmtClusterConnection porterv1connect.ClusterControlPlaneServiceClient, cluster models.Cluster) (string, error) {
+	kubeconfigResp, err := mgmtClusterConnection.KubeConfigForCluster(context.Background(), connect.NewRequest(
+		&porterv1.KubeConfigForClusterRequest{
+			ProjectId: int64(cluster.ProjectID),
+			ClusterId: int64(cluster.ID),
+		},
+	))
+	if err != nil {
+		return "", fmt.Errorf("error getting capi config: %w", err)
+	}
+	if kubeconfigResp.Msg == nil {
+		return "", errors.New("no kubeconfig returned for capi cluster")
+	}
+	if kubeconfigResp.Msg.KubeConfig == "" {
+		return "", errors.New("no kubeconfig returned for capi cluster")
+	}
+	return kubeconfigResp.Msg.KubeConfig, nil
+}
+
+// writeKubeConfigToFileAndRestClient writes a literal kubeconfig to a temporary file
+// then uses the client-go kubernetes package to create a rest.Config from it
+func writeKubeConfigToFileAndRestClient(kubeconf []byte) (*rest.Config, error) {
+	tmpFile, err := os.CreateTemp(os.TempDir(), "kconf-")
+	if err != nil {
+		return nil, fmt.Errorf("unable to create temp file: %w", err)
+	}
+	defer os.Remove(tmpFile.Name())
+
+	if _, err = tmpFile.Write(kubeconf); err != nil {
+		return nil, fmt.Errorf("unable to write to temp file: %w", err)
+	}
+	if err := tmpFile.Close(); err != nil {
+		return nil, fmt.Errorf("unable to close temp file: %w", err)
+	}
+	kconfPath, err := filepath.Abs(tmpFile.Name())
+	if err != nil {
+		return nil, fmt.Errorf("unable to find temp file: %w", err)
+	}
+	rest, err := clientcmd.BuildConfigFromFlags("", kconfPath)
+	if err != nil {
+		return nil, fmt.Errorf("unable create rest config from temp file: %w", err)
+	}
+	return rest, nil
 }
 
 // IsInCluster returns true if the process is running in a Kubernetes cluster,
@@ -118,14 +196,23 @@ type OutOfClusterConfig struct {
 
 	// Only required if using DigitalOcean OAuth as an auth mechanism
 	DigitalOceanOAuth *oauth2.Config
+
+	CAPIManagementClusterClient porterv1connect.ClusterControlPlaneServiceClient
 }
 
 // ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
 // the result of ToRawKubeConfigLoader, and also adds a custom http transport layer
 // if necessary (required for GCP auth)
 func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
-	cmdConf, err := conf.GetClientConfigFromCluster()
+	if conf.Cluster.ProvisionedBy == "CAPI" {
+		rc, err := restConfigForCAPICluster(context.Background(), conf.CAPIManagementClusterClient, *conf.Cluster)
+		if err != nil {
+			return nil, err
+		}
+		return rc, nil
+	}
 
+	cmdConf, err := conf.GetClientConfigFromCluster()
 	if err != nil {
 		return nil, err
 	}
@@ -194,6 +281,14 @@ func (conf *OutOfClusterConfig) GetClientConfigFromCluster() (clientcmd.ClientCo
 		return nil, fmt.Errorf("cluster cannot be nil")
 	}
 
+	if conf.Cluster.ProvisionedBy == "CAPI" {
+		rc, err := kubeConfigForCAPICluster(context.Background(), conf.CAPIManagementClusterClient, *conf.Cluster)
+		if err != nil {
+			return nil, err
+		}
+		return clientcmd.NewClientConfigFromBytes([]byte(rc))
+	}
+
 	if conf.Cluster.AuthMechanism == models.Local {
 		kubeAuth, err := conf.Repo.KubeIntegration().ReadKubeIntegration(
 			conf.Cluster.ProjectID,

+ 34 - 0
internal/models/api_contract_revision.go

@@ -0,0 +1,34 @@
+package models
+
+import (
+	"github.com/google/uuid"
+	"gorm.io/gorm"
+)
+
+// APIContractRevision represents a revision of an API contract
+type APIContractRevision struct {
+	gorm.Model
+
+	// ID is a UUID for the APIContract
+	ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
+
+	// Base64Contract is the APIContract as json encoded in base64
+	Base64Contract string `json:"base64_contract"`
+
+	// ClusterID is the ID of the cluster that the config created.
+	// This should be a foreign key, but GORM doesnt play well with FKs.
+	ClusterID int `json:"cluster_id"`
+
+	// ProjectID is the ID of the project that the config belongs to.
+	// This should be a foreign key, but GORM doesnt play well with FKs.
+	ProjectID int `json:"project_id"`
+
+	// Condition is the status of the apply that happened for this revision.
+	// Condition will contain any failure reasons for a revision, or "SUCCESS" if the revision was applied successfully.
+	Condition string `json:"condition"`
+}
+
+// TableName overrides the table name
+func (APIContractRevision) TableName() string {
+	return "api_contract_revisions"
+}

+ 34 - 0
internal/models/aws_assume_role_chain.go

@@ -0,0 +1,34 @@
+package models
+
+import (
+	"github.com/google/uuid"
+	"gorm.io/gorm"
+)
+
+// AWSAssumeRoleChain represents an assume role chain link.
+// a unique constraint is created on this table by the migration script
+// because gorm creates unique indices, instead of unique constraints, which is utterly useless.
+type AWSAssumeRoleChain struct {
+	gorm.Model
+
+	// ID is a UUID for the CAPI Cluster's config
+	ID uuid.UUID `gorm:"type:uuid;primaryKey"`
+
+	// ProjectID is the ID of the project that the config belongs to.
+	// This should be a foreign key, but GORM doesnt play well with FKs.
+	ProjectID int `json:"project_id"`
+
+	// SourceARN is ARN which will assume the target ARN
+	SourceARN string `json:"source_arn"`
+
+	// TargetARN is ARN which will assume the target ARN
+	TargetARN string `json:"target_arn"`
+
+	// ExternalID is ID which is required when assuming a role
+	ExternalID string `json:"external_id"`
+}
+
+// TableName overrides the table name
+func (AWSAssumeRoleChain) TableName() string {
+	return "aws_assume_role_chains"
+}

+ 32 - 10
internal/models/cluster.go

@@ -42,6 +42,9 @@ type Cluster struct {
 	// Name of the cluster
 	Name string `json:"name"`
 
+	// VanityName allows for a display-only name without changing how the cluster looks
+	VanityName string `json:"vanity_name"`
+
 	// Server endpoint for the cluster
 	Server string `json:"server"`
 
@@ -62,6 +65,20 @@ type Cluster struct {
 
 	AWSClusterID string
 
+	// Status defines the current status of the cluster. Accepted values: [READY, UPDATING]
+	Status types.ClusterStatus `json:"status"`
+
+	// ProvisionedBy is used for identifing the provisioner used for the cluster. Accepted values: [CAPI, ]
+	ProvisionedBy string `json:"provisioned_by"`
+
+	// CloudProvider is the cloud provider that hosts the Kubernetes Cluster. Accepted values: [AWS, GCP, AZURE]
+	CloudProvider string `json:"cloud_provider"`
+
+	// CloudProviderCredentialIdentifier is a reference to find the credentials required for access the cluster's API.
+	// This was likely the credential that was used to create the cluster.
+	// For AWS EKS clusters, this will be an ARN for the final target role in the assume role chain.
+	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
+
 	// ------------------------------------------------------------------
 	// All fields below this line are encrypted before storage
 	// ------------------------------------------------------------------
@@ -100,16 +117,21 @@ func (c *Cluster) ToClusterType() *types.Cluster {
 	}
 
 	return &types.Cluster{
-		ID:                      c.ID,
-		ProjectID:               c.ProjectID,
-		Name:                    c.Name,
-		Server:                  c.Server,
-		Service:                 serv,
-		AgentIntegrationEnabled: c.AgentIntegrationEnabled,
-		InfraID:                 c.InfraID,
-		AWSIntegrationID:        c.AWSIntegrationID,
-		AWSClusterID:            c.AWSClusterID,
-		PreviewEnvsEnabled:      c.PreviewEnvsEnabled,
+		ID:                                c.ID,
+		ProjectID:                         c.ProjectID,
+		Name:                              c.Name,
+		VanityName:                        c.VanityName,
+		Server:                            c.Server,
+		Service:                           serv,
+		AgentIntegrationEnabled:           c.AgentIntegrationEnabled,
+		InfraID:                           c.InfraID,
+		AWSIntegrationID:                  c.AWSIntegrationID,
+		AWSClusterID:                      c.AWSClusterID,
+		PreviewEnvsEnabled:                c.PreviewEnvsEnabled,
+		Status:                            c.Status,
+		ProvisionedBy:                     c.ProvisionedBy,
+		CloudProvider:                     c.CloudProvider,
+		CloudProviderCredentialIdentifier: c.CloudProviderCredentialIdentifier,
 	}
 }
 

+ 15 - 13
internal/models/project.go

@@ -58,11 +58,12 @@ type Project struct {
 	AzureIntegrations  []ints.AzureIntegration  `json:"azure_integrations"`
 	GitlabIntegrations []ints.GitlabIntegration `json:"gitlab_integrations"`
 
-	PreviewEnvsEnabled  bool
-	RDSDatabasesEnabled bool
-	ManagedInfraEnabled bool
-	StacksEnabled       bool
-	APITokensEnabled    bool
+	PreviewEnvsEnabled     bool
+	RDSDatabasesEnabled    bool
+	ManagedInfraEnabled    bool
+	StacksEnabled          bool
+	APITokensEnabled       bool
+	CapiProvisionerEnabled bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -74,13 +75,14 @@ func (p *Project) ToProjectType() *types.Project {
 	}
 
 	return &types.Project{
-		ID:                  p.ID,
-		Name:                p.Name,
-		Roles:               roles,
-		PreviewEnvsEnabled:  p.PreviewEnvsEnabled,
-		RDSDatabasesEnabled: p.RDSDatabasesEnabled,
-		ManagedInfraEnabled: p.ManagedInfraEnabled,
-		StacksEnabled:       p.StacksEnabled,
-		APITokensEnabled:    p.APITokensEnabled,
+		ID:                     p.ID,
+		Name:                   p.Name,
+		Roles:                  roles,
+		PreviewEnvsEnabled:     p.PreviewEnvsEnabled,
+		RDSDatabasesEnabled:    p.RDSDatabasesEnabled,
+		ManagedInfraEnabled:    p.ManagedInfraEnabled,
+		StacksEnabled:          p.StacksEnabled,
+		APITokensEnabled:       p.APITokensEnabled,
+		CapiProvisionerEnabled: p.CapiProvisionerEnabled,
 	}
 }

+ 56 - 0
internal/nats/nats.go

@@ -0,0 +1,56 @@
+package nats
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/nats-io/nats.go"
+)
+
+// NATS holds a connection to a NATS cluster
+type NATS struct {
+	NatsConnection *nats.Conn
+	JetStream      nats.JetStreamContext
+}
+
+// Config contains the config required to setup a connection to a NATS cluster
+type Config struct {
+	URL string
+}
+
+// NewConnection creates a new nats and JetStream connection
+func NewConnection(ctx context.Context, conf Config) (NATS, error) {
+	var n NATS
+
+	url := conf.URL
+	if url == "" {
+		url = nats.DefaultURL
+	}
+	nc, err := nats.Connect(conf.URL)
+	if err != nil {
+		return n, err
+	}
+	if nc == nil {
+		return n, errors.New("nats connection was not obtained")
+	}
+	if len(nc.Servers()) == 0 {
+		return n, errors.New("nats connection was not obtained, no servers added")
+	}
+	n.NatsConnection = nc
+
+	js, err := nc.JetStream()
+	if err != nil {
+		return n, fmt.Errorf("jetstream connection was not obtained - %w", err)
+	}
+	ai, err := js.AccountInfo()
+	if err != nil {
+		return n, fmt.Errorf("jetstream connection was not obtained, no account info returned - %w", err)
+	}
+	if ai == nil {
+		return n, fmt.Errorf("unable to get jetsteam")
+	}
+	n.JetStream = js
+
+	return n, nil
+}

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