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

Merge branch 'master' of github.com:porter-dev/porter into stacks-notifications-tab

Feroze Mohideen 2 лет назад
Родитель
Сommit
4658ba9f34
69 измененных файлов с 2145 добавлено и 824 удалено
  1. 9 30
      .github/PULL_REQUEST_TEMPLATE.md
  2. 1 0
      .gitignore
  3. 0 29
      api/client/api.go
  4. 24 0
      api/client/porter_app.go
  5. 34 10
      api/server/handlers/porter_app/delete.go
  6. 123 0
      api/server/handlers/porter_app/predeploy_status.go
  7. 51 0
      api/server/handlers/project/rename.go
  8. 69 0
      api/server/handlers/project_integration/preflight_check.go
  9. 29 0
      api/server/router/porter_app.go
  10. 28 1
      api/server/router/project.go
  11. 28 0
      api/server/router/project_integration.go
  12. 5 0
      api/types/project.go
  13. 1 0
      api/types/request.go
  14. 66 0
      cli/cmd/commands/all.go
  15. 6 6
      cli/cmd/commands/app.go
  16. 2 2
      cli/cmd/commands/apply.go
  17. 17 13
      cli/cmd/commands/auth.go
  18. 6 6
      cli/cmd/commands/cluster.go
  19. 6 6
      cli/cmd/commands/config.go
  20. 16 16
      cli/cmd/commands/connect.go
  21. 5 10
      cli/cmd/commands/create.go
  22. 16 31
      cli/cmd/commands/delete.go
  23. 2 2
      cli/cmd/commands/deploy_bluegreen.go
  24. 2 2
      cli/cmd/commands/docker.go
  25. 19 2
      cli/cmd/commands/errors.go
  26. 8 18
      cli/cmd/commands/get.go
  27. 2 2
      cli/cmd/commands/helm.go
  28. 14 30
      cli/cmd/commands/job.go
  29. 2 2
      cli/cmd/commands/kubectl.go
  30. 17 32
      cli/cmd/commands/list.go
  31. 2 2
      cli/cmd/commands/logs.go
  32. 6 6
      cli/cmd/commands/project.go
  33. 8 8
      cli/cmd/commands/registry.go
  34. 4 4
      cli/cmd/commands/run.go
  35. 8 18
      cli/cmd/commands/stack.go
  36. 20 35
      cli/cmd/commands/update.go
  37. 12 31
      cli/cmd/config/config.go
  38. 70 3
      cli/cmd/v2/apply.go
  39. 7 7
      dashboard/package-lock.json
  40. 1 1
      dashboard/package.json
  41. 141 77
      dashboard/src/components/GCPCredentialsForm.tsx
  42. 247 62
      dashboard/src/components/GCPProvisionerSettings.tsx
  43. 139 0
      dashboard/src/components/PreflightChecks.tsx
  44. 56 56
      dashboard/src/components/ProvisionerSettings.tsx
  45. 26 0
      dashboard/src/components/RadioFilter.tsx
  46. 1 0
      dashboard/src/components/porter/Text.tsx
  47. 12 2
      dashboard/src/lib/hooks/useAppAnalytics.ts
  48. 8 0
      dashboard/src/lib/hooks/usePorterYaml.ts
  49. 11 1
      dashboard/src/lib/porter-apps/services.ts
  50. 175 25
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  51. 69 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx
  52. 41 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx
  53. 20 14
      dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx
  54. 140 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx
  55. 8 5
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  56. 53 47
      dashboard/src/main/home/app-dashboard/create-app/RepoSettings.tsx
  57. 3 3
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  58. 4 3
      dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx
  59. 9 6
      dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx
  60. 6 6
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Networking.tsx
  61. 7 5
      dashboard/src/main/home/project-settings/InviteList.tsx
  62. 149 91
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  63. 2 2
      dashboard/src/main/home/sidebar/ProjectButton.tsx
  64. 6 1
      dashboard/src/main/home/sidebar/ProjectSelectionModal.tsx
  65. 55 50
      dashboard/src/shared/api.tsx
  66. 6 0
      dashboard/src/shared/util.ts
  67. 1 1
      go.mod
  68. 2 2
      go.sum
  69. 2 0
      internal/porter_app/v2/yaml.go

+ 9 - 30
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,33 +1,12 @@
-## Pull request type
+## POR-
+<!-- Enter your issue ID in the title above or type "N/A" if there isn't one -->
+## What does this PR do?
 
-<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed. -->
-
-Please check the type of change your PR introduces:
-
-- [ ] Bugfix
-- [ ] Feature
-- [ ] Other (please describe):
-
-## Pull request checklist
-
-Please check if your PR fulfills the following requirements:
-
-- [ ] If it's a backend change, tests for the changes have been added and `go test ./...` runs successfully from the root folder.
-- [ ] If it's a frontend change, Prettier has been run
-- [ ] Docs have been reviewed and added / updated if needed
-
-## What is the current behavior?
-
-<!-- Please describe the current behavior that you are modifying, or link to a relevant issue.
-
-Issue Number: N/A
+<!--
+This is where you should write the PR description. What are we reviewing?
+Be concise, summarize with bullet points if possible.
 
+- Add screenshots for frontend changes.
+- Outline complex testing steps for posterity.
+- Note if this PR depends on other PRs or specific actions.
 -->
-
-## What is the new behavior?
-
-<!-- Please describe the behavior or changes that are being added by this PR. -->
-
-<!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. -->
-
-## Technical Spec/Implementation Notes

+ 1 - 0
.gitignore

@@ -16,6 +16,7 @@ staging.sh
 bin
 openapi.yaml
 .idea
+portercli
 
 
 vendor

+ 0 - 29
api/client/api.go

@@ -10,13 +10,11 @@ import (
 	"net/http"
 	"net/url"
 	"os"
-	"path/filepath"
 	"strings"
 	"time"
 
 	"github.com/gorilla/schema"
 	"github.com/porter-dev/porter/api/types"
-	"k8s.io/client-go/util/homedir"
 )
 
 // Client represents the client for the Porter API
@@ -82,33 +80,6 @@ func NewClientWithConfig(ctx context.Context, input NewClientInput) (Client, err
 // ErrNoAuthCredential returns an error when no auth credentials have been provided such as cookies or tokens
 var ErrNoAuthCredential = errors.New("unable to create an API session with cookie nor token")
 
-// NewClient constructs a new client based on a set of options
-func NewClient(baseURL string, cookieFileName string) *Client {
-	home := homedir.HomeDir()
-	cookieFilePath := filepath.Join(home, ".porter", cookieFileName)
-
-	client := &Client{
-		BaseURL:        baseURL,
-		CookieFilePath: cookieFilePath,
-		HTTPClient: &http.Client{
-			Timeout: time.Minute,
-		},
-	}
-
-	cookie, _ := client.getCookie()
-
-	if cookie != nil {
-		client.Cookie = cookie
-	}
-
-	// look for a cloudflare access token specifically for Porter
-	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
-		client.cfToken = cfToken
-	}
-
-	return client
-}
-
 func (c *Client) getRequest(relPath string, data interface{}, response interface{}) error {
 	vals := make(map[string][]string)
 	err := schema.NewEncoder().Encode(data, vals)

+ 24 - 0
api/client/porter_app.go

@@ -357,3 +357,27 @@ func (c *Client) CreateSubdomain(
 
 	return resp, err
 }
+
+// PredeployStatus checks the current status of a predeploy job for an app revision
+func (c *Client) PredeployStatus(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	appName string, appRevisionId string,
+) (*porter_app.PredeployStatusResponse, error) {
+	resp := &porter_app.PredeployStatusResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/%s/predeploy-status",
+			projectID, clusterID, appName, appRevisionId,
+		),
+		nil,
+		resp,
+	)
+
+	if resp.Status == "" {
+		return nil, fmt.Errorf("no predeploy status found")
+	}
+
+	return resp, err
+}

+ 34 - 10
api/server/handlers/porter_app/delete.go

@@ -3,6 +3,8 @@ package porter_app
 import (
 	"net/http"
 
+	"connectrpc.com/connect"
+	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"
@@ -11,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type DeletePorterAppByNameHandler struct {
@@ -30,26 +33,47 @@ func NewDeletePorterAppByNameHandler(
 }
 
 func (c *DeletePorterAppByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "server-delete-porter-app-by-name")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		err := telemetry.Error(ctx, span, reqErr, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
-	porterApp, appErr := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
-	if appErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(appErr))
+	if appName == "" {
+		err := telemetry.Error(ctx, span, nil, "porter app name cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
-	delApp, delErr := c.Repo().PorterApp().DeletePorterApp(porterApp)
-	if delErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(delErr))
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	deleteReq := connect.NewRequest[porterv1.DeletePorterAppRequest](&porterv1.DeletePorterAppRequest{
+		ProjectId: int64(project.ID),
+		AppName:   appName,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.DeletePorterApp(r.Context(), deleteReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error deleting porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	c.WriteResult(w, r, delApp)
+	c.WriteResult(w, r, ccpResp.Msg)
 }

+ 123 - 0
api/server/handlers/porter_app/predeploy_status.go

@@ -0,0 +1,123 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// PredeployStatusHandler handles requests to the /apps/{porter_app_name}/{app_revision_id}/predeploy-status endpoint
+type PredeployStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewPredeployStatusHandler returns a new PredeployStatusHandler
+func NewPredeployStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PredeployStatusHandler {
+	return &PredeployStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// PredeployStatus is the status of the predeploy
+type PredeployStatus string
+
+const (
+	// PredeployStatus_InProgress signifies the predeploy is still running
+	PredeployStatus_InProgress PredeployStatus = "in-progress"
+	// PredeployStatus_Failed signifies the predeploy has failed
+	PredeployStatus_Failed PredeployStatus = "failed"
+	// PredeployStatus_Successful signifies the predeploy was successful
+	PredeployStatus_Successful PredeployStatus = "successful"
+)
+
+// PredeployStatusResponse is the response object for the /apps/{porter_app_name}/{app_revision_id}/predeploy-status endpoint
+type PredeployStatusResponse struct {
+	// Status is the status of the predeploy
+	Status PredeployStatus `json:"status"`
+}
+
+// ServeHTTP forwards the predeploy status request to the cluster control plane and returns the response
+func (c *PredeployStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-predeploy-status")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	appRevisionId, _ := requestutils.GetURLParamString(r, types.URLParamAppRevisionID)
+
+	if appRevisionId == "" {
+		err := telemetry.Error(ctx, span, nil, "app revision id is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+		telemetry.AttributeKV{Key: "app-name", Value: name},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionId},
+	)
+
+	predeployStatusReq := connect.NewRequest(&porterv1.PredeployStatusRequest{
+		ProjectId:     int64(project.ID),
+		AppRevisionId: appRevisionId,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.PredeployStatus(ctx, predeployStatusReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "predeploy-status", Value: ccpResp.Msg.PredeployStatus})
+
+	var status PredeployStatus
+	switch ccpResp.Msg.PredeployStatus {
+	case porterv1.EnumPredeployStatus_ENUM_PREDEPLOY_STATUS_IN_PROGRESS:
+		status = PredeployStatus_InProgress
+	case porterv1.EnumPredeployStatus_ENUM_PREDEPLOY_STATUS_FAILED:
+		status = PredeployStatus_Failed
+	case porterv1.EnumPredeployStatus_ENUM_PREDEPLOY_STATUS_SUCCESSFUL:
+		status = PredeployStatus_Successful
+	default:
+		err := telemetry.Error(ctx, span, nil, "ccp resp predeploy status is invalid")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := &PredeployStatusResponse{
+		Status: status,
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 51 - 0
api/server/handlers/project/rename.go

@@ -0,0 +1,51 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// RenameProjectHandler Renames a project
+type RenameProjectHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewRenameProjectHandler renames the project with the given name
+func NewRenameProjectHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RenameProjectHandler {
+	return &RenameProjectHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RenameProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	request := &types.UpdateProjectNameRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Name != "" && proj.Name != request.Name {
+		proj.Name = request.Name
+	}
+
+	project, err := c.Repo().Project().UpdateProject(proj)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, project.ToProjectType())
+}

+ 69 - 0
api/server/handlers/project_integration/preflight_check.go

@@ -0,0 +1,69 @@
+package project_integration
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+
+	"connectrpc.com/connect"
+	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/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CreatePreflightCheckHandler Create Preflight Checks
+type CreatePreflightCheckHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCreatePreflightCheckHandler Create Preflight Checks
+func NewCreatePreflightCheckHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreatePreflightCheckHandler {
+	return &CreatePreflightCheckHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "preflight-checks")
+	defer span.End()
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	cloudValues := &porterv1.PreflightCheckRequest{}
+	err := helpers.UnmarshalContractObjectFromReader(r.Body, cloudValues)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "error unmarshalling preflight check data")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
+		return
+	}
+
+	input := porterv1.PreflightCheckRequest{
+		ProjectId:                  int64(project.ID),
+		CloudProvider:              cloudValues.CloudProvider,
+		CloudProviderCredentialsId: cloudValues.CloudProviderCredentialsId,
+	}
+
+	if cloudValues.PreflightValues != nil {
+		if cloudValues.CloudProvider == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP {
+			input.PreflightValues = cloudValues.PreflightValues
+		}
+	}
+
+	checkResp, err := p.Config().ClusterControlPlaneClient.PreflightCheck(ctx, connect.NewRequest(&input))
+	if err != nil {
+		e := fmt.Errorf("Pre-provision check failed: %w", err)
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
+		return
+	}
+
+	p.WriteResult(w, r, checkResp)
+}

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

@@ -803,5 +803,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/{app_revision_id}/predeploy-status -> porter_app.NewPredeployStatusHandler
+	predeployStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/{%s}/predeploy-status", types.URLParamPorterAppName, types.URLParamAppRevisionID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	predeployStatusHandler := porter_app.NewPredeployStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: predeployStatusEndpoint,
+		Handler:  predeployStatusHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 28 - 1
api/server/router/project.go

@@ -1395,12 +1395,39 @@ func getProjectRoutes(
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
-
 	routes = append(routes, &router.Route{
 		Endpoint: deleteAPIContractRevisionsEndpoint,
 		Handler:  deleteAPIContractRevisionHandler,
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/rename -> cluster.newRenamProject
+	renameProjectEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/rename",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	renameProjectHandler := project.NewRenameProjectHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: renameProjectEndpoint,
+		Handler:  renameProjectHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

@@ -248,6 +248,34 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/integrations/preflightcheck -> project_integration.NewCreatePreflightCheckHandler
+	preflightCheckEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/preflightcheck",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	preflightCheckHandler := project_integration.NewCreatePreflightCheckHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: preflightCheckEndpoint,
+		Handler:  preflightCheckHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/integrations/azure -> project_integration.NewListAzureHandler
 	listAzureEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 5 - 0
api/types/project.go

@@ -144,3 +144,8 @@ type UpdateOnboardingStepRequest struct {
 	// ExternalId used as a 'password' for the aws assume role chain to porter-manager role
 	ExternalId string `json:"external_id"`
 }
+
+// UpdateProjectNameRequest takes in a name to rename projects
+type UpdateProjectNameRequest struct {
+	Name string `json:"name" form:"required"`
+}

+ 1 - 0
api/types/request.go

@@ -52,6 +52,7 @@ const (
 	URLParamStackEventID          URLParam = "stack_event_id"
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
+	URLParamAppRevisionID         URLParam = "app_revision_id"
 )
 
 type Path struct {

+ 66 - 0
cli/cmd/commands/all.go

@@ -50,3 +50,69 @@ func RegisterCommands() (*cobra.Command, error) {
 	rootCmd.AddCommand(registerCommand_Version(cliConf))
 	return rootCmd, nil
 }
+
+// overrideConfigWithFlags grabs the runtime value of registered flags, and overrides the values in CLIConfig.
+// It was done this way to reduce the size of a refactor, as the codebase conflates initialisation of the commands, with the runtime values.
+func overrideConfigWithFlags(cmd *cobra.Command, config config.CLIConfig) config.CLIConfig {
+	type flag struct {
+		// stringName is the name of the flag which is a string
+		stringName string
+		// stringConfigTarget is the pointer to the string in the config struct
+		stringConfigTarget *string
+
+		// uintName is the name of the flag which is a uint
+		uintName string
+		// uintConfigTarget is the pointer to the uint in the config struct
+		uintConfigTarget *uint
+	}
+
+	flagsToOverride := []flag{
+		{
+			stringName:         "driver",
+			stringConfigTarget: &config.Driver,
+		},
+		{
+			stringName:         "host",
+			stringConfigTarget: &config.Host,
+		},
+		{
+			stringName:         "token",
+			stringConfigTarget: &config.Token,
+		},
+		{
+			stringName:         "kubeconfig",
+			stringConfigTarget: &config.Kubeconfig,
+		},
+		{
+			uintName:         "project",
+			uintConfigTarget: &config.Project,
+		},
+		{
+			uintName:         "cluster",
+			uintConfigTarget: &config.Cluster,
+		},
+		{
+			uintName:         "registry",
+			uintConfigTarget: &config.Registry,
+		},
+		{
+			uintName:         "helm_repo",
+			uintConfigTarget: &config.HelmRepo,
+		},
+	}
+	for _, fl := range flagsToOverride {
+		if fl.stringName != "" {
+			st, _ := cmd.Flags().GetString(fl.stringName)
+			if st != "" {
+				*fl.stringConfigTarget = st
+			}
+		}
+		if fl.uintName != "" {
+			ui, _ := cmd.Flags().GetUint(fl.uintName)
+			if ui != 0 {
+				*fl.uintConfigTarget = ui
+			}
+		}
+	}
+	return config
+}

+ 6 - 6
cli/cmd/commands/app.go

@@ -55,7 +55,7 @@ func registerCommand_App(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.MinimumNArgs(2),
 		Short: "Runs a command inside a connected cluster container.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, appRun)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, appRun)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -70,7 +70,7 @@ func registerCommand_App(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.NoArgs,
 		Short: "Delete any lingering ephemeral pods that were created with \"porter app run\".",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, appCleanup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, appCleanup)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -84,7 +84,7 @@ func registerCommand_App(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.MinimumNArgs(1),
 		Short: "Updates the image tag for an application.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, appUpdateTag)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, appUpdateTag)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -152,7 +152,7 @@ func appRunFlags(appRunCmd *cobra.Command) {
 	)
 }
 
-func appRun(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, args []string) error {
+func appRun(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, args []string) error {
 	execArgs := args[1:]
 
 	color.New(color.FgGreen).Println("Attempting to run", strings.Join(execArgs, " "), "for application", args[0])
@@ -267,7 +267,7 @@ func appRun(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client a
 	return appExecuteRunEphemeral(ctx, config, appNamespace, selectedPod.Name, selectedContainerName, execArgs)
 }
 
-func appCleanup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ []string) error {
+func appCleanup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	config := &AppPorterRunSharedConfig{
 		Client:    client,
 		CLIConfig: cliConfig,
@@ -1044,7 +1044,7 @@ func appCreateEphemeralPodFromExisting(
 	)
 }
 
-func appUpdateTag(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, args []string) error {
+func appUpdateTag(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, args []string) error {
 	namespace := fmt.Sprintf("porter-stack-%s", args[0])
 	if appTag == "" {
 		appTag = "latest"

+ 2 - 2
cli/cmd/commands/apply.go

@@ -73,7 +73,7 @@ applying a configuration:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter apply -f porter.yaml"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, apply)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, apply)
 			if err != nil {
 				if strings.Contains(err.Error(), "Forbidden") {
 					_, _ = color.New(color.FgRed).Fprintf(os.Stderr, "You may have to update your GitHub secret token")
@@ -108,7 +108,7 @@ applying a configuration:
 	return applyCmd
 }
 
-func apply(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ []string) (err error) {
+func apply(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, _ []string) (err error) {
 	project, err := client.GetProject(ctx, cliConfig.Project)
 	if err != nil {
 		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")

+ 17 - 13
cli/cmd/commands/auth.go

@@ -29,9 +29,14 @@ func registerCommand_Auth(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "login",
 		Short: "Authorizes a user for a given Porter server",
 		Run: func(cmd *cobra.Command, args []string) {
+			cliConf = overrideConfigWithFlags(cmd, cliConf)
+
 			err := login(cmd.Context(), cliConf)
 			if err != nil {
 				color.Red("Error logging in: %s\n", err.Error())
+				if strings.Contains(err.Error(), "Forbidden") {
+					_ = cliConf.SetToken("")
+				}
 				os.Exit(1)
 			}
 		},
@@ -41,6 +46,8 @@ func registerCommand_Auth(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "register",
 		Short: "Creates a user for a given Porter server",
 		Run: func(cmd *cobra.Command, args []string) {
+			cliConf = overrideConfigWithFlags(cmd, cliConf)
+
 			err := register(cmd.Context(), cliConf)
 			if err != nil {
 				color.Red("Error registering: %s\n", err.Error())
@@ -53,8 +60,9 @@ func registerCommand_Auth(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "logout",
 		Short: "Logs a user out of a given Porter server",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, logout)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, logout)
 			if err != nil {
+				_ = cliConf.SetToken("")
 				os.Exit(1)
 			}
 		},
@@ -88,9 +96,8 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 	user, err := client.AuthCheck(ctx)
 	if err != nil {
 		if !strings.Contains(err.Error(), "Forbidden") {
-			return fmt.Errorf("unexpected error performing authorization check")
+			return fmt.Errorf("unexpected error performing authorization check: %w", err)
 		}
-		fmt.Println(err)
 	}
 
 	if cliConf.Token == "" {
@@ -107,7 +114,6 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 
 		// set the token in config
 		err = cliConf.SetToken(token)
-
 		if err != nil {
 			return err
 		}
@@ -121,14 +127,12 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 		}
 
 		user, err = client.AuthCheck(ctx)
-
 		if err != nil {
 			color.Red("Invalid token.")
 			return err
 		}
 
 		_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
-
 		return setProjectForUser(ctx, client, cliConf, user.ID)
 
 	}
@@ -137,7 +141,6 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 	if err != nil {
 		return err
 	}
-	_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
 
 	projID, exists, err := api.GetProjectIDFromToken(cliConf.Token)
 	if err != nil {
@@ -167,6 +170,8 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 			return err
 		}
 	}
+	_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
+
 	return nil
 }
 
@@ -183,7 +188,6 @@ func setProjectForUser(ctx context.Context, client api.Client, config config.CLI
 		config.SetProject(ctx, client, projects[0].ID) //nolint:errcheck,gosec // do not want to change logic of CLI. New linter error
 
 		err = setProjectCluster(ctx, client, config, projects[0].ID)
-
 		if err != nil {
 			return err
 		}
@@ -193,6 +197,7 @@ func setProjectForUser(ctx context.Context, client api.Client, config config.CLI
 }
 
 func loginManual(ctx context.Context, cliConf config.CLIConfig, client api.Client) error {
+	client.CookieFilePath = "cookie.json" // required as this uses cookies for auth instead of a token
 	var username, pw string
 
 	fmt.Println("Please log in with an email and password:")
@@ -203,16 +208,13 @@ func loginManual(ctx context.Context, cliConf config.CLIConfig, client api.Clien
 	}
 
 	pw, err = utils.PromptPassword("Password: ")
-
 	if err != nil {
 		return err
 	}
-
 	_, err = client.Login(ctx, &types.LoginUserRequest{
 		Email:    username,
 		Password: pw,
 	})
-
 	if err != nil {
 		return err
 	}
@@ -279,10 +281,12 @@ func register(ctx context.Context, cliConf config.CLIConfig) error {
 	return nil
 }
 
-func logout(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func logout(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	err := client.Logout(ctx)
 	if err != nil {
-		return err
+		if !strings.Contains(err.Error(), "You are not logged in.") {
+			return err
+		}
 	}
 
 	cliConf.SetToken("")

+ 6 - 6
cli/cmd/commands/cluster.go

@@ -27,7 +27,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Short: "Lists the linked clusters in the current project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listClusters)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listClusters)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -40,7 +40,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Deletes the cluster with the given id",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteCluster)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteCluster)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -59,7 +59,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Short: "Lists the namespaces in a cluster",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listNamespaces)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listNamespaces)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -70,7 +70,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 	return clusterCmd
 }
 
-func listClusters(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listClusters(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	resp, err := client.ListProjectClusters(ctx, cliConf.Project)
 	if err != nil {
 		return err
@@ -98,7 +98,7 @@ func listClusters(ctx context.Context, user *types.GetAuthenticatedUserResponse,
 	return nil
 }
 
-func deleteCluster(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func deleteCluster(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 			`Are you sure you'd like to delete the cluster with id %s? %s `,
@@ -128,7 +128,7 @@ func deleteCluster(ctx context.Context, user *types.GetAuthenticatedUserResponse
 	return nil
 }
 
-func listNamespaces(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listNamespaces(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	pID := cliConf.Project
 
 	// get the service account based on the cluster id

+ 6 - 6
cli/cmd/commands/config.go

@@ -46,7 +46,7 @@ func registerCommand_Config(cliConf config.CLIConfig) *cobra.Command {
 			}
 
 			if len(args) == 0 {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAndSetProject)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAndSetProject)
 				if err != nil {
 					os.Exit(1)
 				}
@@ -72,7 +72,7 @@ func registerCommand_Config(cliConf config.CLIConfig) *cobra.Command {
 		Short: "Saves the cluster id in the default configuration",
 		Run: func(cmd *cobra.Command, args []string) {
 			if len(args) == 0 {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAndSetCluster)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAndSetCluster)
 				if err != nil {
 					os.Exit(1)
 				}
@@ -99,7 +99,7 @@ func registerCommand_Config(cliConf config.CLIConfig) *cobra.Command {
 		Short: "Saves the registry id in the default configuration",
 		Run: func(cmd *cobra.Command, args []string) {
 			if len(args) == 0 {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAndSetRegistry)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAndSetRegistry)
 				if err != nil {
 					os.Exit(1)
 				}
@@ -186,7 +186,7 @@ func printConfig() error {
 	return nil
 }
 
-func listAndSetProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listAndSetProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
 	_ = s.Color("cyan")
 	s.Suffix = " Loading list of projects"
@@ -230,7 +230,7 @@ func listAndSetProject(ctx context.Context, _ *types.GetAuthenticatedUserRespons
 	return nil
 }
 
-func listAndSetCluster(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listAndSetCluster(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
 	_ = s.Color("cyan")
 	s.Suffix = " Loading list of clusters"
@@ -273,7 +273,7 @@ func listAndSetCluster(ctx context.Context, _ *types.GetAuthenticatedUserRespons
 	return nil
 }
 
-func listAndSetRegistry(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listAndSetRegistry(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
 	_ = s.Color("cyan")
 	s.Suffix = " Loading list of registries"

+ 16 - 16
cli/cmd/commands/connect.go

@@ -27,7 +27,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "kubeconfig",
 		Short: "Uses the local kubeconfig to add a cluster",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectKubeconfig)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectKubeconfig)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -38,7 +38,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "ecr",
 		Short: "Adds an ECR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectECR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectECR)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -49,7 +49,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "dockerhub",
 		Short: "Adds a Docker Hub registry integration to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectDockerhub)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectDockerhub)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -60,7 +60,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "registry",
 		Short: "Adds a custom image registry to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectRegistry)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectRegistry)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -71,7 +71,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "helm",
 		Short: "Adds a custom Helm registry to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectHelmRepo)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectHelmRepo)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -82,7 +82,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "gcr",
 		Short: "Adds a GCR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectGCR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectGCR)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -93,7 +93,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "gar",
 		Short: "Adds a GAR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectGAR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectGAR)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -105,7 +105,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "docr",
 		Short: "Adds a DOCR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectDOCR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectDOCR)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -138,7 +138,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 	return connectCmd
 }
 
-func runConnectKubeconfig(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectKubeconfig(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	isLocal := false
 
 	if cliConf.Driver == "local" {
@@ -160,7 +160,7 @@ func runConnectKubeconfig(ctx context.Context, _ *types.GetAuthenticatedUserResp
 	return cliConf.SetCluster(id)
 }
 
-func runConnectECR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectECR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	regID, err := connect.ECR(
 		ctx,
 		client,
@@ -173,7 +173,7 @@ func runConnectECR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetRegistry(regID)
 }
 
-func runConnectGCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectGCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	regID, err := connect.GCR(
 		ctx,
 		client,
@@ -186,7 +186,7 @@ func runConnectGCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetRegistry(regID)
 }
 
-func runConnectGAR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectGAR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	regID, err := connect.GAR(
 		ctx,
 		client,
@@ -199,7 +199,7 @@ func runConnectGAR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetRegistry(regID)
 }
 
-func runConnectDOCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectDOCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	regID, err := connect.DOCR(
 		ctx,
 		client,
@@ -212,7 +212,7 @@ func runConnectDOCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse,
 	return cliConf.SetRegistry(regID)
 }
 
-func runConnectDockerhub(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectDockerhub(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	regID, err := connect.Dockerhub(
 		ctx,
 		client,
@@ -225,7 +225,7 @@ func runConnectDockerhub(ctx context.Context, _ *types.GetAuthenticatedUserRespo
 	return cliConf.SetRegistry(regID)
 }
 
-func runConnectRegistry(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectRegistry(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	regID, err := connect.Registry(
 		ctx,
 		client,
@@ -238,7 +238,7 @@ func runConnectRegistry(ctx context.Context, _ *types.GetAuthenticatedUserRespon
 	return cliConf.SetRegistry(regID)
 }
 
-func runConnectHelmRepo(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ []string) error {
+func runConnectHelmRepo(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	hrID, err := connect.HelmRepo(
 		ctx,
 		client,

+ 5 - 10
cli/cmd/commands/create.go

@@ -78,7 +78,7 @@ To deploy an application from a Docker registry, use "--source registry" and pas
 			color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --source registry --image gcr.io/snowflake-12345/example-app:latest"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, createFull)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, createFull)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -180,14 +180,9 @@ To deploy an application from a Docker registry, use "--source registry" and pas
 
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
 
-func createFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.CreateFull(ctx)
+func createFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.CreateFull(ctx)
 		if err != nil {
 			return err
 		}
@@ -279,7 +274,7 @@ func createFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 				return err
 			}
 
-			err = config.SetDockerConfig(ctx, createAgent.Client, project.ID)
+			err = config.SetDockerConfig(ctx, createAgent.Client, cliConf.Project)
 
 			if err != nil {
 				return err

+ 16 - 31
cli/cmd/commands/delete.go

@@ -35,7 +35,7 @@ deleting a configuration:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter delete"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteDeployment)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteDeployment)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -49,7 +49,7 @@ deleting a configuration:
 		Short:   "Deletes an existing app",
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteApp)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteApp)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -63,7 +63,7 @@ deleting a configuration:
 		Short:   "Deletes an existing job",
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteJob)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteJob)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -77,7 +77,7 @@ deleting a configuration:
 		Short:   "Deletes an existing addon",
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteAddon)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteAddon)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -91,7 +91,7 @@ deleting a configuration:
 		Short:   "Deletes an existing helm repo",
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteHelm)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteHelm)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -113,14 +113,9 @@ deleting a configuration:
 	return deleteCmd
 }
 
-func deleteDeployment(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.DeleteDeployment(ctx)
+func deleteDeployment(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.DeleteDeployment(ctx)
 		if err != nil {
 			return err
 		}
@@ -157,14 +152,9 @@ func deleteDeployment(ctx context.Context, _ *types.GetAuthenticatedUserResponse
 	)
 }
 
-func deleteApp(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.DeleteApp(ctx)
+func deleteApp(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.DeleteApp(ctx)
 		if err != nil {
 			return err
 		}
@@ -199,14 +189,9 @@ func deleteApp(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clien
 	return nil
 }
 
-func deleteJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.DeleteJob(ctx)
+func deleteJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.DeleteJob(ctx)
 		if err != nil {
 			return err
 		}
@@ -241,7 +226,7 @@ func deleteJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clien
 	return nil
 }
 
-func deleteAddon(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func deleteAddon(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	name := args[0]
 
 	resp, err := client.GetRelease(
@@ -270,7 +255,7 @@ func deleteAddon(ctx context.Context, _ *types.GetAuthenticatedUserResponse, cli
 	return nil
 }
 
-func deleteHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func deleteHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	name := args[0]
 
 	resp, err := client.ListHelmRepos(ctx, cliConf.Project)

+ 2 - 2
cli/cmd/commands/deploy_bluegreen.go

@@ -28,7 +28,7 @@ func registerCommand_Deploy(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "blue-green-switch",
 		Short: "Automatically switches the traffic of a blue-green deployment once the new application is ready.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, bluegreenSwitch)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, bluegreenSwitch)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -61,7 +61,7 @@ func registerCommand_Deploy(cliConf config.CLIConfig) *cobra.Command {
 	return deployCmd
 }
 
-func bluegreenSwitch(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, args []string) error {
+func bluegreenSwitch(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, args []string) error {
 	project, err := client.GetProject(ctx, cliConfig.Project)
 	if err != nil {
 		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")

+ 2 - 2
cli/cmd/commands/docker.go

@@ -20,7 +20,7 @@ func registerCommand_Docker(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "configure",
 		Short: "Configures the host's Docker instance",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, dockerConfig)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, dockerConfig)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -31,6 +31,6 @@ func registerCommand_Docker(cliConf config.CLIConfig) *cobra.Command {
 	return dockerCmd
 }
 
-func dockerConfig(ctx context.Context, user *ptypes.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func dockerConfig(ctx context.Context, user *ptypes.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	return config.SetDockerConfig(ctx, client, cliConf.Project)
 }

+ 19 - 2
cli/cmd/commands/errors.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	cliErrors "github.com/porter-dev/porter/cli/cmd/errors"
+	"github.com/spf13/cobra"
 )
 
 var (
@@ -19,7 +20,12 @@ var (
 	ErrCannotConnect error = errors.New("Unable to connect to the Porter server.")
 )
 
-func checkLoginAndRunWithConfig(ctx context.Context, cliConf config.CLIConfig, args []string, runner func(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error) error {
+type authenticatedRunnerFunc func(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error
+
+func checkLoginAndRunWithConfig(cmd *cobra.Command, cliConf config.CLIConfig, args []string, runner authenticatedRunnerFunc) error {
+	ctx := cmd.Context()
+	cliConf = overrideConfigWithFlags(cmd, cliConf)
+
 	client, err := api.NewClientWithConfig(ctx, api.NewClientInput{
 		BaseURL:        fmt.Sprintf("%s/api", cliConf.Host),
 		BearerToken:    cliConf.Token,
@@ -47,8 +53,19 @@ func checkLoginAndRunWithConfig(ctx context.Context, cliConf config.CLIConfig, a
 		return err
 	}
 
-	err = runner(ctx, user, client, cliConf, args)
+	project, err := client.GetProject(ctx, cliConf.Project)
+	if err != nil {
+		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run: %w", err)
+	}
+	if project == nil {
+		return fmt.Errorf("project [%d] not found", cliConf.Project)
+	}
+
+	flags := config.FeatureFlags{
+		ValidateApplyV2Enabled: project.ValidateApplyV2,
+	}
 
+	err = runner(ctx, user, client, cliConf, flags, args)
 	if err != nil {
 		red := color.New(color.FgRed)
 

+ 8 - 18
cli/cmd/commands/get.go

@@ -24,7 +24,7 @@ func registerCommand_Get(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Fetches a release.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, get)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, get)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -37,7 +37,7 @@ func registerCommand_Get(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Fetches the Helm values for a release.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, getValues)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, getValues)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -71,14 +71,9 @@ type getReleaseInfo struct {
 	RevisionID   int       `json:"revision_id" yaml:"revision_id"`
 }
 
-func get(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.Get(ctx)
+func get(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.Get(ctx)
 		if err != nil {
 			return err
 		}
@@ -123,14 +118,9 @@ func get(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.
 	return nil
 }
 
-func getValues(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.GetValues(ctx)
+func getValues(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.GetValues(ctx)
 		if err != nil {
 			return err
 		}

+ 2 - 2
cli/cmd/commands/helm.go

@@ -17,7 +17,7 @@ func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "helm",
 		Short: "Use helm to interact with a Porter cluster",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runHelm)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runHelm)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -27,7 +27,7 @@ func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
 	return helmCmd
 }
 
-func runHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func runHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	_, err := exec.LookPath("helm")
 	if err != nil {
 		return fmt.Errorf("error finding helm: %w", err)

+ 14 - 30
cli/cmd/commands/job.go

@@ -47,7 +47,7 @@ use the --namespace flag:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter job update-images --namespace custom-namespace --image-repo-uri my-image.registry.io --tag newtag"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, batchImageUpdate)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, batchImageUpdate)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -77,7 +77,7 @@ use the --namespace flag:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter job wait --name job-example --namespace custom-namespace"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, waitForJob)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, waitForJob)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -107,7 +107,7 @@ use the --namespace flag:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter job run --name job-example --namespace custom-namespace"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runJob)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runJob)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -177,14 +177,9 @@ use the --namespace flag:
 	return jobCmd
 }
 
-func batchImageUpdate(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.BatchImageUpdate(ctx)
+func batchImageUpdate(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.BatchImageUpdate(ctx)
 		if err != nil {
 			return err
 		}
@@ -206,14 +201,9 @@ func batchImageUpdate(ctx context.Context, _ *types.GetAuthenticatedUserResponse
 }
 
 // waits for a job with a given name/namespace
-func waitForJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.WaitForJob(ctx)
+func waitForJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.WaitForJob(ctx)
 		if err != nil {
 			return err
 		}
@@ -228,14 +218,9 @@ func waitForJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 	})
 }
 
-func runJob(ctx context.Context, authRes *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.RunJob(ctx)
+func runJob(ctx context.Context, authRes *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.RunJob(ctx)
 		if err != nil {
 			return err
 		}
@@ -258,7 +243,7 @@ func runJob(ctx context.Context, authRes *types.GetAuthenticatedUserResponse, cl
 		},
 	}
 
-	err = updateAgent.UpdateImageAndValues(
+	err := updateAgent.UpdateImageAndValues(
 		ctx,
 		map[string]interface{}{
 			"paused": false,
@@ -267,8 +252,7 @@ func runJob(ctx context.Context, authRes *types.GetAuthenticatedUserResponse, cl
 		return fmt.Errorf("error running job: %w", err)
 	}
 
-	err = waitForJob(ctx, authRes, client, cliConf, args)
-
+	err = waitForJob(ctx, authRes, client, cliConf, featureFlags, args)
 	if err != nil {
 		return fmt.Errorf("error waiting for job to complete: %w", err)
 	}

+ 2 - 2
cli/cmd/commands/kubectl.go

@@ -17,7 +17,7 @@ func registerCommand_Kubectl(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "kubectl",
 		Short: "Use kubectl to interact with a Porter cluster",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runKubectl)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runKubectl)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -26,7 +26,7 @@ func registerCommand_Kubectl(cliConf config.CLIConfig) *cobra.Command {
 	return kubectlCmd
 }
 
-func runKubectl(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func runKubectl(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	_, err := exec.LookPath("kubectl")
 	if err != nil {
 		return fmt.Errorf("error finding kubectl: %w", err)

+ 17 - 32
cli/cmd/commands/list.go

@@ -24,7 +24,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Short: "List applications, addons or jobs.",
 		Run: func(cmd *cobra.Command, args []string) {
 			if len(args) == 0 || (args[0] == "all") {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAll)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAll)
 				if err != nil {
 					os.Exit(1)
 				}
@@ -39,7 +39,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Aliases: []string{"applications", "app", "application"},
 		Short:   "Lists applications in a specific namespace, or across all namespaces",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listApps)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listApps)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -51,7 +51,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Aliases: []string{"job"},
 		Short:   "Lists jobs in a specific namespace, or across all namespaces",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listJobs)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listJobs)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -63,7 +63,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Aliases: []string{"addon"},
 		Short:   "Lists addons in a specific namespace, or across all namespaces",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAddons)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAddons)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -91,21 +91,16 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 	return listCmd
 }
 
-func listAll(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.ListAll(ctx)
+func listAll(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.ListAll(ctx)
 		if err != nil {
 			return err
 		}
 		return nil
 	}
 
-	err = writeReleases(ctx, client, cliConf, "all")
+	err := writeReleases(ctx, client, cliConf, "all")
 	if err != nil {
 		return err
 	}
@@ -113,21 +108,16 @@ func listAll(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	return nil
 }
 
-func listApps(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.ListApps(ctx)
+func listApps(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.ListApps(ctx)
 		if err != nil {
 			return err
 		}
 		return nil
 	}
 
-	err = writeReleases(ctx, client, cliConf, "application")
+	err := writeReleases(ctx, client, cliConf, "application")
 	if err != nil {
 		return err
 	}
@@ -135,21 +125,16 @@ func listApps(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	return nil
 }
 
-func listJobs(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.ListJobs(ctx)
+func listJobs(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.ListJobs(ctx)
 		if err != nil {
 			return err
 		}
 		return nil
 	}
 
-	err = writeReleases(ctx, client, cliConf, "job")
+	err := writeReleases(ctx, client, cliConf, "job")
 	if err != nil {
 		return err
 	}
@@ -157,7 +142,7 @@ func listJobs(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	return nil
 }
 
-func listAddons(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listAddons(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	err := writeReleases(ctx, client, cliConf, "addon")
 	if err != nil {
 		return err

+ 2 - 2
cli/cmd/commands/logs.go

@@ -20,7 +20,7 @@ func registerCommand_Logs(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Logs the output from a given application.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, logs)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, logs)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -44,7 +44,7 @@ func registerCommand_Logs(cliConf config.CLIConfig) *cobra.Command {
 	return logsCmd
 }
 
-func logs(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, args []string) error {
+func logs(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, args []string) error {
 	podsSimple, err := getPods(ctx, client, cliConfig, namespace, args[0])
 	if err != nil {
 		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())

+ 6 - 6
cli/cmd/commands/project.go

@@ -28,7 +28,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Creates a project with the authorized user as admin",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, createProject)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, createProject)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -41,7 +41,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Deletes the project with the given id",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteProject)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteProject)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -53,7 +53,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Short: "Lists the projects for the logged in user",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listProjects)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listProjects)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -64,7 +64,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 	return projectCmd
 }
 
-func createProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func createProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	resp, err := client.CreateProject(ctx, &types.CreateProjectRequest{
 		Name: args[0],
 	})
@@ -77,7 +77,7 @@ func createProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetProject(ctx, client, resp.ID)
 }
 
-func listProjects(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listProjects(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	resp, err := client.ListUserProjects(ctx)
 	if err != nil {
 		return err
@@ -105,7 +105,7 @@ func listProjects(ctx context.Context, user *types.GetAuthenticatedUserResponse,
 	return nil
 }
 
-func deleteProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, args []string) error {
+func deleteProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, args []string) error {
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 			`Are you sure you'd like to delete the project with id %s? %s `,

+ 8 - 8
cli/cmd/commands/registry.go

@@ -27,7 +27,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Short: "Lists the registries linked to a project",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listRegistries)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listRegistries)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -39,7 +39,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Deletes the registry with the given id",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteRegistry)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteRegistry)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -56,7 +56,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Short: "Lists the repositories in an image registry",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listRepos)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listRepos)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -74,7 +74,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Lists the images the specified image repository",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listImages)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listImages)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -95,7 +95,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 	return registryCmd
 }
 
-func listRegistries(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listRegistries(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	pID := cliConf.Project
 
 	// get the list of namespaces
@@ -129,7 +129,7 @@ func listRegistries(ctx context.Context, user *types.GetAuthenticatedUserRespons
 	return nil
 }
 
-func deleteRegistry(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func deleteRegistry(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 			`Are you sure you'd like to delete the registry with id %s? %s `,
@@ -159,7 +159,7 @@ func deleteRegistry(ctx context.Context, user *types.GetAuthenticatedUserRespons
 	return nil
 }
 
-func listRepos(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listRepos(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	pID := cliConf.Project
 	rID := cliConf.Registry
 
@@ -189,7 +189,7 @@ func listRepos(ctx context.Context, user *types.GetAuthenticatedUserResponse, cl
 	return nil
 }
 
-func listImages(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func listImages(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	pID := cliConf.Project
 	rID := cliConf.Registry
 	repoName := args[0]

+ 4 - 4
cli/cmd/commands/run.go

@@ -48,7 +48,7 @@ func registerCommand_Run(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.MinimumNArgs(2),
 		Short: "Runs a command inside a connected cluster container.",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, run)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, run)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -61,7 +61,7 @@ func registerCommand_Run(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.NoArgs,
 		Short: "Delete any lingering ephemeral pods that were created with \"porter run\".",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, cleanup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, cleanup)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -126,7 +126,7 @@ func registerCommand_Run(cliConf config.CLIConfig) *cobra.Command {
 	return runCmd
 }
 
-func run(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func run(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	execArgs := args[1:]
 
 	color.New(color.FgGreen).Println("Running", strings.Join(execArgs, " "), "for release", args[0])
@@ -242,7 +242,7 @@ func run(ctx context.Context, user *types.GetAuthenticatedUserResponse, client a
 	return executeRunEphemeral(ctx, config, namespace, selectedPod.Name, selectedContainerName, execArgs)
 }
 
-func cleanup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ []string) error {
+func cleanup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConfig config.CLIConfig, _ config.FeatureFlags, _ []string) error {
 	config := &PorterRunSharedConfig{
 		Client:    client,
 		CLIConfig: cliConfig,

+ 8 - 18
cli/cmd/commands/stack.go

@@ -37,7 +37,7 @@ func registerCommand_Stack(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Add an env group to a stack",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, stackAddEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, stackAddEnvGroup)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -49,7 +49,7 @@ func registerCommand_Stack(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Short: "Remove an existing env group from a stack",
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, stackRemoveEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, stackRemoveEnvGroup)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -101,14 +101,9 @@ func registerCommand_Stack(cliConf config.CLIConfig) *cobra.Command {
 	return stackCmd
 }
 
-func stackAddEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.StackAddEnvGroup(ctx)
+func stackAddEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.StackAddEnvGroup(ctx)
 		if err != nil {
 			return err
 		}
@@ -184,14 +179,9 @@ func stackAddEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse
 	return nil
 }
 
-func stackRemoveEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.StackRemoveEnvGroup(ctx)
+func stackRemoveEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.StackRemoveEnvGroup(ctx)
 		if err != nil {
 			return err
 		}

+ 20 - 35
cli/cmd/commands/update.go

@@ -92,7 +92,7 @@ specify it as follows:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --method docker --dockerfile ./docker/prod.Dockerfile"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateFull)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateFull)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -120,7 +120,7 @@ destination path for a .env file. For example:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update get-env --app example-app --file .env"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateGetEnv)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateGetEnv)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -162,7 +162,7 @@ for the application:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app --method docker --dockerfile ./prod.Dockerfile"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateBuild)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateBuild)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -202,7 +202,7 @@ linked it via "porter connect".
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updatePush)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updatePush)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -232,7 +232,7 @@ the image that the application uses if no --values file is specified:
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update config --app example-app --tag custom-tag"),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateUpgrade)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateUpgrade)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -253,7 +253,7 @@ the image that the application uses if no --values file is specified:
 		Short: "Sets the desired value of an environment variable in an env group in the form VAR=VALUE.",
 		Args:  cobra.MaximumNArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateSetEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateSetEnvGroup)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -265,7 +265,7 @@ the image that the application uses if no --values file is specified:
 		Short: "Removes an environment variable from an env group.",
 		Args:  cobra.MinimumNArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateUnsetEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateUnsetEnvGroup)
 			if err != nil {
 				os.Exit(1)
 			}
@@ -443,14 +443,9 @@ the image that the application uses if no --values file is specified:
 	return updateCmd
 }
 
-func updateFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.UpdateFull(ctx)
+func updateFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.UpdateFull(ctx)
 		if err != nil {
 			return err
 		}
@@ -508,7 +503,7 @@ func updateFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 	return nil
 }
 
-func updateGetEnv(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func updateGetEnv(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	updateAgent, err := updateGetAgent(ctx, client, cliConf)
 	if err != nil {
 		return err
@@ -531,14 +526,9 @@ func updateGetEnv(ctx context.Context, _ *types.GetAuthenticatedUserResponse, cl
 	return updateAgent.WriteBuildEnv(getEnvFileDest)
 }
 
-func updateBuild(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.UpdateBuild(ctx)
+func updateBuild(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.UpdateBuild(ctx)
 		if err != nil {
 			return err
 		}
@@ -553,7 +543,7 @@ func updateBuild(ctx context.Context, _ *types.GetAuthenticatedUserResponse, cli
 	return updateBuildWithAgent(ctx, updateAgent)
 }
 
-func updatePush(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func updatePush(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	if app == "" {
 		if len(args) == 0 {
 			return fmt.Errorf("please provide the docker image name")
@@ -612,14 +602,9 @@ func updatePush(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 	return updatePushWithAgent(ctx, updateAgent)
 }
 
-func updateUpgrade(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
-	project, err := client.GetProject(ctx, cliConf.Project)
-	if err != nil {
-		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
-	}
-
-	if project.ValidateApplyV2 {
-		err = v2.UpdateUpgrade(ctx)
+func updateUpgrade(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
+	if featureFlags.ValidateApplyV2Enabled {
+		err := v2.UpdateUpgrade(ctx)
 		if err != nil {
 			return err
 		}
@@ -650,7 +635,7 @@ func updateUpgrade(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return nil
 }
 
-func updateSetEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func updateSetEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	if len(normalEnvGroupVars) == 0 && len(secretEnvGroupVars) == 0 && len(args) == 0 {
 		return fmt.Errorf("please provide one or more variables to update")
 	}
@@ -758,7 +743,7 @@ func validateVarValue(in string) (string, string, error) {
 	return key, value, nil
 }
 
-func updateUnsetEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, args []string) error {
+func updateUnsetEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, args []string) error {
 	if len(args) == 0 {
 		return fmt.Errorf("required variable name")
 	}

+ 12 - 31
cli/cmd/config/config.go

@@ -36,16 +36,19 @@ type CLIConfig struct {
 	Kubeconfig string `yaml:"kubeconfig"`
 }
 
+// FeatureFlags are any flags that are relevant to the feature set of the CLI. This should not include all feature flags, only those relevant to client-side CLI operations
+type FeatureFlags struct {
+	// ValidateApplyV2Enabled is a project-wide flag for checking if `porter apply` with porter.yaml is enabled
+	ValidateApplyV2Enabled bool
+}
+
 // InitAndLoadConfig populates the config object with the following precedence rules:
 // 1. flag
 // 2. env
 // 3. config
 // 4. default
+// Make sure to call overrideConfigWithFlags during runtime, to ensure that the flag values are considered
 func InitAndLoadConfig() (CLIConfig, error) {
-	return initAndLoadConfig()
-}
-
-func initAndLoadConfig() (CLIConfig, error) {
 	var config CLIConfig
 
 	porterDir, err := getOrCreatePorterDirectoryAndConfig()
@@ -56,6 +59,11 @@ func initAndLoadConfig() (CLIConfig, error) {
 	viper.SetConfigType("yaml")
 	viper.AddConfigPath(porterDir)
 
+	err = createAndLoadPorterYaml(porterDir)
+	if err != nil {
+		return config, fmt.Errorf("unable to load porter config: %w", err)
+	}
+
 	utils.DriverFlagSet.StringVar(
 		&config.Driver,
 		"driver",
@@ -141,11 +149,6 @@ func initAndLoadConfig() (CLIConfig, error) {
 		return config, err
 	}
 
-	err = createAndLoadPorterYaml(porterDir)
-	if err != nil {
-		return config, fmt.Errorf("unable to load porter config: %w", err)
-	}
-
 	err = viper.Unmarshal(&config)
 	if err != nil {
 		return config, fmt.Errorf("unable to unmarshal porter config: %w", err)
@@ -188,28 +191,6 @@ func createAndLoadPorterYaml(porterDir string) error {
 	return nil
 }
 
-// func GetCLIConfig() *CLIConfig {
-// 	if config == nil {
-// 		panic("GetCLIConfig() called before initialisation")
-// 	}
-
-// 	return config
-// }
-
-// func GetAPIClient() api.Client {
-// 	ctx := ctx
-
-// 	config := GetCLIConfig()
-
-// 	client := api.NewClientWithConfig(ctx, api.NewClientInput{
-// 		BaseURL:        fmt.Sprintf("%s/api", config.Host),
-// 		BearerToken:    config.Token,
-// 		CookieFileName: "cookie.json",
-// 	})
-
-// 	return client
-// }
-
 func (c *CLIConfig) SetDriver(driver string) error {
 	viper.Set("driver", driver)
 	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)

+ 70 - 3
cli/cmd/v2/apply.go

@@ -8,6 +8,9 @@ import (
 	"os"
 	"path/filepath"
 	"strconv"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 
 	"github.com/cli/cli/git"
 
@@ -40,6 +43,13 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return errors.New("b64 app proto is empty")
 	}
 
+	appName, err := appNameFromB64AppProto(parseResp.B64AppProto)
+	if err != nil {
+		return fmt.Errorf("error getting app name from b64 app proto: %w", err)
+	}
+
+	color.New(color.FgGreen).Printf("Successfully parsed Porter YAML: applying app \"%s\"\n", appName) // nolint:errcheck,gosec
+
 	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
 	if err != nil {
 		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
@@ -52,6 +62,8 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	var commitSHA string
 	if os.Getenv("PORTER_COMMIT_SHA") != "" {
 		commitSHA = os.Getenv("PORTER_COMMIT_SHA")
+	} else if os.Getenv("GITHUB_SHA") != "" {
+		commitSHA = os.Getenv("GITHUB_SHA")
 	} else if commit, err := git.LastCommit(); err == nil && commit != nil {
 		commitSHA = commit.Sha
 	}
@@ -91,6 +103,8 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	}
 
 	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
+		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
+
 		if commitSHA == "" {
 			return errors.New("Build is required but commit SHA cannot be identified. Please set the PORTER_COMMIT_SHA environment variable or run apply in git repository with access to the git CLI.")
 		}
@@ -100,7 +114,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("error building settings from base64 app proto: %w", err)
 		}
 
-		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, buildSettings.AppName, targetResp.DeploymentTargetID)
+		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, targetResp.DeploymentTargetID)
 		if err != nil {
 			return fmt.Errorf("error getting current app revision: %w", err)
 		}
@@ -127,9 +141,38 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("error building app: %w", err)
 		}
 
+		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.ImageTag) // nolint:errcheck,gosec
+
+		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
+		if err != nil {
+			return fmt.Errorf("apply error post-build: %w", err)
+		}
+	}
+
+	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_TRACK_PREDEPLOY {
+		color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
+
+		now := time.Now().UTC()
+		for {
+			if time.Since(now) > checkPredeployTimeout {
+				return errors.New("timed out waiting for predeploy to complete")
+			}
+
+			predeployStatusResp, err := client.PredeployStatus(ctx, cliConf.Project, cliConf.Cluster, appName, applyResp.AppRevisionId)
+			if err != nil {
+				return fmt.Errorf("error calling predeploy status endpoint: %w", err)
+			}
+
+			if predeployStatusResp.Status == porter_app.PredeployStatus_Failed || predeployStatusResp.Status == porter_app.PredeployStatus_Successful {
+				break
+			}
+
+			time.Sleep(checkPredeployFrequency)
+		}
+
 		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
 		if err != nil {
-			return fmt.Errorf("error calling apply endpoint after build: %w", err)
+			return fmt.Errorf("apply error post-predeploy: %w", err)
 		}
 	}
 
@@ -137,10 +180,34 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
 	}
 
-	color.New(color.FgGreen).Printf("Successfully applied Porter YAML as revision %v, next action: %v\n", applyResp.AppRevisionId, applyResp.CLIAction) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", applyResp.AppRevisionId, appName) // nolint:errcheck,gosec
 	return nil
 }
 
+// checkPredeployTimeout is the maximum amount of time the CLI will wait for a predeploy to complete before calling apply again
+const checkPredeployTimeout = 60 * time.Minute
+
+// checkPredeployFrequency is the frequency at which the CLI will check the status of a predeploy
+const checkPredeployFrequency = 10 * time.Second
+
+func appNameFromB64AppProto(base64AppProto string) (string, error) {
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return "", fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return "", fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	if app.Name == "" {
+		return "", fmt.Errorf("app does not contain name")
+	}
+	return app.Name, nil
+}
+
 func createPorterAppDbEntryInputFromProtoAndEnv(base64AppProto string) (api.CreatePorterAppDBEntryInput, error) {
 	var input api.CreatePorterAppDBEntryInput
 

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,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.93",
+        "@porter-dev/api-contracts": "^0.0.95",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.93",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.93.tgz",
-      "integrity": "sha512-BPJKvCNUXsVGw2rp3SC04fp6lYRTliEdxxORs/SxbxkQOysZxs21K/lAHmti7LIlqyFStil+g+gKyTCQSyWagg==",
+      "version": "0.0.95",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
+      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.93",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.93.tgz",
-      "integrity": "sha512-BPJKvCNUXsVGw2rp3SC04fp6lYRTliEdxxORs/SxbxkQOysZxs21K/lAHmti7LIlqyFStil+g+gKyTCQSyWagg==",
+      "version": "0.0.95",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
+      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,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.93",
+    "@porter-dev/api-contracts": "^0.0.95",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 141 - 77
dashboard/src/components/GCPCredentialsForm.tsx

@@ -12,6 +12,9 @@ import Text from "components/porter/Text";
 import Button from "components/porter/Button";
 import Spacer from "./porter/Spacer";
 import Container from "./porter/Container";
+import PreflightChecks from "./PreflightChecks";
+import { EnumCloudProvider, GKENetwork, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
+
 
 
 type Props = {
@@ -27,11 +30,19 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
   const [isLoading, setIsLoading] = useState(false);
   const [errorMessage, setErrorMessage] = useState("");
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
+  const [gcpCloudProviderCredentialID, setGCPCloudProviderCredentialId] = useState<string>("")
+  const [preFlightData, setPreflightData] = useState(null)
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(true)
 
   useEffect(() => {
     setDetected(undefined);
   }, []);
 
+  useEffect(() => {
+
+    gcpIntegration()
+
+  }, [detected])
   interface FailureState {
     condition: boolean;
     errorMessage: string;
@@ -48,7 +59,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
     message: string;
   };
 
-  const saveCredentials = async () => {
+  const gcpIntegration = async () => {
     failureStates.forEach((failureState) => {
       if (failureState.condition) {
         setErrorMessage(failureState.errorMessage);
@@ -70,16 +81,47 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
         setErrorMessage("Unable to store cluster credentials. Please try again later. If the problem persists, contact support@porter.run")
         return;
       }
-      const gcpCloudProviderCredentialID = gcpIntegrationResponse.data.cloud_provider_credentials_id;
-      proceed(gcpCloudProviderCredentialID)
+      setGCPCloudProviderCredentialId(gcpIntegrationResponse.data.cloud_provider_credentials_id)
+      setIsLoading(false)
+
+      if (gcpIntegrationResponse?.data?.cloud_provider_credentials_id) {
+        setIsLoading(true);
+        var data = new PreflightCheckRequest({
+          projectId: BigInt(currentProject.id),
+          cloudProvider: EnumCloudProvider.GCP,
+          cloudProviderCredentialsId: gcpIntegrationResponse.data.cloud_provider_credentials_id
+
+        })
+        const preflightDataResp = await api.preflightCheck(
+          "<token>", data,
+          {
+            id: currentProject.id,
+          }
+        )
+        setPreflightData(preflightDataResp?.data?.Msg);
+        setIsLoading(false)
+
+      }
+    }
+    catch (err) {
+      setIsLoading(false)
 
-    } catch (err) {
       if (err.response?.data?.error) {
         setErrorMessage(err.response?.data?.error.replace("unknown: ", ""));
       } else {
         setErrorMessage("Something went wrong, please try again later.");
       }
-    };
+    }
+
+  }
+
+
+  const saveCredentials = async () => {
+
+    if (gcpCloudProviderCredentialID) {
+      proceed(gcpCloudProviderCredentialID)
+    }
+
   }
 
   const handleLoadJSON = (serviceAccountJSONFile: string) => {
@@ -104,13 +146,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
     setIsContinueEnabled(true);
   }
 
-  if (isLoading) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
+
 
   return (
     <>
@@ -133,23 +169,45 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
         isRequired={true}
       />
 
-      {detected && serviceAccountKey && (
-        <AppearingDiv color={projectId ? "#8590ff" : "#fcba03"}>
-          {detected.detected ? (
-            <I className="material-icons">check</I>
-          ) : (
-            <I className="material-icons">error</I>
-          )}
-          <Text color={detected.detected ? "#8590ff" : "#fcba03"}>
-            {detected.message}
-          </Text>
-        </AppearingDiv>
+      {detected && serviceAccountKey && (<>
+
+
+        <>
+          <AppearingDiv color={projectId ? "#8590ff" : "#fcba03"}>
+            {detected.detected ? (
+              <>
+                <I className="material-icons">check</I>
+              </>
+            ) : (
+              <I className="material-icons">error</I>
+            )}
+
+            <Text color={detected.detected ? "#8590ff" : "#fcba03"}>
+              {detected.message}
+            </Text>
+          </AppearingDiv>
+          <Spacer y={1} />
+          {isLoading ?
+            <>
+              <Placeholder>
+                <Loading />
+              </Placeholder>
+
+            </>
+            :
+
+            preFlightData ?
+              (<PreflightChecks preflightData={preFlightData} setPreflightFailed={setPreflightFailed} />)
+              : (<Text>  Could not perform preflight checks on your account. Please verify your credentials are correct or contact Porter Support at support@porter.run</Text>)
+
+          }
+        </>
+      </>
       )}
 
       <Spacer y={0.5} />
-
       <Button
-        disabled={!isContinueEnabled}
+        disabled={!isContinueEnabled || preflightFailed || isLoading}
         onClick={saveCredentials}
       >Continue</Button>
 
@@ -161,72 +219,78 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
 export default GCPCredentialsForm;
 
 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;
+      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;
+        color: white;
+      font-size: 16px;
+      margin-right: 6px;
+      margin-left: -2px;
   }
-`;
+      `;
 
 const HelperButton = styled.div`
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  margin-left: 10px;
-  justify-content: center;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      margin-left: 10px;
+      justify-content: center;
   > i {
-    color: #aaaabb;
-    width: 24px;
-    height: 24px;
-    font-size: 20px;
-    border-radius: 20px;
+        color: #aaaabb;
+      width: 24px;
+      height: 24px;
+      font-size: 20px;
+      border-radius: 20px;
   }
-`;
+      `;
 
 const Img = styled.img`
-  height: 18px;
-  margin-right: 15px;
-`;
+      height: 18px;
+      margin-right: 15px;
+      `;
 
 const AppearingDiv = styled.div<{ color?: string }>`
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-  display: flex;
-  align-items: center;
-  color: ${(props) => props.color || "#ffffff44"};
-  margin-left: 10px;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
+        animation: floatIn 0.5s;
+        animation-fill-mode: forwards;
+        display: flex;
+        align-items: center;
+        color: ${(props) => props.color || "#ffffff44"};
+        margin-left: 10px;
+        @keyframes floatIn {
+          from {
+          opacity: 0;
+        transform: translateY(20px);
     }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
+        to {
+          opacity: 1;
+        transform: translateY(0px);
     }
   }
-`;
+        `;
 
 const I = styled.i`
-  font-size: 18px;
-  margin-right: 5px;
-`;
+        font-size: 18px;
+        margin-right: 5px;
+        `;
+
+const StatusIcon = styled.img`
+        top: 20px;
+        right: 20px;
+        height: 18px;
+        `;

+ 247 - 62
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -19,7 +19,9 @@ import {
   GKE,
   GKENetwork,
   GKENodePool,
-  GKENodePoolType
+  GKENodePoolType,
+  GKEPreflightValues,
+  PreflightCheckRequest
 } from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import Button from "./porter/Button";
@@ -28,6 +30,13 @@ import Spacer from "./porter/Spacer";
 import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Text from "./porter/Text";
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import Loading from "components/Loading";
+import Placeholder from "./Placeholder";
+import Fieldset from "./porter/Fieldset";
+import ExpandableSection from "./porter/ExpandableSection";
+import PreflightChecks from "./PreflightChecks";
 
 const locationOptions = [
   { value: "us-east1", label: "us-east1" },
@@ -71,6 +80,11 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
+  const [detected, setDetected] = useState<Detected | undefined>(undefined);
+  const [preflightData, setPreflightData] = useState({})
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(false)
+  const [isLoading, setIsLoading] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
 
   const markStepStarted = async (step: string) => {
     try {
@@ -128,6 +142,54 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
     return "";
   }
+  const renderAdvancedSettings = () => {
+    return (
+      <>
+        {
+          < Heading >
+            <ExpandHeader
+              onClick={() => setIsExpanded(!isExpanded)}
+              isExpanded={isExpanded}
+            >
+              <i className="material-icons">arrow_drop_down</i>
+              Advanced settings
+            </ExpandHeader>
+          </Heading >
+        }
+        {
+          isExpanded && (
+            <>
+              <InputRow
+                width="350px"
+                type="string"
+                disabled={isReadOnly}
+                value={clusterNetworking.cidrRange}
+                setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
+                label="VPC CIDR range"
+                placeholder="ex: 10.78.0.0/16"
+              />
+              <Spacer y={0.25} />
+              <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+            </>
+          )
+        }
+      </>
+    );
+  };
+
+  const statusPreflight = (): string => {
+
+
+    if (!clusterNetworking.cidrRange) {
+      return "VPC CIDR range is required";
+    }
+    if (!VALID_CIDR_RANGE_PATTERN.test(clusterNetworking.cidrRange)) {
+      return "VPC CIDR range must be in the format of [0-255].[0-255].0.0/16";
+    }
+
+    return "";
+  }
+
   const createCluster = async () => {
     const err = validateInputs();
     if (err !== "") {
@@ -182,56 +244,63 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
       }),
     });
 
-    if (props.clusterId) {
-      data["cluster"]["clusterId"] = props.clusterId;
-    }
+    if (preflightData) {
+      if (props.clusterId) {
+        data["cluster"]["clusterId"] = props.clusterId;
+      }
 
-    try {
-      setIsReadOnly(true);
-      setErrorMessage("");
-      setErrorDetails("")
+      try {
+        setIsReadOnly(true);
+        setErrorMessage("");
+        setErrorDetails("")
 
-      if (!props.clusterId) {
-        markStepStarted("provisioning-started");
-      }
+        if (!props.clusterId) {
+          markStepStarted("provisioning-started");
+        }
 
-      const res = await api.createContract("<token>", data, {
-        project_id: currentProject.id,
-      });
+        const res = await api.createContract("<token>", data, {
+          project_id: currentProject.id,
+        });
 
-      setErrorMessage("");
-      setErrorDetails("");
-
-      // Only refresh and set clusters on initial create
-      setShouldRefreshClusters(true);
-      api
-        .getClusters("<token>", {}, { id: currentProject.id })
-        .then(({ data }) => {
-          data.forEach((cluster: ClusterType) => {
-            if (cluster.id === res.data.contract_revision?.cluster_id) {
-              // setHasFinishedOnboarding(true);
-              setCurrentCluster(cluster);
-              OFState.actions.goTo("clean_up");
-              pushFiltered(props, "/cluster-dashboard", ["project_id"], {
-                cluster: cluster.name,
-              });
-            }
+        setErrorMessage("");
+        setErrorDetails("");
+
+        // Only refresh and set clusters on initial create
+        setShouldRefreshClusters(true);
+        api
+          .getClusters("<token>", {}, { id: currentProject.id })
+          .then(({ data }) => {
+            data.forEach((cluster: ClusterType) => {
+              if (cluster.id === res.data.contract_revision?.cluster_id) {
+                // setHasFinishedOnboarding(true);
+                setCurrentCluster(cluster);
+                OFState.actions.goTo("clean_up");
+                pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+                  cluster: cluster.name,
+                });
+              }
+            });
+          })
+          .catch((err) => {
+            setErrorMessage("Error fetching clusters");
+            setErrorDetails(err)
           });
-        })
-        .catch((err) => {
-          setErrorMessage("Error fetching clusters");
-          setErrorDetails(err)
-        });
 
-    } catch (err) {
-      const errMessage = err.response.data.error.replace("unknown: ", "");
+      } catch (err) {
+        const errMessage = err.response.data.error.replace("unknown: ", "");
+        setIsClicked(false);
+        // TODO: handle different error conditions here from preflights
+        setErrorMessage(DEFAULT_ERROR_MESSAGE);
+        setErrorDetails(errMessage)
+      } finally {
+        setIsReadOnly(false);
+        setIsClicked(false);
+      }
+    } else {
       setIsClicked(false);
       // TODO: handle different error conditions here from preflights
       setErrorMessage(DEFAULT_ERROR_MESSAGE);
-      setErrorDetails(errMessage)
-    } finally {
-      setIsReadOnly(false);
-      setIsClicked(false);
+      setErrorDetails("Could not perform Preflight Checks ")
     }
   };
 
@@ -273,6 +342,44 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
     }
   }, [props.selectedClusterVersion]);
 
+  useEffect(() => {
+    if (statusPreflight() == "" && !props.clusterId) {
+      preflightChecks()
+    }
+
+  }, [props.selectedClusterVersion, clusterNetworking]);
+
+  const preflightChecks = async () => {
+    setIsLoading(true);
+
+
+    var data = new PreflightCheckRequest({
+      projectId: BigInt(currentProject.id),
+      cloudProvider: EnumCloudProvider.GCP,
+      cloudProviderCredentialsId: props.credentialId,
+      preflightValues: {
+        case: "gkePreflightValues",
+        value: new GKEPreflightValues({
+          network: new GKENetwork({
+            cidrRange: clusterNetworking.cidrRange || defaultClusterNetworking.cidrRange,
+            controlPlaneCidr: defaultClusterNetworking.controlPlaneCidr,
+            podCidr: defaultClusterNetworking.podCidr,
+            serviceCidr: defaultClusterNetworking.serviceCidr,
+          })
+        })
+      }
+    });
+    const preflightDataResp = await api.preflightCheck(
+      "<token>", data,
+      {
+        id: currentProject.id,
+      }
+    )
+    setPreflightData(preflightDataResp?.data?.Msg);
+    setIsLoading(false)
+
+  }
+
   const renderForm = () => {
     // Render simplified form if initial create
     if (!props.clusterId) {
@@ -295,17 +402,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
             setActiveValue={setRegion}
             label="📍 GCP location"
           />
-          <InputRow
-            width="350px"
-            type="string"
-            disabled={isReadOnly}
-            value={clusterNetworking.cidrRange}
-            setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
-            label="VPC CIDR range"
-            placeholder="ex: 10.78.0.0/16"
-          />
-          <Spacer y={0.25} />
-          <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+          {renderAdvancedSettings()}
+
         </>
       );
     }
@@ -331,8 +429,32 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   return (
     <>
       <StyledForm>{renderForm()}</StyledForm>
+
+      {props.credentialId && (<>
+
+        {isLoading ?
+          <>
+            <Placeholder>
+              <Loading />
+            </Placeholder>
+            <Spacer y={1} />
+          </>
+          :
+          <>
+            {(!props.clusterId) &&
+              <>
+                <PreflightChecks preflightData={preflightData} setPreflightFailed={setPreflightFailed} />
+                <Spacer y={1} />
+              </>
+            }
+          </>
+        }
+
+      </>
+      )}
+
       <Button
-        disabled={isDisabled()}
+        disabled={isDisabled() || isLoading || preflightFailed}
         onClick={createCluster}
         status={getStatus()}
       >
@@ -346,14 +468,14 @@ export default withRouter(GCPProvisionerSettings);
 
 
 const StyledForm = styled.div`
-  position: relative;
-  padding: 30px 30px 25px;
-  border-radius: 5px;
-  background: ${({ theme }) => theme.fg};
-  border: 1px solid #494b4f;
-  font-size: 13px;
-  margin-bottom: 30px;
-`;
+      position: relative;
+      padding: 30px 30px 25px;
+      border-radius: 5px;
+      background: ${({ theme }) => theme.fg};
+      border: 1px solid #494b4f;
+      font-size: 13px;
+      margin-bottom: 30px;
+      `;
 
 const DEFAULT_ERROR_MESSAGE =
   "An error occurred while provisioning your infrastructure. Please try again.";
@@ -364,3 +486,66 @@ const errorMessageToModal = (errorMessage: string) => {
       return null;
   }
 };
+
+const AppearingDiv = styled.div<{ color?: string }>`
+        animation: floatIn 0.5s;
+        animation-fill-mode: forwards;
+        display: flex;
+        flex-direction: column;
+        color: ${(props) => props.color || "#ffffff44"};
+        margin-left: 10px;
+        @keyframes floatIn {
+          from {
+          opacity: 0;
+        transform: translateY(20px);
+    }
+        to {
+          opacity: 1;
+        transform: translateY(0px);
+    }
+  }
+        `;
+const StatusIcon = styled.img`
+        height: 14px;
+        `;
+
+const CheckItemContainer = styled.div`
+        display: flex;
+        flex-direction: column;
+        border: 1px solid ${props => props.theme.border};
+        border-radius: 5px;
+        font-size: 13px;
+        width: 100%;
+        margin-bottom: 10px;
+        padding-left: 10px;
+        cursor: ${props => (props.hasMessage ? 'pointer' : 'default')};
+        background: ${props => props.theme.clickable.bg};
+
+        `;
+
+const CheckItemTop = styled.div`
+        display: flex;
+        align-items: center;
+        padding: 10px;
+        background: ${props => props.theme.clickable.bg};
+        `;
+
+const ExpandIcon = styled.i<{ isExpanded: boolean }>`
+        margin-left: 8px;
+        color: #ffffff66;
+        font-size: 20px;
+        cursor: pointer;
+        border-radius: 20px;
+        transform: ${props => props.isExpanded ? "" : "rotate(-90deg)"};
+        `; 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)"};
+          transition: transform 0.1s ease;
+        }
+      `;

+ 139 - 0
dashboard/src/components/PreflightChecks.tsx

@@ -0,0 +1,139 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+import Spacer from "./porter/Spacer";
+
+import Text from "./porter/Text";
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import { PREFLIGHT_MESSAGE_CONST } from "shared/util";
+
+type Props = RouteComponentProps & {
+  preflightData: any
+  setPreflightFailed: (x: boolean) => void;
+};
+
+
+const PreflightChecks: React.FC<Props> = (props) => {
+  const [trackFailures, setFailures] = useState<boolean>(false)
+  const PreflightCheckItem = ({ check }) => {
+    const [isExpanded, setIsExpanded] = useState(false);
+    const hasMessage = !!check.value?.message;
+    if (hasMessage) {
+      setFailures(hasMessage)
+    }
+    const handleToggle = () => {
+      if (hasMessage) {
+        setIsExpanded(!isExpanded);
+      }
+    }
+    props.setPreflightFailed(trackFailures)
+    return (
+      <CheckItemContainer hasMessage={hasMessage} onClick={handleToggle}>
+        <CheckItemTop>
+          {hasMessage ? <StatusIcon src={failure} /> : <StatusIcon src={healthy} />}
+          <Spacer inline x={1} />
+          <Text style={{ marginLeft: '10px', flex: 1 }}>{PREFLIGHT_MESSAGE_CONST[check.key]}</Text>
+          {hasMessage && <ExpandIcon className="material-icons" isExpanded={isExpanded}>
+            arrow_drop_down
+          </ExpandIcon>}
+        </CheckItemTop>
+        {isExpanded && hasMessage && (
+          <div>
+            <ErrorMessageLabel>Error Message:</ErrorMessageLabel>
+            <ErrorMessageContent>{check.value.message}</ErrorMessageContent>
+            {check.value.metadata &&
+              Object.entries(check.value.metadata).map(([key, value]) => (
+                <div key={key}>
+                  <ErrorMessageLabel>{key}:</ErrorMessageLabel>
+                  <ErrorMessageContent>{value}</ErrorMessageContent>
+                </div>
+              ))}
+          </div>
+        )}
+      </CheckItemContainer>
+    );
+  };
+
+  return (
+    <div>
+      {props.preflightData && (
+        <AppearingDiv>
+          <Text> Preflight Checks </Text>
+          <Spacer y={.5} />
+          {Object.entries(props.preflightData.preflight_checks || {}).map(([key, value]) => (
+            <PreflightCheckItem key={key} check={{ key, value }} />
+          ))}
+        </AppearingDiv>
+      )}
+    </div>
+  );
+};
+
+
+export default withRouter(PreflightChecks);
+
+
+const AppearingDiv = styled.div<{ color?: string }>`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  display: flex;
+  flex-direction: column; 
+  color: ${(props) => props.color || "#ffffff44"};
+  margin-left: 10px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+const StatusIcon = styled.img`
+height: 14px;
+`;
+
+const CheckItemContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  border: 1px solid ${props => props.theme.border};
+  border-radius: 5px;
+  font-size: 13px;
+  width: 100%;
+  margin-bottom: 10px;
+  padding-left: 10px;
+  cursor: ${props => (props.hasMessage ? 'pointer' : 'default')};
+  background: ${props => props.theme.clickable.bg};
+
+`;
+
+const CheckItemTop = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  background: ${props => props.theme.clickable.bg};
+`;
+
+const ExpandIcon = styled.i<{ isExpanded: boolean }>`
+  margin-left: 8px;
+  color: #ffffff66;
+  font-size: 20px;
+  cursor: pointer;
+  border-radius: 20px;
+  transform: ${props => props.isExpanded ? "" : "rotate(-90deg)"};
+`;
+const ErrorMessageLabel = styled.span`
+  font-weight: bold;
+  margin-left: 10px;
+`;
+const ErrorMessageContent = styled.div`
+  font-family: 'Courier New', Courier, monospace;
+  padding: 5px 10px;
+  border-radius: 4px;
+  margin-left: 10px;
+  user-select: text;
+  cursor: text
+`;

+ 56 - 56
dashboard/src/components/ProvisionerSettings.tsx

@@ -368,7 +368,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       // }
       setErrorMessage(undefined);
     } catch (err) {
-      const errMessage = err.response.data.error.replace("unknown: ", "");
+      const errMessage = err.response.data?.error.replace("unknown: ", "");
       // hacky, need to standardize error contract with backend
       setIsClicked(false);
       if (errMessage.includes("elastic IP")) {
@@ -775,8 +775,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                         </ErrorInLine>
                       )}
 
-                    <Spacer y={1} />
-                    {/* <Checkbox
+                      <Spacer y={1} />
+                      {/* <Checkbox
               checked={accessS3Logs}
               disabled={isReadOnly}
               toggleChecked={() => {
@@ -844,61 +844,61 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                     </>
                   )}
                   <FlexCenter>
-                      <Checkbox
-                          checked={guardDutyEnabled}
-                          disabled={isReadOnly}
-                          toggleChecked={() => {
-                            setGuardDutyEnabled(!guardDutyEnabled);
-                          }}
-                          disabledTooltip={
-                            "Wait for provisioning to complete before editing this field."
-                          }
-                      >
-                        <Text color="helper">
-                          Install AWS GuardDuty agent on this cluster (see details to fully enable)
-                        </Text>
-                        <Spacer x={.5} inline/>
-                        <Tooltip
-                            children={<Icon src={info} />}
-                            content={
-                              "In addition to installing the agent, you must enable GuardDuty through your AWS Console and enable EKS Protection in the EKS Protection tab of the GuardDuty console."
-                            }
-                            position="right"
-                        />
-                      </Checkbox>
+                    <Checkbox
+                      checked={guardDutyEnabled}
+                      disabled={isReadOnly}
+                      toggleChecked={() => {
+                        setGuardDutyEnabled(!guardDutyEnabled);
+                      }}
+                      disabledTooltip={
+                        "Wait for provisioning to complete before editing this field."
+                      }
+                    >
+                      <Text color="helper">
+                        Install AWS GuardDuty agent on this cluster (see details to fully enable)
+                      </Text>
+                      <Spacer x={.5} inline />
+                      <Tooltip
+                        children={<Icon src={info} />}
+                        content={
+                          "In addition to installing the agent, you must enable GuardDuty through your AWS Console and enable EKS Protection in the EKS Protection tab of the GuardDuty console."
+                        }
+                        position="right"
+                      />
+                    </Checkbox>
                   </FlexCenter>
                   <Spacer y={1} />
                   <FlexCenter>
-                      <Checkbox
-                          checked={kmsEncryptionEnabled}
-                          disabled={isReadOnly || currentCluster != null}
-                          toggleChecked={() => {
-                            setKmsEncryptionEnabled(!kmsEncryptionEnabled);
-                          }}
-                          disabledTooltip={ kmsEncryptionEnabled ? "KMS encryption can never be disabled." :
-                            "Encryption is only supported at cluster creation."
-                          }
-                      >
-                        <Text color="helper">
-                          Enable KMS encryption for this cluster
-                        </Text>
-                        <Spacer x={.5} inline/>
-                        <Tooltip
-                            children={<Icon src={info} />}
-                            content={
-                              "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
-                            }
-                            position="right"
-                        />
-                      </Checkbox>
-                  </FlexCenter>
-                  {kmsEncryptionEnabled && (
-                      <ErrorInLine>
-                        <i className="material-icons">error</i>
-                        {
+                    <Checkbox
+                      checked={kmsEncryptionEnabled}
+                      disabled={isReadOnly || currentCluster != null}
+                      toggleChecked={() => {
+                        setKmsEncryptionEnabled(!kmsEncryptionEnabled);
+                      }}
+                      disabledTooltip={kmsEncryptionEnabled ? "KMS encryption can never be disabled." :
+                        "Encryption is only supported at cluster creation."
+                      }
+                    >
+                      <Text color="helper">
+                        Enable KMS encryption for this cluster
+                      </Text>
+                      <Spacer x={.5} inline />
+                      <Tooltip
+                        children={<Icon src={info} />}
+                        content={
                           "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
                         }
-                      </ErrorInLine>
+                        position="right"
+                      />
+                    </Checkbox>
+                  </FlexCenter>
+                  {kmsEncryptionEnabled && (
+                    <ErrorInLine>
+                      <i className="material-icons">error</i>
+                      {
+                        "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
+                      }
+                    </ErrorInLine>
                   )}
                   <Spacer y={1} />
                 </>
@@ -964,11 +964,11 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         // disabled={isDisabled()}
         disabled={
           user?.email === "admin@porter.run" ||
-          currentProject?.enable_reprovision
+            currentProject?.enable_reprovision
             ? false
             : currentCluster
-            ? true
-            : isDisabled()
+              ? true
+              : isDisabled()
         }
         onClick={createCluster}
         status={getStatus()}

+ 26 - 0
dashboard/src/components/RadioFilter.tsx

@@ -178,6 +178,32 @@ const Placeholder = styled.div`
 const ScrollableWrapper = styled.div`
   overflow-y: auto;
   max-height: 350px;
+
+  ::-webkit-scrollbar {
+    width: 5px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    border-radius: 5px;
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+    border-radius: 5px;
+  }
 `;
 
 const Relative = styled.div`

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

@@ -51,5 +51,6 @@ const StyledText = styled.div<{
   font-size: ${props => props.size || 13}px;
   display: inline;
   align-items: center;
+  user-select: text;
   ${props => props.additionalStyles ? props.additionalStyles : ""}
 `;

+ 12 - 2
dashboard/src/lib/hooks/useAppAnalytics.ts

@@ -5,12 +5,21 @@ import api from "shared/api";
 type AppStep =
   | "stack-launch-complete"
   | "stack-launch-success"
-  | "stack-launch-failure";
+  | "stack-launch-failure"
+  | "stack-deletion";
 
 export const useAppAnalytics = (appName: string) => {
   const { currentCluster, currentProject } = useContext(Context);
 
-  const updateAppStep = async (step: AppStep, errorMessage: string = "") => {
+  const updateAppStep = async ({
+    step,
+    errorMessage = "",
+    deleteWorkflow = false,
+  }: {
+    step: AppStep;
+    errorMessage?: string;
+    deleteWorkflow?: boolean;
+  }) => {
     try {
       if (!currentCluster?.id || !currentProject?.id) {
         return;
@@ -21,6 +30,7 @@ export const useAppAnalytics = (appName: string) => {
           step,
           stack_name: appName,
           error_message: errorMessage,
+          delete_workflow_file: deleteWorkflow,
         },
         {
           cluster_id: currentCluster.id,

+ 8 - 0
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -73,6 +73,7 @@ export const usePorterYaml = ({
         source?.type === "github" &&
         Boolean(source.git_repo_name) &&
         Boolean(source.git_branch),
+      retry: false,
     }
   );
 
@@ -139,6 +140,13 @@ export const usePorterYaml = ({
     }
   }, [data]);
 
+  if (source?.type !== "github") {
+    return {
+      loading: false,
+      detectedServices: null,
+    };
+  }
+
   if (status === "loading") {
     return {
       loading: true,

+ 11 - 1
dashboard/src/lib/porter-apps/services.ts

@@ -38,9 +38,12 @@ export const serviceValidator = z.object({
     z.object({
       type: z.literal("web"),
       autoscaling: autoscalingValidator.optional(),
-      ingressEnabled: z.boolean().default(false).optional(),
       domains: domainsValidator,
       healthCheck: healthcheckValidator.optional(),
+      private: serviceBooleanValidator.default({
+        value: false,
+        readOnly: false,
+      }),
     }),
     z.object({
       type: z.literal("worker"),
@@ -76,6 +79,7 @@ export type SerializedService = {
         }[];
         autoscaling?: SerializedAutoscaling;
         healthCheck?: SerializedHealthcheck;
+        private: boolean;
       }
     | {
         type: "worker";
@@ -132,6 +136,7 @@ export function defaultSerialized({
         autoscaling: defaultAutoscaling,
         healthCheck: defaultHealthCheck,
         domains: [],
+        private: false,
       },
     }))
     .with("worker", () => ({
@@ -180,6 +185,7 @@ export function serializeService(service: ClientService): SerializedService {
           domains: config.domains.map((domain) => ({
             name: domain.name.value,
           })),
+          private: config.private.value,
         },
       })
     )
@@ -280,6 +286,10 @@ export function deserializeService({
               )?.name
             ),
           })),
+          private: ServiceField.boolean(
+            config.private,
+            overrideWebConfig?.private
+          ),
         },
       };
     })

+ 175 - 25
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import { FormProvider, useForm } from "react-hook-form";
 import {
   PorterAppFormData,
@@ -14,6 +14,17 @@ import TabSelector from "components/TabSelector";
 import { useHistory } from "react-router";
 import { match } from "ts-pattern";
 import Overview from "./tabs/Overview";
+import { useAppValidation } from "lib/hooks/useAppValidation";
+import api from "shared/api";
+import { useQueryClient } from "@tanstack/react-query";
+import Settings from "./tabs/Settings";
+import BuildSettings from "./tabs/BuildSettings";
+import Environment from "./tabs/Environment";
+import AnimateHeight from "react-animate-height";
+import Banner from "components/porter/Banner";
+import Button from "components/porter/Button";
+import Icon from "components/porter/Icon";
+import save from "assets/save-01.svg";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -39,6 +50,9 @@ type AppDataContainerProps = {
 
 const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const history = useHistory();
+  const queryClient = useQueryClient();
+  const [redeployOnSave, setRedeployOnSave] = useState(false);
+
   const {
     porterApp,
     latestProto,
@@ -48,6 +62,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     deploymentTargetId,
     servicesFromYaml,
   } = useLatestRevision();
+  const { validateApp } = useAppValidation({
+    deploymentTargetID: deploymentTargetId,
+  });
 
   const currentTab = useMemo(() => {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
@@ -86,7 +103,94 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       source: latestSource,
     },
   });
-  const { reset } = porterAppFormMethods;
+  const {
+    reset,
+    handleSubmit,
+    formState: { isDirty, dirtyFields, isSubmitting },
+  } = porterAppFormMethods;
+
+  // getAllDirtyFields recursively gets all dirty fields from the dirtyFields object
+  // all fields in the form are set to a boolean indicating if the current value is different from the default value
+  const getAllDirtyFields = (dirtyFields: object) => {
+    const dirty: string[] = [];
+
+    Object.entries(dirtyFields).forEach(([key, value]) => {
+      if (value) {
+        if (typeof value === "boolean" && value === true) {
+          dirty.push(key);
+        }
+
+        if (typeof value === "object") {
+          dirty.push(...getAllDirtyFields(value));
+        }
+      }
+    });
+
+    return dirty;
+  };
+
+  // onlyExpandedChanged is true if the only dirty fields are expanded and id
+  // expanded is a ui only value used to determine if a service is expanded or not
+  // id is set by useFieldArray and is also not relevant to the app proto
+  const onlyExpandedChanged = useMemo(() => {
+    if (!isDirty) return false;
+
+    // get all entries in entire dirtyFields object that are true
+    const dirty = getAllDirtyFields(dirtyFields);
+    return dirty.every((f) => f === "expanded" || f === "id");
+  }, [isDirty, JSON.stringify(dirtyFields)]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    try {
+      const validatedAppProto = await validateApp(data);
+      await api.applyApp(
+        "<token>",
+        {
+          b64_app_proto: btoa(validatedAppProto.toJsonString()),
+          deployment_target_id: deploymentTargetId,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+        }
+      );
+
+      if (
+        redeployOnSave &&
+        latestSource.type === "github" &&
+        dirtyFields.app?.build
+      ) {
+        await api.reRunGHWorkflow(
+          "<token>",
+          {},
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+            git_installation_id: latestSource.git_repo_id,
+            owner: latestSource.git_repo_name.split("/")[0],
+            name: latestSource.git_repo_name.split("/")[1],
+            branch: porterApp.git_branch,
+            filename: "porter_stack_" + porterApp.name + ".yml",
+          }
+        );
+
+        setRedeployOnSave(false);
+      }
+
+      await queryClient.invalidateQueries([
+        "getLatestRevision",
+        projectId,
+        clusterId,
+        deploymentTargetId,
+        porterApp.name,
+      ]);
+
+      reset({
+        app: clientAppFromProto(latestProto, servicesFromYaml),
+        source: latestSource,
+      });
+    } catch (err) {}
+  });
 
   useEffect(() => {
     if (servicesFromYaml) {
@@ -95,32 +199,78 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         source: latestSource,
       });
     }
-  }, [servicesFromYaml]);
+  }, [servicesFromYaml, currentTab]);
 
   return (
     <FormProvider {...porterAppFormMethods}>
-      <RevisionsList
-        latestRevisionNumber={latestRevision.revision_number}
-        deploymentTargetId={deploymentTargetId}
-        projectId={projectId}
-        clusterId={clusterId}
-        appName={porterApp.name}
-        sourceType={latestSource.type}
-      />
-      <Spacer y={1} />
-      <TabSelector
-        noBuffer
-        options={[{ label: "Overview", value: "overview" }]}
-        currentTab={currentTab}
-        setCurrentTab={() => {
-          history.push(`/apps/${porterApp.name}/${currentTab}`);
-        }}
-      />
-      <Spacer y={1} />
-      {match(currentTab)
-        .with("overview", () => <Overview />)
-        .otherwise(() => null)}
-      <Spacer y={2} />
+      <form onSubmit={onSubmit}>
+        <RevisionsList
+          latestRevisionNumber={latestRevision.revision_number}
+          deploymentTargetId={deploymentTargetId}
+          projectId={projectId}
+          clusterId={clusterId}
+          appName={porterApp.name}
+          sourceType={latestSource.type}
+        />
+        <Spacer y={1} />
+        <AnimateHeight height={isDirty && !onlyExpandedChanged ? "auto" : 0}>
+          <Banner
+            type="warning"
+            suffix={
+              <>
+                <Button
+                  type="submit"
+                  loadingText={"Updating..."}
+                  height={"10px"}
+                  status={isSubmitting ? "loading" : ""}
+                  disabled={isSubmitting}
+                >
+                  <Icon src={save} height={"13px"} />
+                  <Spacer inline x={0.5} />
+                  Save as latest version
+                </Button>
+              </>
+            }
+          >
+            Changes you are currently previewing have not been saved.
+            <Spacer inline width="5px" />
+          </Banner>
+          <Spacer y={1} />
+        </AnimateHeight>
+        <TabSelector
+          noBuffer
+          options={[
+            { label: "Overview", value: "overview" },
+            { label: "Environment", value: "environment" },
+            ...(latestProto.build
+              ? [
+                  {
+                    label: "Build Settings",
+                    value: "build-settings",
+                  },
+                ]
+              : []),
+            { label: "Settings", value: "settings" },
+          ]}
+          currentTab={currentTab}
+          setCurrentTab={(tab) => {
+            history.push(`/apps/${porterApp.name}/${tab}`);
+          }}
+        />
+        <Spacer y={1} />
+        {match(currentTab)
+          .with("overview", () => <Overview />)
+          .with("build-settings", () => (
+            <BuildSettings
+              redeployOnSave={redeployOnSave}
+              setRedeployOnSave={setRedeployOnSave}
+            />
+          ))
+          .with("environment", () => <Environment />)
+          .with("settings", () => <Settings />)
+          .otherwise(() => null)}
+        <Spacer y={2} />
+      </form>
     </FormProvider>
   );
 };

+ 69 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx

@@ -0,0 +1,69 @@
+import React, { Dispatch, SetStateAction, useMemo } from "react";
+import RepoSettings from "../../create-app/RepoSettings";
+import { useFormContext } from "react-hook-form";
+import { PorterAppFormData } from "lib/porter-apps";
+import { useLatestRevision } from "../LatestRevisionContext";
+import Spacer from "components/porter/Spacer";
+import Checkbox from "components/porter/Checkbox";
+import Text from "components/porter/Text";
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+
+type Props = {
+  redeployOnSave: boolean;
+  setRedeployOnSave: Dispatch<SetStateAction<boolean>>;
+};
+
+const BuildSettings: React.FC<Props> = ({
+  redeployOnSave,
+  setRedeployOnSave,
+}) => {
+  const {
+    watch,
+    formState: { isSubmitting, errors },
+  } = useFormContext<PorterAppFormData>();
+  const { projectId } = useLatestRevision();
+
+  const build = watch("app.build");
+  const source = watch("source");
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+
+    if (Object.keys(errors).length > 0) {
+      return <Error message="Unable to update app" />;
+    }
+
+    return "";
+  }, [isSubmitting, errors]);
+
+  if (source.type !== "github") {
+    return null;
+  }
+
+  return (
+    <>
+      <RepoSettings
+        build={build}
+        source={source}
+        projectId={projectId}
+        appExists
+      />
+      <Spacer y={1} />
+      <Checkbox
+        checked={redeployOnSave}
+        toggleChecked={() => setRedeployOnSave(!redeployOnSave)}
+      >
+        <Text>Re-run build and deploy on save</Text>
+      </Checkbox>
+      <Spacer y={1} />
+      <Button type="submit" status={buttonStatus}>
+        Save build settings
+      </Button>
+    </>
+  );
+};
+
+export default BuildSettings;

+ 41 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx

@@ -0,0 +1,41 @@
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import React, { useMemo } from "react";
+import EnvVariables from "../../validate-apply/app-settings/EnvVariables";
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import { useFormContext } from "react-hook-form";
+import { PorterAppFormData } from "lib/porter-apps";
+
+const Environment: React.FC = () => {
+  const {
+    formState: { isSubmitting, errors },
+  } = useFormContext<PorterAppFormData>();
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+
+    if (Object.keys(errors).length > 0) {
+      return <Error message="Unable to update app" />;
+    }
+
+    return "";
+  }, [isSubmitting, errors]);
+
+  return (
+    <>
+      <Text size={16}>Environment variables</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">Shared among all services.</Text>
+      <EnvVariables />
+      <Spacer y={0.5} />
+      <Button type="submit" status={buttonStatus} loadingText={"Updating..."}>
+        Update app
+      </Button>
+    </>
+  );
+};
+
+export default Environment;

+ 20 - 14
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -11,9 +11,11 @@ import {
 } from "lib/porter-apps/services";
 import Error from "components/porter/Error";
 import Button from "components/porter/Button";
+import { useLatestRevision } from "../LatestRevisionContext";
 
 const Overview: React.FC = () => {
   const { formState } = useFormContext<PorterAppFormData>();
+  const { porterApp } = useLatestRevision();
 
   const buttonStatus = useMemo(() => {
     if (formState.isSubmitting) {
@@ -29,20 +31,24 @@ const Overview: React.FC = () => {
 
   return (
     <>
-      <Text size={16}>Pre-deploy job</Text>
-      <Spacer y={0.5} />
-      <ServiceList
-        limitOne={true}
-        addNewText={"Add a new pre-deploy job"}
-        prePopulateService={deserializeService({
-          service: defaultSerialized({
-            name: "pre-deploy",
-            type: "predeploy",
-          }),
-        })}
-        isPredeploy
-      />
-      <Spacer y={0.5} />
+      {porterApp.git_repo_id && (
+        <>
+          <Text size={16}>Pre-deploy job</Text>
+          <Spacer y={0.5} />
+          <ServiceList
+            limitOne={true}
+            addNewText={"Add a new pre-deploy job"}
+            prePopulateService={deserializeService({
+              service: defaultSerialized({
+                name: "pre-deploy",
+                type: "predeploy",
+              }),
+            })}
+            isPredeploy
+          />
+          <Spacer y={0.5} />
+        </>
+      )}
       <Text size={16}>Application services</Text>
       <Spacer y={0.5} />
       <ServiceList addNewText={"Add a new service"} />

+ 140 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -0,0 +1,140 @@
+import React, { useCallback, useState } from "react";
+import styled from "styled-components";
+import { useHistory } from "react-router";
+
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
+import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
+
+import { useLatestRevision } from "../LatestRevisionContext";
+import api from "shared/api";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+
+const Settings: React.FC = () => {
+  const history = useHistory();
+  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+  const { porterApp, clusterId, projectId } = useLatestRevision();
+  const { updateAppStep } = useAppAnalytics(porterApp.name);
+
+  const githubWorkflowFilename = `porter_stack_${porterApp.name}.yml`;
+
+  const workflowFileExists = useCallback(async () => {
+    try {
+      if (
+        !porterApp.git_branch ||
+        !porterApp.repo_name ||
+        !porterApp.git_repo_id
+      ) {
+        return false;
+      }
+
+      await api.getBranchContents(
+        "<token>",
+        {
+          dir: `./.github/workflows/porter_stack_${porterApp.name}.yml`,
+        },
+        {
+          project_id: projectId,
+          git_repo_id: porterApp.git_repo_id,
+          kind: "github",
+          owner: porterApp.repo_name.split("/")[0],
+          name: porterApp.repo_name.split("/")[1],
+          branch: porterApp.git_branch,
+        }
+      );
+
+      return true;
+    } catch (err) {
+      return false;
+    }
+  }, [githubWorkflowFilename, porterApp.name, clusterId, projectId]);
+
+  const onDelete = useCallback(
+    async (deleteWorkflow?: boolean) => {
+      try {
+        await api.deletePorterApp(
+          "<token>",
+          {},
+          {
+            cluster_id: clusterId,
+            project_id: projectId,
+            name: porterApp.name,
+          }
+        );
+
+        if (!deleteWorkflow) {
+          return;
+        }
+
+        const exists = await workflowFileExists();
+        if (
+          exists &&
+          porterApp.git_branch &&
+          porterApp.repo_name &&
+          porterApp.git_repo_id
+        ) {
+          const res = await api.createSecretAndOpenGitHubPullRequest(
+            "<token>",
+            {
+              github_app_installation_id: porterApp.git_repo_id,
+              github_repo_owner: porterApp.repo_name.split("/")[0],
+              github_repo_name: porterApp.repo_name.split("/")[1],
+              branch: porterApp.git_branch,
+              delete_workflow_filename: githubWorkflowFilename,
+            },
+            {
+              project_id: projectId,
+              cluster_id: clusterId,
+              stack_name: porterApp.name,
+            }
+          );
+          if (res.data?.url) {
+            window.open(res.data.url, "_blank", "noreferrer");
+          }
+
+          updateAppStep({ step: "stack-deletion", deleteWorkflow: true });
+          history.push("/apps");
+          return;
+        }
+
+        updateAppStep({ step: "stack-deletion", deleteWorkflow: false });
+        history.push("/apps");
+      } catch (err) {}
+    },
+    [githubWorkflowFilename, porterApp.name, clusterId, projectId]
+  );
+
+  return (
+    <StyledSettingsTab>
+      <Text size={16}>Delete "{porterApp.name}"</Text>
+      <Spacer y={1} />
+      <Text color="helper">
+        Delete this application and all of its resources.
+      </Text>
+      <Spacer y={1} />
+      <Button
+        type="button"
+        onClick={() => {
+          setIsDeleteModalOpen(true);
+        }}
+        color="#b91133"
+      >
+        Delete
+      </Button>
+      {isDeleteModalOpen && (
+        <DeleteApplicationModal
+          closeModal={() => setIsDeleteModalOpen(false)}
+          githubWorkflowFilename={githubWorkflowFilename}
+          deleteApplication={onDelete}
+        />
+      )}
+    </StyledSettingsTab>
+  );
+};
+
+export default Settings;
+
+const StyledSettingsTab = styled.div`
+  width: 100%;
+`;

+ 8 - 5
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -98,10 +98,10 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       app: {
         name: "",
         build: {
+          method: "pack",
           context: "./",
           builder: "",
           buildpacks: [],
-          dockerfile: "",
         },
       },
       source: {
@@ -166,7 +166,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     }) => {
       setIsDeploying(true);
       // log analytics event that we started form submission
-      updateAppStep("stack-launch-complete");
+      updateAppStep({ step: "stack-launch-complete" });
 
       try {
         if (!currentProject?.id || !currentCluster?.id) {
@@ -202,7 +202,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         );
 
         // log analytics event that we successfully deployed
-        updateAppStep("stack-launch-success");
+        updateAppStep({ step: "stack-launch-success" });
 
         if (source.type === "docker-registry") {
           history.push(`/apps/${app.name}`);
@@ -211,14 +211,17 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         return true;
       } catch (err) {
         if (axios.isAxiosError(err) && err.response?.data?.error) {
-          updateAppStep("stack-launch-failure", err.response?.data?.error);
+          updateAppStep({
+            step: "stack-launch-failure",
+            errorMessage: err.response?.data?.error,
+          });
           setDeployError(err.response?.data?.error);
           return false;
         }
 
         const msg =
           "An error occurred while deploying your application. Please try again.";
-        updateAppStep("stack-launch-failure", msg);
+        updateAppStep({ step: "stack-launch-failure", errorMessage: msg });
         setDeployError(msg);
         return false;
       } finally {

+ 53 - 47
dashboard/src/main/home/app-dashboard/create-app/RepoSettings.tsx

@@ -18,11 +18,13 @@ import {
 import RepositorySelector from "../build-settings/RepositorySelector";
 import BranchSelector from "../build-settings/BranchSelector";
 import BuildpackSettings from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
+import { match } from "ts-pattern";
 
 type Props = {
   projectId: number;
   source: SourceOptions & { type: "github" };
   build: BuildOptions;
+  appExists?: boolean;
 };
 
 const branchContentsSchema = z
@@ -35,15 +37,14 @@ const branchContentsSchema = z
 type BranchContents = z.infer<typeof branchContentsSchema>;
 type BuildView = "docker" | "pack";
 
-const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
-  const {
-    watch,
-    control,
-    register,
-    setValue,
-  } = useFormContext<PorterAppFormData>();
+const RepoSettings: React.FC<Props> = ({
+  projectId,
+  source,
+  build,
+  appExists,
+}) => {
+  const { control, register, setValue } = useFormContext<PorterAppFormData>();
   const [showSettings, setShowSettings] = useState<boolean>(false);
-  const method = watch("app.build.method");
 
   const repoIsSet = useMemo(() => source.git_repo_name !== "", [
     source.git_repo_name,
@@ -129,23 +130,25 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
             setValue={() => {}}
             placeholder=""
           />
-          <BackButton
-            width="135px"
-            onClick={() => {
-              setValue("source", {
-                type: "github",
-                git_repo_name: "",
-                git_branch: "",
-                git_repo_id: 0,
-                porter_yaml_path: "./porter.yaml",
-              });
+          {!appExists && (
+            <BackButton
+              width="135px"
+              onClick={() => {
+                setValue("source", {
+                  type: "github",
+                  git_repo_name: "",
+                  git_branch: "",
+                  git_repo_id: 0,
+                  porter_yaml_path: "./porter.yaml",
+                });
 
-              setValue("app.build.context", "./");
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select repo
-          </BackButton>
+                setValue("app.build.context", "./");
+              }}
+            >
+              <i className="material-icons">keyboard_backspace</i>
+              Select repo
+            </BackButton>
+          )}
           <Spacer y={0.5} />
           <Spacer y={0.5} />
           <Text color="helper">Specify your GitHub branch.</Text>
@@ -208,7 +211,7 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
                   setShowSettings(!showSettings);
                 }}
               >
-                {method == "docker" ? (
+                {build.method == "docker" ? (
                   <AdvancedBuildTitle>
                     <i className="material-icons dropdown">arrow_drop_down</i>
                     Configure Dockerfile settings
@@ -224,7 +227,7 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
               <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
                 <StyledSourceBox>
                   <Select
-                    value={method}
+                    value={build.method}
                     width="300px"
                     options={[
                       { value: "docker", label: "Docker" },
@@ -235,28 +238,31 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
                     }
                     label="Build method"
                   />
-                  {method === "docker" ? (
-                    <>
-                      <Spacer y={0.5} />
-                      <Text color="helper">
-                        Dockerfile path (absolute path)
-                      </Text>
-                      <Spacer y={0.5} />
-                      <ControlledInput
-                        width="300px"
-                        placeholder="ex: ./Dockerfile"
-                        type="text"
-                        {...register("app.build.dockerfile")}
+                  {match(build)
+                    .with({ method: "docker" }, () => (
+                      <>
+                        <Spacer y={0.5} />
+                        <Text color="helper">
+                          Dockerfile path (absolute path)
+                        </Text>
+                        <Spacer y={0.5} />
+                        <ControlledInput
+                          width="300px"
+                          placeholder="ex: ./Dockerfile"
+                          type="text"
+                          {...register("app.build.dockerfile")}
+                        />
+                        <Spacer y={0.5} />
+                      </>
+                    ))
+                    .with({ method: "pack" }, (b) => (
+                      <BuildpackSettings
+                        projectId={projectId}
+                        build={b}
+                        source={source}
                       />
-                      <Spacer y={0.5} />
-                    </>
-                  ) : (
-                    <BuildpackSettings
-                      projectId={projectId}
-                      build={build}
-                      source={source}
-                    />
-                  )}
+                    ))
+                    .exhaustive()}
                 </StyledSourceBox>
               </AnimateHeight>
             </>

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

@@ -570,9 +570,8 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 <Text size={16}>Pre-deploy job (optional)</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  You may add a pre-deploy job to
-                  perform an operation before your application services
-                  deploy each time, like a database migration.
+                  After your application is built each time, your pre-deploy command will run before your services
+                  are deployed. Use this for operations like a database migration.
                 </Text>
                 <Spacer y={0.5} />
                 <Services
@@ -584,6 +583,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   limitOne={true}
                   addNewText={"Add a new pre-deploy job"}
                   prePopulateService={Service.default("pre-deploy", "release", porterJsonWithPath?.porterJson)}
+                  appName={porterApp.name}
                 />
               </>,
               <Button

+ 4 - 3
dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx

@@ -23,7 +23,7 @@ interface ServiceProps {
   chart?: any;
   editService: (service: Service) => void;
   deleteService: () => void;
-  setExpandedJob: (x: string) => void;
+  setExpandedJob?: (x: string) => void;
 }
 
 const ServiceContainer: React.FC<ServiceProps> = ({
@@ -35,7 +35,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
 }) => {
   const [height, setHeight] = React.useState<Height>("auto");
 
-  const UPPER_BOUND = .75;
+  const UPPER_BOUND = .5;
 
   const [maxCPU, setMaxCPU] = useState(2 * UPPER_BOUND); //default is set to a t3 medium 
   const [maxRAM, setMaxRAM] = useState(4 * UPPER_BOUND); //default is set to a t3 medium
@@ -80,7 +80,8 @@ const ServiceContainer: React.FC<ServiceProps> = ({
               RAM: 4,
             };
 
-            data.forEach(node => {
+            // TODO: type this response
+            data.forEach((node: any) => {
               if (node.labels['porter.run/workload-kind'] == "application") {
                 var instanceType: string = node.labels['beta.kubernetes.io/instance-type'];
                 const [instanceClass, instanceSize] = instanceType.split('.');

+ 9 - 6
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -124,7 +124,14 @@ const Services: React.FC<ServicesProps> = ({
       )}
       {maybeRenderAddServicesButton()}
       {showAddServiceModal && (
-        <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
+        <Modal
+          closeModal={() => {
+            setShowAddServiceModal(false)
+            setServiceName("")
+            setServiceType("web")
+          }}
+          width="500px"
+        >
           <Text size={16}>{addNewText}</Text>
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
@@ -168,11 +175,7 @@ const Services: React.FC<ServicesProps> = ({
               setServiceName("");
               setServiceType("web");
             }}
-            disabled={
-              !isServiceNameValid(serviceName) ||
-              isServiceNameDuplicate(serviceName) ||
-              serviceName?.length > 61
-            }
+            disabled={maybeGetError() != null}
           >
             <I className="material-icons">add</I> Add service
           </Button>

+ 6 - 6
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Networking.tsx

@@ -28,7 +28,7 @@ const prefixSubdomain = (subdomain: string) => {
 const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
   const { register, control, watch } = useFormContext<PorterAppFormData>();
 
-  const ingressEnabled = watch(`app.services.${index}.config.ingressEnabled`);
+  const privateService = watch(`app.services.${index}.config.private.value`);
 
   const getApplicationURLText = () => {
     if (service.config.domains.length !== 0) {
@@ -69,12 +69,12 @@ const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
       />
       <Spacer y={1} />
       <Controller
-        name={`app.services.${index}.config.ingressEnabled`}
+        name={`app.services.${index}.config.private.value`}
         control={control}
         render={({ field: { value, onChange } }) => (
           <Checkbox
-            checked={Boolean(value)}
-            disabled={service.config.domains.some((d) => d.name.readOnly)}
+            checked={value}
+            disabled={service.config.private.readOnly}
             toggleChecked={() => {
               onChange(!value);
             }}
@@ -82,11 +82,11 @@ const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
               "You may only edit this field in your porter.yaml."
             }
           >
-            <Text color="helper">Expose to external traffic</Text>
+            <Text color="helper">Private Service</Text>
           </Checkbox>
         )}
       />
-      {ingressEnabled && (
+      {!privateService && (
         <>
           <Spacer y={0.5} />
           {getApplicationURLText()}

+ 7 - 5
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -24,7 +24,7 @@ export type Collaborator = {
   kind: string;
 };
 
-const InvitePage: React.FunctionComponent<Props> = ({}) => {
+const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const {
     currentProject,
     setCurrentModal,
@@ -185,8 +185,11 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   };
 
   const validateEmail = () => {
+    const trimmedEmail = email.trim();
+    setEmail(trimmedEmail);
+
     const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    if (!regex.test(email.toLowerCase())) {
+    if (!regex.test(trimmedEmail.toLowerCase())) {
       setIsInvalidEmail(true);
       return;
     }
@@ -335,9 +338,8 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
     const buildInviteLink = (token: string) => `
-      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
-      currentProject.id
-    }/invites/${token}
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${currentProject.id
+      }/invites/${token}
     `;
 
     if (!user) {

+ 149 - 91
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -18,9 +18,17 @@ import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
 import Metadata from "./Metadata";
+import Button from "components/porter/Button";
+import Input from "components/porter/Input";
+import { isAlphanumeric } from "shared/common";
+import api from "shared/api";
+import Error from "components/porter/Error";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
-
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
 type StateType = {
   projectName: string;
   currentTab: string;
@@ -28,62 +36,49 @@ type StateType = {
   showCostConfirmModal: boolean;
 };
 
-class ProjectSettings extends Component<PropsType, StateType> {
-  state = {
-    projectName: "",
-    currentTab: "manage-access",
-    tabOptions: [] as { value: string; label: string }[],
-    showCostConfirmModal: false,
-  };
+function ProjectSettings(props: any) {
+  const context = useContext(Context);
+
+  const [projectName, setProjectName] = useState("");
+  const [currentTab, setCurrentTab] = useState("manage-access");
+  const [tabOptions, setTabOptions] = useState([]);
+  const [showCostConfirmModal, setShowCostConfirmModal] = useState(false);
+  const [name, setName] = useState(context?.currentProject?.name);
+  const [disabled, setDisabled] = useState<boolean>(false);
+  const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
 
-  componentDidUpdate(prevProps: PropsType) {
+  useEffect(() => {
     const selectedTab =
-      getQueryParam(this.props, "selected_tab") || "manage-access";
+      getQueryParam(props, "selected_tab") || "manage-access";
 
-    if (
-      prevProps.location.search !== this.props.location.search &&
-      this.state.currentTab !== selectedTab
-    ) {
-      this.setState({ currentTab: selectedTab });
+    if (currentTab !== selectedTab) {
+      setCurrentTab(selectedTab);
+    }
+  }, [props.location.search]);
+  useEffect(() => {
+    const currentProject = context.currentProject;
+    if (projectName !== currentProject.name) {
+      setProjectName(currentProject.name);
     }
 
-    // if (
-    //   this.context?.hasBillingEnabled &&
-    //   !this.state.tabOptions.find((t) => t.value === "billing")
-    // ) {
-    //   const tabOptions = this.state.tabOptions;
-    //   tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
-    //   this.setState({ tabOptions });
-    //   return;
-    // }
-
-    // if (
-    //   !this.context?.hasBillingEnabled &&
-    //   this.state.tabOptions.find((t) => t.value === "billing")
-    // ) {
-    //   const tabOptions = this.state.tabOptions;
-    //   const billingIndex = this.state.tabOptions.findIndex(
-    //     (t) => t.value === "billing"
-    //   );
-    //   tabOptions.splice(billingIndex, 1);
-    // }
-  }
+  }, []);
 
-  componentDidMount() {
-    let { currentProject } = this.context;
 
-    if (this.state.projectName !== currentProject.name) {
-      this.setState({ projectName: currentProject.name });
+  useEffect(() => {
+    let { currentProject } = context;
+    if (projectName !== currentProject.name) {
+      setProjectName(currentProject.name);
     }
-    const tabOptions = [];
-    tabOptions.push({ value: "manage-access", label: "Manage access" });
+
+    const tabOpts = [];
+    tabOpts.push({ value: "manage-access", label: "Manage access" });
     // ? Disabled for now https://discord.com/channels/542888846271184896/1059277393031856208/1059277395913351258
     // tabOptions.push({
     //   value: "billing",
     //   label: "Billing",
     // });
-    tabOptions.push({ value: "metadata", label: "Metadata" });
-    if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
+    tabOpts.push({ value: "metadata", label: "Metadata" });
+    if (props.isAuthorized("settings", "", ["get", "delete"])) {
       // if (this.context?.hasBillingEnabled) {
       //   tabOptions.push({
       //     value: "billing",
@@ -92,54 +87,97 @@ class ProjectSettings extends Component<PropsType, StateType> {
       // }
 
       if (currentProject?.api_tokens_enabled) {
-        tabOptions.push({
+        tabOpts.push({
           value: "api-tokens",
           label: "API Tokens",
         });
       }
 
-      tabOptions.push({
+      tabOpts.push({
         value: "additional-settings",
         label: "Additional settings",
       });
     }
 
-    if (!_.isEqual(tabOptions, this.state.tabOptions)) {
-      this.setState({ tabOptions });
+
+    if (!_.isEqual(tabOpts, tabOptions)) {
+      setTabOptions(tabOpts);
+    }
+
+    const selectedTab = getQueryParam(props, "selected_tab");
+    if (selectedTab && selectedTab !== currentTab) {
+      setCurrentTab(selectedTab);
+    }
+
+  }, [context, projectName, currentTab, props, tabOptions]);
+
+  const validateProjectName = (): ValidationError => {
+    if (name === "") {
+      return {
+        hasError: true,
+        description: "The name cannot be empty. Please fill the input.",
+      };
+    }
+    if (!isAlphanumeric(name)) {
+      return {
+        hasError: true,
+        description:
+          'Please be sure that the text is alphanumeric. (lowercase letters, numbers, and "-" only)',
+      };
     }
+    if (name.length > 25) {
+      return {
+        hasError: true,
+        description:
+          "The length of the name cannot be more than 25 characters.",
+      };
+    }
+
+    return {
+      hasError: false,
+    };
+  };
+
+  const handleNameChange = async () => {
+    try {
+      setButtonStatus("loading");
 
-    const selectedTab = getQueryParam(this.props, "selected_tab");
-    if (selectedTab && selectedTab !== this.state.currentTab) {
-      this.setState({ currentTab: selectedTab });
+      await api.renameProject(
+        "<token>",
+        {
+          name: name,
+        },
+        {
+          project_id: context.currentProject.id,
+        })
+      setButtonStatus("success");
+      window.location.reload();
+
+    } catch (err) {
+      console.log(err)
+      setButtonStatus(<Error message="Unable to rename project" />);
     }
   }
 
-  renderTabContents = () => {
-    if (!this.props.isAuthorized("settings", "", ["get", "delete"])) {
+  const renderTabContents = () => {
+    if (!props.isAuthorized("settings", "", ["get", "delete"])) {
       return <InvitePage />;
     }
 
-    // if (
-    //   this.state.currentTab === "billing" &&
-    //   this.context?.hasBillingEnabled
-    // ) {
-    //   return <BillingPage />;
-    // }
-
-    if (this.state.currentTab === "manage-access") {
+    if (currentTab === "manage-access") {
       return <InvitePage />;
     }
-    else if (this.state.currentTab == "metadata") {
+    else if (currentTab == "metadata") {
       return <Metadata />
-    } else if (this.state.currentTab === "api-tokens") {
+    } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
-    } else if (this.state.currentTab === "billing") {
+    } else if (currentTab === "billing") {
       return (
         <Placeholder>
           <Helper>
             Visit the{" "}
             <a
-              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+              href={`/api/projects/${context.currentProject?.id}/billing/redirect`}
             >
               billing portal
             </a>{" "}
@@ -150,9 +188,30 @@ class ProjectSettings extends Component<PropsType, StateType> {
     } else {
       return (
         <>
-          <Heading isAtTop={true}>Delete project</Heading>
-          <Helper>
+
+          <Heading isAtTop={true}>Rename Project</Heading>
+
+          <Helper color={validateProjectName().hasError ? "#f5cb42" : "#aaaabb"}>
+            (lowercase letters, numbers, and "-" only)
           </Helper>
+          <Input placeholder={"ex: perspective-vortex"} value={name} setValue={setName} width={"500px"}>
+          </Input>
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+              handleNameChange()
+            }}
+            status={buttonStatus}
+            loadingText={"Updating..."}
+            disabled={validateProjectName().hasError}
+          >
+            Change name
+          </Button>
+
+          <Spacer y={1} />
+          <Spacer y={1} />
+          <Heading isAtTop={true}>Delete project</Heading>
+
           <Helper>
             Permanently delete this project. This will destroy all clusters tied
             to this project that have been provisioned by Porter. Note that this
@@ -162,41 +221,40 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
           <DeleteButton
             onClick={() => {
-              this.setState({ showCostConfirmModal: true });
+              setShowCostConfirmModal(true);
             }}
           >
             Delete project
           </DeleteButton>
           <ProjectDeleteConsent
-            setShowCostConfirmModal={(show: boolean) => this.setState({ showCostConfirmModal: show })}
-            show={this.state.showCostConfirmModal}  // <-- Pass these props
+            setShowCostConfirmModal={setShowCostConfirmModal}
+            show={showCostConfirmModal}  // <-- Pass these props
           />
         </>
       );
     }
   };
 
-  render() {
-    return (
-      <StyledProjectSettings>
-        <DashboardHeader
-          image={settings}
-          title="Project settings"
-          description="Configure access permissions and additional project settings."
-          disableLineBreak
-        />
-        <TabRegion
-          currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          options={this.state.tabOptions}
-        >
-          {this.renderTabContents()}
-        </TabRegion>
-      </StyledProjectSettings>
-    );
-  }
+  return (
+    <StyledProjectSettings>
+      <DashboardHeader
+        image={settings}
+        title="Project settings"
+        description="Configure access permissions and additional project settings."
+        disableLineBreak
+      />
+      <TabRegion
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+        options={tabOptions}
+      >
+        {renderTabContents()}
+      </TabRegion>
+    </StyledProjectSettings>
+  );
 }
 
+
 ProjectSettings.contextType = Context;
 
 export default withRouter(withAuth(ProjectSettings));
@@ -217,7 +275,7 @@ const Placeholder = styled.div`
 const Warning = styled.div`
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
-    props.highlight ? "#f5cb42" : ""};
+    props.highlight ? "#f5cb42" : "#aaaabb"}
   margin-bottom: 20px;
 `;
 

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

@@ -22,7 +22,7 @@ const ProjectButton: React.FC<PropsType> = (props) => {
   const context = useContext(Context);
   const [showGHAModal, setShowGHAModal] = useState<boolean>(false);
 
-  const { setCurrentProject, setCurrentCluster, user } = context;
+  const { user } = context;
 
   useEffect(() => {
     document.addEventListener("mousedown", handleClickOutside);
@@ -263,4 +263,4 @@ const RefreshButton = styled.div`
     justify-content: center;
     height: 15px;
   }
-`;
+`;

+ 6 - 1
dashboard/src/main/home/sidebar/ProjectSelectionModal.tsx

@@ -104,7 +104,12 @@ const ProjectSelectionModal: React.FC<Props> = ({
 
             if (clusters_list?.length > 0) {
               setCurrentCluster(clusters_list[0]);
-              pushFiltered(props, "/apps", ["project_id"], {});
+              if (project.simplified_view_enabled) {
+                pushFiltered(props, "/onboarding/source", ["project_id"], {});
+              }
+              else {
+                pushFiltered(props, "/applications", ["project_id"], {});
+              }
             } else {
               pushFiltered(props, "/onboarding", ["project_id"], {});
             }

+ 55 - 50
dashboard/src/shared/api.tsx

@@ -11,7 +11,7 @@ import {
   CreateStackBody,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
-import { Contract } from "@porter-dev/api-contracts";
+import { Contract, EnumCloudProvider, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
 
 /**
  * Generic api call format
@@ -75,6 +75,13 @@ const getGitlabIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
 );
 
+
+const preflightCheck = baseApi<PreflightCheckRequest,
+  { id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/integrations/preflightcheck`;
+});
+
 const preflightCheckAWSUsage = baseApi<
   {
     target_arn: string;
@@ -128,7 +135,16 @@ const updateCluster = baseApi<
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
-
+const renameProject = baseApi<
+  {
+    name: string | undefined;
+  },
+  {
+    project_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/rename`;
+});
 const renameCluster = baseApi<
   {
     name: string;
@@ -272,9 +288,8 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
-    page || 1
-  }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
+    }`;
 });
 
 const createEnvironment = baseApi<
@@ -699,11 +714,9 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
@@ -734,11 +747,9 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -754,11 +765,9 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getPorterYamlContents = baseApi<
@@ -774,11 +783,9 @@ const getPorterYamlContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -814,11 +821,9 @@ const getBranchHead = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const validatePorterApp = baseApi<
@@ -837,21 +842,21 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-      name: string;
-      type: "github";
-      git_repo_id: number;
-      git_branch: string;
-      git_repo_name: string;
-      porter_yaml_path: string;
-    }
+    name: string;
+    type: "github";
+    git_repo_id: number;
+    git_branch: string;
+    git_repo_name: string;
+    porter_yaml_path: string;
+  }
   | {
-      name: string;
-      type: "docker-registry";
-      image: {
-        repository: string;
-        tag: string;
-      };
-    },
+    name: string;
+    type: "docker-registry";
+    image: {
+      repository: string;
+      tag: string;
+    };
+  },
   {
     project_id: number;
     cluster_id: number;
@@ -1762,11 +1767,9 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${
-    pathParams.cluster_id
-  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
-    pathParams.version ? "&version=" + pathParams.version : ""
-  }`;
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
+    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
+    }`;
 });
 
 const getConfigMap = baseApi<
@@ -2823,7 +2826,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -2860,6 +2863,7 @@ export default {
   overwriteAWSIntegration,
   updateCluster,
   renameCluster,
+  renameProject,
   createAzureIntegration,
   createGitlabIntegration,
   createEmailVerification,
@@ -3047,6 +3051,7 @@ export default {
   addApplicationToEnvGroup,
   removeApplicationFromEnvGroup,
   provisionDatabase,
+  preflightCheck,
   preflightCheckAWSUsage,
   getDatabases,
   getPreviousLogsForContainer,

+ 6 - 0
dashboard/src/shared/util.ts

@@ -10,3 +10,9 @@ export const isJSON = (value: string): boolean => {
 export function valueExists<T>(value: T | null | undefined): value is T {
   return !!value;
 }
+
+
+export const PREFLIGHT_MESSAGE_CONST = {
+  "apiEnabled": "APIs enabled on service account",
+  "cidrAvailability": "CIDR availability"
+}

+ 1 - 1
go.mod

@@ -79,7 +79,7 @@ require (
 	github.com/matryer/is v1.4.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.93
+	github.com/porter-dev/api-contracts v0.0.95
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1489,8 +1489,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.93 h1:RuPDe64q7D4/IvrofWRAbiWWT3v96TqCeU3kJXAxIIU=
-github.com/porter-dev/api-contracts v0.0.93/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.95 h1:at2td0mo5zEFJljAmDjBIiTZUvSqua41RU9q+jFCSNE=
+github.com/porter-dev/api-contracts v0.0.95/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 2 - 0
internal/porter_app/v2/yaml.go

@@ -112,6 +112,7 @@ type Service struct {
 	HealthCheck     *HealthCheck `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
 	AllowConcurrent bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
 	Cron            string       `yaml:"cron" validate:"excluded_unless=Type job"`
+	Private         bool         `yaml:"private" validate:"excluded_unless=Type web"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -218,6 +219,7 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 			})
 		}
 		webConfig.Domains = domains
+		webConfig.Private = service.Private
 
 		serviceProto.Config = &porterv1.Service_WebConfig{
 			WebConfig: webConfig,