Parcourir la source

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

Feroze Mohideen il y a 2 ans
Parent
commit
4658ba9f34
69 fichiers modifiés avec 2145 ajouts et 824 suppressions
  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
 bin
 openapi.yaml
 openapi.yaml
 .idea
 .idea
+portercli
 
 
 
 
 vendor
 vendor

+ 0 - 29
api/client/api.go

@@ -10,13 +10,11 @@ import (
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
 	"os"
 	"os"
-	"path/filepath"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
 	"github.com/gorilla/schema"
 	"github.com/gorilla/schema"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
-	"k8s.io/client-go/util/homedir"
 )
 )
 
 
 // Client represents the client for the Porter API
 // 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
 // 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")
 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 {
 func (c *Client) getRequest(relPath string, data interface{}, response interface{}) error {
 	vals := make(map[string][]string)
 	vals := make(map[string][]string)
 	err := schema.NewEncoder().Encode(data, vals)
 	err := schema.NewEncoder().Encode(data, vals)

+ 24 - 0
api/client/porter_app.go

@@ -357,3 +357,27 @@ func (c *Client) CreateSubdomain(
 
 
 	return resp, err
 	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 (
 import (
 	"net/http"
 	"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/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 )
 
 
 type DeletePorterAppByNameHandler struct {
 type DeletePorterAppByNameHandler struct {
@@ -30,26 +33,47 @@ func NewDeletePorterAppByNameHandler(
 }
 }
 
 
 func (c *DeletePorterAppByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 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)
 	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
 	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
 		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
 		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
 		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,
 		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
 	return routes, newPath
 }
 }

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

@@ -1395,12 +1395,39 @@ func getProjectRoutes(
 		factory.GetDecoderValidator(),
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 		factory.GetResultWriter(),
 	)
 	)
-
 	routes = append(routes, &router.Route{
 	routes = append(routes, &router.Route{
 		Endpoint: deleteAPIContractRevisionsEndpoint,
 		Endpoint: deleteAPIContractRevisionsEndpoint,
 		Handler:  deleteAPIContractRevisionHandler,
 		Handler:  deleteAPIContractRevisionHandler,
 		Router:   r,
 		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
 	return routes, newPath
 }
 }

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

@@ -248,6 +248,34 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 		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
 	// GET /api/projects/{project_id}/integrations/azure -> project_integration.NewListAzureHandler
 	listAzureEndpoint := factory.NewAPIEndpoint(
 	listAzureEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&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 used as a 'password' for the aws assume role chain to porter-manager role
 	ExternalId string `json:"external_id"`
 	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"
 	URLParamStackEventID          URLParam = "stack_event_id"
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
+	URLParamAppRevisionID         URLParam = "app_revision_id"
 )
 )
 
 
 type Path struct {
 type Path struct {

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

@@ -50,3 +50,69 @@ func RegisterCommands() (*cobra.Command, error) {
 	rootCmd.AddCommand(registerCommand_Version(cliConf))
 	rootCmd.AddCommand(registerCommand_Version(cliConf))
 	return rootCmd, nil
 	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),
 		Args:  cobra.MinimumNArgs(2),
 		Short: "Runs a command inside a connected cluster container.",
 		Short: "Runs a command inside a connected cluster container.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, appRun)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, appRun)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -70,7 +70,7 @@ func registerCommand_App(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.NoArgs,
 		Args:  cobra.NoArgs,
 		Short: "Delete any lingering ephemeral pods that were created with \"porter app run\".",
 		Short: "Delete any lingering ephemeral pods that were created with \"porter app run\".",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, appCleanup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, appCleanup)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -84,7 +84,7 @@ func registerCommand_App(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.MinimumNArgs(1),
 		Args:  cobra.MinimumNArgs(1),
 		Short: "Updates the image tag for an application.",
 		Short: "Updates the image tag for an application.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, appUpdateTag)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, appUpdateTag)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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:]
 	execArgs := args[1:]
 
 
 	color.New(color.FgGreen).Println("Attempting to run", strings.Join(execArgs, " "), "for application", args[0])
 	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)
 	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{
 	config := &AppPorterRunSharedConfig{
 		Client:    client,
 		Client:    client,
 		CLIConfig: cliConfig,
 		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])
 	namespace := fmt.Sprintf("porter-stack-%s", args[0])
 	if appTag == "" {
 	if appTag == "" {
 		appTag = "latest"
 		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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter apply -f porter.yaml"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, apply)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, apply)
 			if err != nil {
 			if err != nil {
 				if strings.Contains(err.Error(), "Forbidden") {
 				if strings.Contains(err.Error(), "Forbidden") {
 					_, _ = color.New(color.FgRed).Fprintf(os.Stderr, "You may have to update your GitHub secret token")
 					_, _ = 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
 	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)
 	project, err := client.GetProject(ctx, cliConfig.Project)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
 		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",
 		Use:   "login",
 		Short: "Authorizes a user for a given Porter server",
 		Short: "Authorizes a user for a given Porter server",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
+			cliConf = overrideConfigWithFlags(cmd, cliConf)
+
 			err := login(cmd.Context(), cliConf)
 			err := login(cmd.Context(), cliConf)
 			if err != nil {
 			if err != nil {
 				color.Red("Error logging in: %s\n", err.Error())
 				color.Red("Error logging in: %s\n", err.Error())
+				if strings.Contains(err.Error(), "Forbidden") {
+					_ = cliConf.SetToken("")
+				}
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 		},
 		},
@@ -41,6 +46,8 @@ func registerCommand_Auth(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "register",
 		Use:   "register",
 		Short: "Creates a user for a given Porter server",
 		Short: "Creates a user for a given Porter server",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
+			cliConf = overrideConfigWithFlags(cmd, cliConf)
+
 			err := register(cmd.Context(), cliConf)
 			err := register(cmd.Context(), cliConf)
 			if err != nil {
 			if err != nil {
 				color.Red("Error registering: %s\n", err.Error())
 				color.Red("Error registering: %s\n", err.Error())
@@ -53,8 +60,9 @@ func registerCommand_Auth(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "logout",
 		Use:   "logout",
 		Short: "Logs a user out of a given Porter server",
 		Short: "Logs a user out of a given Porter server",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, logout)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, logout)
 			if err != nil {
 			if err != nil {
+				_ = cliConf.SetToken("")
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 		},
 		},
@@ -88,9 +96,8 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 	user, err := client.AuthCheck(ctx)
 	user, err := client.AuthCheck(ctx)
 	if err != nil {
 	if err != nil {
 		if !strings.Contains(err.Error(), "Forbidden") {
 		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 == "" {
 	if cliConf.Token == "" {
@@ -107,7 +114,6 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 
 
 		// set the token in config
 		// set the token in config
 		err = cliConf.SetToken(token)
 		err = cliConf.SetToken(token)
-
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -121,14 +127,12 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 		}
 		}
 
 
 		user, err = client.AuthCheck(ctx)
 		user, err = client.AuthCheck(ctx)
-
 		if err != nil {
 		if err != nil {
 			color.Red("Invalid token.")
 			color.Red("Invalid token.")
 			return err
 			return err
 		}
 		}
 
 
 		_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
 		_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
-
 		return setProjectForUser(ctx, client, cliConf, user.ID)
 		return setProjectForUser(ctx, client, cliConf, user.ID)
 
 
 	}
 	}
@@ -137,7 +141,6 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-	_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
 
 
 	projID, exists, err := api.GetProjectIDFromToken(cliConf.Token)
 	projID, exists, err := api.GetProjectIDFromToken(cliConf.Token)
 	if err != nil {
 	if err != nil {
@@ -167,6 +170,8 @@ func login(ctx context.Context, cliConf config.CLIConfig) error {
 			return err
 			return err
 		}
 		}
 	}
 	}
+	_, _ = color.New(color.FgGreen).Println("Successfully logged in!")
+
 	return nil
 	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
 		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)
 		err = setProjectCluster(ctx, client, config, projects[0].ID)
-
 		if err != nil {
 		if err != nil {
 			return err
 			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 {
 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
 	var username, pw string
 
 
 	fmt.Println("Please log in with an email and password:")
 	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: ")
 	pw, err = utils.PromptPassword("Password: ")
-
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-
 	_, err = client.Login(ctx, &types.LoginUserRequest{
 	_, err = client.Login(ctx, &types.LoginUserRequest{
 		Email:    username,
 		Email:    username,
 		Password: pw,
 		Password: pw,
 	})
 	})
-
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -279,10 +281,12 @@ func register(ctx context.Context, cliConf config.CLIConfig) error {
 	return nil
 	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)
 	err := client.Logout(ctx)
 	if err != nil {
 	if err != nil {
-		return err
+		if !strings.Contains(err.Error(), "You are not logged in.") {
+			return err
+		}
 	}
 	}
 
 
 	cliConf.SetToken("")
 	cliConf.SetToken("")

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

@@ -27,7 +27,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Use:   "list",
 		Short: "Lists the linked clusters in the current project",
 		Short: "Lists the linked clusters in the current project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listClusters)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listClusters)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -40,7 +40,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Args:  cobra.ExactArgs(1),
 		Short: "Deletes the cluster with the given id",
 		Short: "Deletes the cluster with the given id",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteCluster)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteCluster)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -59,7 +59,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Use:   "list",
 		Short: "Lists the namespaces in a cluster",
 		Short: "Lists the namespaces in a cluster",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listNamespaces)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listNamespaces)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -70,7 +70,7 @@ func registerCommand_Cluster(cliConf config.CLIConfig) *cobra.Command {
 	return clusterCmd
 	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)
 	resp, err := client.ListProjectClusters(ctx, cliConf.Project)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -98,7 +98,7 @@ func listClusters(ctx context.Context, user *types.GetAuthenticatedUserResponse,
 	return nil
 	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(
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 		fmt.Sprintf(
 			`Are you sure you'd like to delete the cluster with id %s? %s `,
 			`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
 	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
 	pID := cliConf.Project
 
 
 	// get the service account based on the cluster id
 	// 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 {
 			if len(args) == 0 {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAndSetProject)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAndSetProject)
 				if err != nil {
 				if err != nil {
 					os.Exit(1)
 					os.Exit(1)
 				}
 				}
@@ -72,7 +72,7 @@ func registerCommand_Config(cliConf config.CLIConfig) *cobra.Command {
 		Short: "Saves the cluster id in the default configuration",
 		Short: "Saves the cluster id in the default configuration",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
 			if len(args) == 0 {
 			if len(args) == 0 {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAndSetCluster)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAndSetCluster)
 				if err != nil {
 				if err != nil {
 					os.Exit(1)
 					os.Exit(1)
 				}
 				}
@@ -99,7 +99,7 @@ func registerCommand_Config(cliConf config.CLIConfig) *cobra.Command {
 		Short: "Saves the registry id in the default configuration",
 		Short: "Saves the registry id in the default configuration",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
 			if len(args) == 0 {
 			if len(args) == 0 {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAndSetRegistry)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAndSetRegistry)
 				if err != nil {
 				if err != nil {
 					os.Exit(1)
 					os.Exit(1)
 				}
 				}
@@ -186,7 +186,7 @@ func printConfig() error {
 	return nil
 	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 := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
 	_ = s.Color("cyan")
 	_ = s.Color("cyan")
 	s.Suffix = " Loading list of projects"
 	s.Suffix = " Loading list of projects"
@@ -230,7 +230,7 @@ func listAndSetProject(ctx context.Context, _ *types.GetAuthenticatedUserRespons
 	return nil
 	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 := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
 	_ = s.Color("cyan")
 	_ = s.Color("cyan")
 	s.Suffix = " Loading list of clusters"
 	s.Suffix = " Loading list of clusters"
@@ -273,7 +273,7 @@ func listAndSetCluster(ctx context.Context, _ *types.GetAuthenticatedUserRespons
 	return nil
 	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 := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
 	_ = s.Color("cyan")
 	_ = s.Color("cyan")
 	s.Suffix = " Loading list of registries"
 	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",
 		Use:   "kubeconfig",
 		Short: "Uses the local kubeconfig to add a cluster",
 		Short: "Uses the local kubeconfig to add a cluster",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectKubeconfig)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectKubeconfig)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -38,7 +38,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "ecr",
 		Use:   "ecr",
 		Short: "Adds an ECR instance to a project",
 		Short: "Adds an ECR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectECR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectECR)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -49,7 +49,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "dockerhub",
 		Use:   "dockerhub",
 		Short: "Adds a Docker Hub registry integration to a project",
 		Short: "Adds a Docker Hub registry integration to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectDockerhub)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectDockerhub)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -60,7 +60,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "registry",
 		Use:   "registry",
 		Short: "Adds a custom image registry to a project",
 		Short: "Adds a custom image registry to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectRegistry)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectRegistry)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -71,7 +71,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "helm",
 		Use:   "helm",
 		Short: "Adds a custom Helm registry to a project",
 		Short: "Adds a custom Helm registry to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectHelmRepo)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectHelmRepo)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -82,7 +82,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "gcr",
 		Use:   "gcr",
 		Short: "Adds a GCR instance to a project",
 		Short: "Adds a GCR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectGCR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectGCR)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -93,7 +93,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "gar",
 		Use:   "gar",
 		Short: "Adds a GAR instance to a project",
 		Short: "Adds a GAR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectGAR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectGAR)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -105,7 +105,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "docr",
 		Use:   "docr",
 		Short: "Adds a DOCR instance to a project",
 		Short: "Adds a DOCR instance to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runConnectDOCR)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runConnectDOCR)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -138,7 +138,7 @@ func registerCommand_Connect(cliConf config.CLIConfig) *cobra.Command {
 	return connectCmd
 	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
 	isLocal := false
 
 
 	if cliConf.Driver == "local" {
 	if cliConf.Driver == "local" {
@@ -160,7 +160,7 @@ func runConnectKubeconfig(ctx context.Context, _ *types.GetAuthenticatedUserResp
 	return cliConf.SetCluster(id)
 	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(
 	regID, err := connect.ECR(
 		ctx,
 		ctx,
 		client,
 		client,
@@ -173,7 +173,7 @@ func runConnectECR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetRegistry(regID)
 	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(
 	regID, err := connect.GCR(
 		ctx,
 		ctx,
 		client,
 		client,
@@ -186,7 +186,7 @@ func runConnectGCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetRegistry(regID)
 	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(
 	regID, err := connect.GAR(
 		ctx,
 		ctx,
 		client,
 		client,
@@ -199,7 +199,7 @@ func runConnectGAR(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetRegistry(regID)
 	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(
 	regID, err := connect.DOCR(
 		ctx,
 		ctx,
 		client,
 		client,
@@ -212,7 +212,7 @@ func runConnectDOCR(ctx context.Context, _ *types.GetAuthenticatedUserResponse,
 	return cliConf.SetRegistry(regID)
 	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(
 	regID, err := connect.Dockerhub(
 		ctx,
 		ctx,
 		client,
 		client,
@@ -225,7 +225,7 @@ func runConnectDockerhub(ctx context.Context, _ *types.GetAuthenticatedUserRespo
 	return cliConf.SetRegistry(regID)
 	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(
 	regID, err := connect.Registry(
 		ctx,
 		ctx,
 		client,
 		client,
@@ -238,7 +238,7 @@ func runConnectRegistry(ctx context.Context, _ *types.GetAuthenticatedUserRespon
 	return cliConf.SetRegistry(regID)
 	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(
 	hrID, err := connect.HelmRepo(
 		ctx,
 		ctx,
 		client,
 		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"),
 			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) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, createFull)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, createFull)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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": ""}
 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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -279,7 +274,7 @@ func createFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 				return err
 				return err
 			}
 			}
 
 
-			err = config.SetDockerConfig(ctx, createAgent.Client, project.ID)
+			err = config.SetDockerConfig(ctx, createAgent.Client, cliConf.Project)
 
 
 			if err != nil {
 			if err != nil {
 				return err
 				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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter delete"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteDeployment)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteDeployment)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -49,7 +49,7 @@ deleting a configuration:
 		Short:   "Deletes an existing app",
 		Short:   "Deletes an existing app",
 		Args:    cobra.ExactArgs(1),
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteApp)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteApp)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -63,7 +63,7 @@ deleting a configuration:
 		Short:   "Deletes an existing job",
 		Short:   "Deletes an existing job",
 		Args:    cobra.ExactArgs(1),
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteJob)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteJob)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -77,7 +77,7 @@ deleting a configuration:
 		Short:   "Deletes an existing addon",
 		Short:   "Deletes an existing addon",
 		Args:    cobra.ExactArgs(1),
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteAddon)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteAddon)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -91,7 +91,7 @@ deleting a configuration:
 		Short:   "Deletes an existing helm repo",
 		Short:   "Deletes an existing helm repo",
 		Args:    cobra.ExactArgs(1),
 		Args:    cobra.ExactArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteHelm)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteHelm)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -113,14 +113,9 @@ deleting a configuration:
 	return deleteCmd
 	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 {
 		if err != nil {
 			return err
 			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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -199,14 +189,9 @@ func deleteApp(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clien
 	return nil
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -241,7 +226,7 @@ func deleteJob(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clien
 	return nil
 	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]
 	name := args[0]
 
 
 	resp, err := client.GetRelease(
 	resp, err := client.GetRelease(
@@ -270,7 +255,7 @@ func deleteAddon(ctx context.Context, _ *types.GetAuthenticatedUserResponse, cli
 	return nil
 	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]
 	name := args[0]
 
 
 	resp, err := client.ListHelmRepos(ctx, cliConf.Project)
 	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",
 		Use:   "blue-green-switch",
 		Short: "Automatically switches the traffic of a blue-green deployment once the new application is ready.",
 		Short: "Automatically switches the traffic of a blue-green deployment once the new application is ready.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, bluegreenSwitch)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, bluegreenSwitch)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -61,7 +61,7 @@ func registerCommand_Deploy(cliConf config.CLIConfig) *cobra.Command {
 	return deployCmd
 	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)
 	project, err := client.GetProject(ctx, cliConfig.Project)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("could not retrieve project from Porter API. Please contact support@porter.run")
 		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",
 		Use:   "configure",
 		Short: "Configures the host's Docker instance",
 		Short: "Configures the host's Docker instance",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, dockerConfig)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, dockerConfig)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -31,6 +31,6 @@ func registerCommand_Docker(cliConf config.CLIConfig) *cobra.Command {
 	return dockerCmd
 	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)
 	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/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	cliErrors "github.com/porter-dev/porter/cli/cmd/errors"
 	cliErrors "github.com/porter-dev/porter/cli/cmd/errors"
+	"github.com/spf13/cobra"
 )
 )
 
 
 var (
 var (
@@ -19,7 +20,12 @@ var (
 	ErrCannotConnect error = errors.New("Unable to connect to the Porter server.")
 	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{
 	client, err := api.NewClientWithConfig(ctx, api.NewClientInput{
 		BaseURL:        fmt.Sprintf("%s/api", cliConf.Host),
 		BaseURL:        fmt.Sprintf("%s/api", cliConf.Host),
 		BearerToken:    cliConf.Token,
 		BearerToken:    cliConf.Token,
@@ -47,8 +53,19 @@ func checkLoginAndRunWithConfig(ctx context.Context, cliConf config.CLIConfig, a
 		return err
 		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 {
 	if err != nil {
 		red := color.New(color.FgRed)
 		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),
 		Args:  cobra.ExactArgs(1),
 		Short: "Fetches a release.",
 		Short: "Fetches a release.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, get)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, get)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -37,7 +37,7 @@ func registerCommand_Get(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Args:  cobra.ExactArgs(1),
 		Short: "Fetches the Helm values for a release.",
 		Short: "Fetches the Helm values for a release.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, getValues)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, getValues)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -71,14 +71,9 @@ type getReleaseInfo struct {
 	RevisionID   int       `json:"revision_id" yaml:"revision_id"`
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -123,14 +118,9 @@ func get(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.
 	return nil
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}

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

@@ -17,7 +17,7 @@ func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "helm",
 		Use:   "helm",
 		Short: "Use helm to interact with a Porter cluster",
 		Short: "Use helm to interact with a Porter cluster",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runHelm)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runHelm)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -27,7 +27,7 @@ func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
 	return helmCmd
 	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")
 	_, err := exec.LookPath("helm")
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error finding helm: %w", err)
 		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"),
 			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) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, batchImageUpdate)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, batchImageUpdate)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter job wait --name job-example --namespace custom-namespace"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, waitForJob)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, waitForJob)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter job run --name job-example --namespace custom-namespace"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runJob)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runJob)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -177,14 +177,9 @@ use the --namespace flag:
 	return jobCmd
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -206,14 +201,9 @@ func batchImageUpdate(ctx context.Context, _ *types.GetAuthenticatedUserResponse
 }
 }
 
 
 // waits for a job with a given name/namespace
 // 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 {
 		if err != nil {
 			return err
 			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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -258,7 +243,7 @@ func runJob(ctx context.Context, authRes *types.GetAuthenticatedUserResponse, cl
 		},
 		},
 	}
 	}
 
 
-	err = updateAgent.UpdateImageAndValues(
+	err := updateAgent.UpdateImageAndValues(
 		ctx,
 		ctx,
 		map[string]interface{}{
 		map[string]interface{}{
 			"paused": false,
 			"paused": false,
@@ -267,8 +252,7 @@ func runJob(ctx context.Context, authRes *types.GetAuthenticatedUserResponse, cl
 		return fmt.Errorf("error running job: %w", err)
 		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 {
 	if err != nil {
 		return fmt.Errorf("error waiting for job to complete: %w", err)
 		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",
 		Use:   "kubectl",
 		Short: "Use kubectl to interact with a Porter cluster",
 		Short: "Use kubectl to interact with a Porter cluster",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, runKubectl)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runKubectl)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -26,7 +26,7 @@ func registerCommand_Kubectl(cliConf config.CLIConfig) *cobra.Command {
 	return kubectlCmd
 	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")
 	_, err := exec.LookPath("kubectl")
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error finding kubectl: %w", err)
 		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.",
 		Short: "List applications, addons or jobs.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
 			if len(args) == 0 || (args[0] == "all") {
 			if len(args) == 0 || (args[0] == "all") {
-				err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAll)
+				err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAll)
 				if err != nil {
 				if err != nil {
 					os.Exit(1)
 					os.Exit(1)
 				}
 				}
@@ -39,7 +39,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Aliases: []string{"applications", "app", "application"},
 		Aliases: []string{"applications", "app", "application"},
 		Short:   "Lists applications in a specific namespace, or across all namespaces",
 		Short:   "Lists applications in a specific namespace, or across all namespaces",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listApps)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listApps)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -51,7 +51,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Aliases: []string{"job"},
 		Aliases: []string{"job"},
 		Short:   "Lists jobs in a specific namespace, or across all namespaces",
 		Short:   "Lists jobs in a specific namespace, or across all namespaces",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listJobs)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listJobs)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -63,7 +63,7 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 		Aliases: []string{"addon"},
 		Aliases: []string{"addon"},
 		Short:   "Lists addons in a specific namespace, or across all namespaces",
 		Short:   "Lists addons in a specific namespace, or across all namespaces",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listAddons)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listAddons)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -91,21 +91,16 @@ func registerCommand_List(cliConf config.CLIConfig) *cobra.Command {
 	return listCmd
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
 		return nil
 		return nil
 	}
 	}
 
 
-	err = writeReleases(ctx, client, cliConf, "all")
+	err := writeReleases(ctx, client, cliConf, "all")
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -113,21 +108,16 @@ func listAll(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	return nil
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
 		return nil
 		return nil
 	}
 	}
 
 
-	err = writeReleases(ctx, client, cliConf, "application")
+	err := writeReleases(ctx, client, cliConf, "application")
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -135,21 +125,16 @@ func listApps(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	return nil
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
 		return nil
 		return nil
 	}
 	}
 
 
-	err = writeReleases(ctx, client, cliConf, "job")
+	err := writeReleases(ctx, client, cliConf, "job")
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -157,7 +142,7 @@ func listJobs(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	return nil
 	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")
 	err := writeReleases(ctx, client, cliConf, "addon")
 	if err != nil {
 	if err != nil {
 		return err
 		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),
 		Args:  cobra.ExactArgs(1),
 		Short: "Logs the output from a given application.",
 		Short: "Logs the output from a given application.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, logs)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, logs)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -44,7 +44,7 @@ func registerCommand_Logs(cliConf config.CLIConfig) *cobra.Command {
 	return logsCmd
 	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])
 	podsSimple, err := getPods(ctx, client, cliConfig, namespace, args[0])
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
 		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),
 		Args:  cobra.ExactArgs(1),
 		Short: "Creates a project with the authorized user as admin",
 		Short: "Creates a project with the authorized user as admin",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, createProject)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, createProject)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -41,7 +41,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Args:  cobra.ExactArgs(1),
 		Short: "Deletes the project with the given id",
 		Short: "Deletes the project with the given id",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteProject)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteProject)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -53,7 +53,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Use:   "list",
 		Short: "Lists the projects for the logged in user",
 		Short: "Lists the projects for the logged in user",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listProjects)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listProjects)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -64,7 +64,7 @@ func registerCommand_Project(cliConf config.CLIConfig) *cobra.Command {
 	return projectCmd
 	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{
 	resp, err := client.CreateProject(ctx, &types.CreateProjectRequest{
 		Name: args[0],
 		Name: args[0],
 	})
 	})
@@ -77,7 +77,7 @@ func createProject(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return cliConf.SetProject(ctx, client, resp.ID)
 	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)
 	resp, err := client.ListUserProjects(ctx)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -105,7 +105,7 @@ func listProjects(ctx context.Context, user *types.GetAuthenticatedUserResponse,
 	return nil
 	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(
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 		fmt.Sprintf(
 			`Are you sure you'd like to delete the project with id %s? %s `,
 			`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",
 		Use:   "list",
 		Short: "Lists the registries linked to a project",
 		Short: "Lists the registries linked to a project",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listRegistries)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listRegistries)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -39,7 +39,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Args:  cobra.ExactArgs(1),
 		Short: "Deletes the registry with the given id",
 		Short: "Deletes the registry with the given id",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, deleteRegistry)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, deleteRegistry)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -56,7 +56,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Use:   "list",
 		Use:   "list",
 		Short: "Lists the repositories in an image registry",
 		Short: "Lists the repositories in an image registry",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listRepos)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listRepos)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -74,7 +74,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Args:  cobra.ExactArgs(1),
 		Short: "Lists the images the specified image repository",
 		Short: "Lists the images the specified image repository",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, listImages)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, listImages)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -95,7 +95,7 @@ func registerCommand_Registry(cliConf config.CLIConfig) *cobra.Command {
 	return registryCmd
 	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
 	pID := cliConf.Project
 
 
 	// get the list of namespaces
 	// get the list of namespaces
@@ -129,7 +129,7 @@ func listRegistries(ctx context.Context, user *types.GetAuthenticatedUserRespons
 	return nil
 	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(
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 		fmt.Sprintf(
 			`Are you sure you'd like to delete the registry with id %s? %s `,
 			`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
 	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
 	pID := cliConf.Project
 	rID := cliConf.Registry
 	rID := cliConf.Registry
 
 
@@ -189,7 +189,7 @@ func listRepos(ctx context.Context, user *types.GetAuthenticatedUserResponse, cl
 	return nil
 	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
 	pID := cliConf.Project
 	rID := cliConf.Registry
 	rID := cliConf.Registry
 	repoName := args[0]
 	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),
 		Args:  cobra.MinimumNArgs(2),
 		Short: "Runs a command inside a connected cluster container.",
 		Short: "Runs a command inside a connected cluster container.",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, run)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, run)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -61,7 +61,7 @@ func registerCommand_Run(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.NoArgs,
 		Args:  cobra.NoArgs,
 		Short: "Delete any lingering ephemeral pods that were created with \"porter run\".",
 		Short: "Delete any lingering ephemeral pods that were created with \"porter run\".",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, cleanup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, cleanup)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -126,7 +126,7 @@ func registerCommand_Run(cliConf config.CLIConfig) *cobra.Command {
 	return runCmd
 	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:]
 	execArgs := args[1:]
 
 
 	color.New(color.FgGreen).Println("Running", strings.Join(execArgs, " "), "for release", args[0])
 	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)
 	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{
 	config := &PorterRunSharedConfig{
 		Client:    client,
 		Client:    client,
 		CLIConfig: cliConfig,
 		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),
 		Args:  cobra.ExactArgs(1),
 		Short: "Add an env group to a stack",
 		Short: "Add an env group to a stack",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, stackAddEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, stackAddEnvGroup)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -49,7 +49,7 @@ func registerCommand_Stack(cliConf config.CLIConfig) *cobra.Command {
 		Args:  cobra.ExactArgs(1),
 		Args:  cobra.ExactArgs(1),
 		Short: "Remove an existing env group from a stack",
 		Short: "Remove an existing env group from a stack",
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, stackRemoveEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, stackRemoveEnvGroup)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -101,14 +101,9 @@ func registerCommand_Stack(cliConf config.CLIConfig) *cobra.Command {
 	return stackCmd
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -184,14 +179,9 @@ func stackAddEnvGroup(ctx context.Context, _ *types.GetAuthenticatedUserResponse
 	return nil
 	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 {
 		if err != nil {
 			return err
 			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"),
 			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) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateFull)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateFull)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update get-env --app example-app --file .env"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateGetEnv)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateGetEnv)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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"),
 			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) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateBuild)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateBuild)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updatePush)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updatePush)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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"),
 			color.New(color.FgGreen, color.Bold).Sprintf("porter update config --app example-app --tag custom-tag"),
 		),
 		),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateUpgrade)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateUpgrade)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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.",
 		Short: "Sets the desired value of an environment variable in an env group in the form VAR=VALUE.",
 		Args:  cobra.MaximumNArgs(1),
 		Args:  cobra.MaximumNArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateSetEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateSetEnvGroup)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				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.",
 		Short: "Removes an environment variable from an env group.",
 		Args:  cobra.MinimumNArgs(1),
 		Args:  cobra.MinimumNArgs(1),
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
-			err := checkLoginAndRunWithConfig(cmd.Context(), cliConf, args, updateUnsetEnvGroup)
+			err := checkLoginAndRunWithConfig(cmd, cliConf, args, updateUnsetEnvGroup)
 			if err != nil {
 			if err != nil {
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
@@ -443,14 +443,9 @@ the image that the application uses if no --values file is specified:
 	return updateCmd
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -508,7 +503,7 @@ func updateFull(ctx context.Context, _ *types.GetAuthenticatedUserResponse, clie
 	return nil
 	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)
 	updateAgent, err := updateGetAgent(ctx, client, cliConf)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -531,14 +526,9 @@ func updateGetEnv(ctx context.Context, _ *types.GetAuthenticatedUserResponse, cl
 	return updateAgent.WriteBuildEnv(getEnvFileDest)
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -553,7 +543,7 @@ func updateBuild(ctx context.Context, _ *types.GetAuthenticatedUserResponse, cli
 	return updateBuildWithAgent(ctx, updateAgent)
 	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 app == "" {
 		if len(args) == 0 {
 		if len(args) == 0 {
 			return fmt.Errorf("please provide the docker image name")
 			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)
 	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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -650,7 +635,7 @@ func updateUpgrade(ctx context.Context, _ *types.GetAuthenticatedUserResponse, c
 	return nil
 	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 {
 	if len(normalEnvGroupVars) == 0 && len(secretEnvGroupVars) == 0 && len(args) == 0 {
 		return fmt.Errorf("please provide one or more variables to update")
 		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
 	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 {
 	if len(args) == 0 {
 		return fmt.Errorf("required variable name")
 		return fmt.Errorf("required variable name")
 	}
 	}

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

@@ -36,16 +36,19 @@ type CLIConfig struct {
 	Kubeconfig string `yaml:"kubeconfig"`
 	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:
 // InitAndLoadConfig populates the config object with the following precedence rules:
 // 1. flag
 // 1. flag
 // 2. env
 // 2. env
 // 3. config
 // 3. config
 // 4. default
 // 4. default
+// Make sure to call overrideConfigWithFlags during runtime, to ensure that the flag values are considered
 func InitAndLoadConfig() (CLIConfig, error) {
 func InitAndLoadConfig() (CLIConfig, error) {
-	return initAndLoadConfig()
-}
-
-func initAndLoadConfig() (CLIConfig, error) {
 	var config CLIConfig
 	var config CLIConfig
 
 
 	porterDir, err := getOrCreatePorterDirectoryAndConfig()
 	porterDir, err := getOrCreatePorterDirectoryAndConfig()
@@ -56,6 +59,11 @@ func initAndLoadConfig() (CLIConfig, error) {
 	viper.SetConfigType("yaml")
 	viper.SetConfigType("yaml")
 	viper.AddConfigPath(porterDir)
 	viper.AddConfigPath(porterDir)
 
 
+	err = createAndLoadPorterYaml(porterDir)
+	if err != nil {
+		return config, fmt.Errorf("unable to load porter config: %w", err)
+	}
+
 	utils.DriverFlagSet.StringVar(
 	utils.DriverFlagSet.StringVar(
 		&config.Driver,
 		&config.Driver,
 		"driver",
 		"driver",
@@ -141,11 +149,6 @@ func initAndLoadConfig() (CLIConfig, error) {
 		return config, err
 		return config, err
 	}
 	}
 
 
-	err = createAndLoadPorterYaml(porterDir)
-	if err != nil {
-		return config, fmt.Errorf("unable to load porter config: %w", err)
-	}
-
 	err = viper.Unmarshal(&config)
 	err = viper.Unmarshal(&config)
 	if err != nil {
 	if err != nil {
 		return config, fmt.Errorf("unable to unmarshal porter config: %w", err)
 		return config, fmt.Errorf("unable to unmarshal porter config: %w", err)
@@ -188,28 +191,6 @@ func createAndLoadPorterYaml(porterDir string) error {
 	return nil
 	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 {
 func (c *CLIConfig) SetDriver(driver string) error {
 	viper.Set("driver", driver)
 	viper.Set("driver", driver)
 	color.New(color.FgGreen).Printf("Set the current driver as %s\n", 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"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"strconv"
 	"strconv"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 
 
 	"github.com/cli/cli/git"
 	"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")
 		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)
 	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
 		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
 	var commitSHA string
 	if os.Getenv("PORTER_COMMIT_SHA") != "" {
 	if os.Getenv("PORTER_COMMIT_SHA") != "" {
 		commitSHA = 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 {
 	} else if commit, err := git.LastCommit(); err == nil && commit != nil {
 		commitSHA = commit.Sha
 		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 {
 	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
+		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
+
 		if commitSHA == "" {
 		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.")
 			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)
 			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 {
 		if err != nil {
 			return fmt.Errorf("error getting current app revision: %w", err)
 			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)
 			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)
 		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
 		if err != nil {
 		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)
 		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
 	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) {
 func createPorterAppDbEntryInputFromProtoAndEnv(base64AppProto string) (api.CreatePorterAppDBEntryInput, error) {
 	var input api.CreatePorterAppDBEntryInput
 	var input api.CreatePorterAppDBEntryInput
 
 

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
         "@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",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
       }
     },
     },
     "node_modules/@porter-dev/api-contracts": {
     "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": {
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
         "@bufbuild/protobuf": "^1.1.0"
       }
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     },
     "@porter-dev/api-contracts": {
     "@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": {
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
         "@bufbuild/protobuf": "^1.1.0"
       }
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
     "@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",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^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 Button from "components/porter/Button";
 import Spacer from "./porter/Spacer";
 import Spacer from "./porter/Spacer";
 import Container from "./porter/Container";
 import Container from "./porter/Container";
+import PreflightChecks from "./PreflightChecks";
+import { EnumCloudProvider, GKENetwork, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
+
 
 
 
 
 type Props = {
 type Props = {
@@ -27,11 +30,19 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
   const [isLoading, setIsLoading] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
   const [errorMessage, setErrorMessage] = useState("");
   const [errorMessage, setErrorMessage] = useState("");
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
+  const [gcpCloudProviderCredentialID, setGCPCloudProviderCredentialId] = useState<string>("")
+  const [preFlightData, setPreflightData] = useState(null)
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(true)
 
 
   useEffect(() => {
   useEffect(() => {
     setDetected(undefined);
     setDetected(undefined);
   }, []);
   }, []);
 
 
+  useEffect(() => {
+
+    gcpIntegration()
+
+  }, [detected])
   interface FailureState {
   interface FailureState {
     condition: boolean;
     condition: boolean;
     errorMessage: string;
     errorMessage: string;
@@ -48,7 +59,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
     message: string;
     message: string;
   };
   };
 
 
-  const saveCredentials = async () => {
+  const gcpIntegration = async () => {
     failureStates.forEach((failureState) => {
     failureStates.forEach((failureState) => {
       if (failureState.condition) {
       if (failureState.condition) {
         setErrorMessage(failureState.errorMessage);
         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")
         setErrorMessage("Unable to store cluster credentials. Please try again later. If the problem persists, contact support@porter.run")
         return;
         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) {
       if (err.response?.data?.error) {
         setErrorMessage(err.response?.data?.error.replace("unknown: ", ""));
         setErrorMessage(err.response?.data?.error.replace("unknown: ", ""));
       } else {
       } else {
         setErrorMessage("Something went wrong, please try again later.");
         setErrorMessage("Something went wrong, please try again later.");
       }
       }
-    };
+    }
+
+  }
+
+
+  const saveCredentials = async () => {
+
+    if (gcpCloudProviderCredentialID) {
+      proceed(gcpCloudProviderCredentialID)
+    }
+
   }
   }
 
 
   const handleLoadJSON = (serviceAccountJSONFile: string) => {
   const handleLoadJSON = (serviceAccountJSONFile: string) => {
@@ -104,13 +146,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
     setIsContinueEnabled(true);
     setIsContinueEnabled(true);
   }
   }
 
 
-  if (isLoading) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
+
 
 
   return (
   return (
     <>
     <>
@@ -133,23 +169,45 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
         isRequired={true}
         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} />
       <Spacer y={0.5} />
-
       <Button
       <Button
-        disabled={!isContinueEnabled}
+        disabled={!isContinueEnabled || preflightFailed || isLoading}
         onClick={saveCredentials}
         onClick={saveCredentials}
       >Continue</Button>
       >Continue</Button>
 
 
@@ -161,72 +219,78 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
 export default GCPCredentialsForm;
 export default GCPCredentialsForm;
 
 
 const BackButton = styled.div`
 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 {
   > 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`
 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 {
   > 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`
 const Img = styled.img`
-  height: 18px;
-  margin-right: 15px;
-`;
+      height: 18px;
+      margin-right: 15px;
+      `;
 
 
 const AppearingDiv = styled.div<{ color?: string }>`
 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`
 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,
   GKE,
   GKENetwork,
   GKENetwork,
   GKENodePool,
   GKENodePool,
-  GKENodePoolType
+  GKENodePoolType,
+  GKEPreflightValues,
+  PreflightCheckRequest
 } from "@porter-dev/api-contracts";
 } from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import { ClusterType } from "shared/types";
 import Button from "./porter/Button";
 import Button from "./porter/Button";
@@ -28,6 +30,13 @@ import Spacer from "./porter/Spacer";
 import Step from "./porter/Step";
 import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Link from "./porter/Link";
 import Text from "./porter/Text";
 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 = [
 const locationOptions = [
   { value: "us-east1", label: "us-east1" },
   { value: "us-east1", label: "us-east1" },
@@ -71,6 +80,11 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
   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) => {
   const markStepStarted = async (step: string) => {
     try {
     try {
@@ -128,6 +142,54 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
 
     return "";
     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 createCluster = async () => {
     const err = validateInputs();
     const err = validateInputs();
     if (err !== "") {
     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);
       setIsClicked(false);
       // TODO: handle different error conditions here from preflights
       // TODO: handle different error conditions here from preflights
       setErrorMessage(DEFAULT_ERROR_MESSAGE);
       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]);
   }, [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 = () => {
   const renderForm = () => {
     // Render simplified form if initial create
     // Render simplified form if initial create
     if (!props.clusterId) {
     if (!props.clusterId) {
@@ -295,17 +402,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
             setActiveValue={setRegion}
             setActiveValue={setRegion}
             label="📍 GCP location"
             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 (
   return (
     <>
     <>
       <StyledForm>{renderForm()}</StyledForm>
       <StyledForm>{renderForm()}</StyledForm>
+
+      {props.credentialId && (<>
+
+        {isLoading ?
+          <>
+            <Placeholder>
+              <Loading />
+            </Placeholder>
+            <Spacer y={1} />
+          </>
+          :
+          <>
+            {(!props.clusterId) &&
+              <>
+                <PreflightChecks preflightData={preflightData} setPreflightFailed={setPreflightFailed} />
+                <Spacer y={1} />
+              </>
+            }
+          </>
+        }
+
+      </>
+      )}
+
       <Button
       <Button
-        disabled={isDisabled()}
+        disabled={isDisabled() || isLoading || preflightFailed}
         onClick={createCluster}
         onClick={createCluster}
         status={getStatus()}
         status={getStatus()}
       >
       >
@@ -346,14 +468,14 @@ export default withRouter(GCPProvisionerSettings);
 
 
 
 
 const StyledForm = styled.div`
 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 =
 const DEFAULT_ERROR_MESSAGE =
   "An error occurred while provisioning your infrastructure. Please try again.";
   "An error occurred while provisioning your infrastructure. Please try again.";
@@ -364,3 +486,66 @@ const errorMessageToModal = (errorMessage: string) => {
       return null;
       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);
       setErrorMessage(undefined);
     } catch (err) {
     } 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
       // hacky, need to standardize error contract with backend
       setIsClicked(false);
       setIsClicked(false);
       if (errMessage.includes("elastic IP")) {
       if (errMessage.includes("elastic IP")) {
@@ -775,8 +775,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                         </ErrorInLine>
                         </ErrorInLine>
                       )}
                       )}
 
 
-                    <Spacer y={1} />
-                    {/* <Checkbox
+                      <Spacer y={1} />
+                      {/* <Checkbox
               checked={accessS3Logs}
               checked={accessS3Logs}
               disabled={isReadOnly}
               disabled={isReadOnly}
               toggleChecked={() => {
               toggleChecked={() => {
@@ -844,61 +844,61 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                     </>
                     </>
                   )}
                   )}
                   <FlexCenter>
                   <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>
                   </FlexCenter>
                   <Spacer y={1} />
                   <Spacer y={1} />
                   <FlexCenter>
                   <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."
                           "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} />
                   <Spacer y={1} />
                 </>
                 </>
@@ -964,11 +964,11 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         // disabled={isDisabled()}
         // disabled={isDisabled()}
         disabled={
         disabled={
           user?.email === "admin@porter.run" ||
           user?.email === "admin@porter.run" ||
-          currentProject?.enable_reprovision
+            currentProject?.enable_reprovision
             ? false
             ? false
             : currentCluster
             : currentCluster
-            ? true
-            : isDisabled()
+              ? true
+              : isDisabled()
         }
         }
         onClick={createCluster}
         onClick={createCluster}
         status={getStatus()}
         status={getStatus()}

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

@@ -178,6 +178,32 @@ const Placeholder = styled.div`
 const ScrollableWrapper = styled.div`
 const ScrollableWrapper = styled.div`
   overflow-y: auto;
   overflow-y: auto;
   max-height: 350px;
   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`
 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;
   font-size: ${props => props.size || 13}px;
   display: inline;
   display: inline;
   align-items: center;
   align-items: center;
+  user-select: text;
   ${props => props.additionalStyles ? props.additionalStyles : ""}
   ${props => props.additionalStyles ? props.additionalStyles : ""}
 `;
 `;

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

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

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

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

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

@@ -38,9 +38,12 @@ export const serviceValidator = z.object({
     z.object({
     z.object({
       type: z.literal("web"),
       type: z.literal("web"),
       autoscaling: autoscalingValidator.optional(),
       autoscaling: autoscalingValidator.optional(),
-      ingressEnabled: z.boolean().default(false).optional(),
       domains: domainsValidator,
       domains: domainsValidator,
       healthCheck: healthcheckValidator.optional(),
       healthCheck: healthcheckValidator.optional(),
+      private: serviceBooleanValidator.default({
+        value: false,
+        readOnly: false,
+      }),
     }),
     }),
     z.object({
     z.object({
       type: z.literal("worker"),
       type: z.literal("worker"),
@@ -76,6 +79,7 @@ export type SerializedService = {
         }[];
         }[];
         autoscaling?: SerializedAutoscaling;
         autoscaling?: SerializedAutoscaling;
         healthCheck?: SerializedHealthcheck;
         healthCheck?: SerializedHealthcheck;
+        private: boolean;
       }
       }
     | {
     | {
         type: "worker";
         type: "worker";
@@ -132,6 +136,7 @@ export function defaultSerialized({
         autoscaling: defaultAutoscaling,
         autoscaling: defaultAutoscaling,
         healthCheck: defaultHealthCheck,
         healthCheck: defaultHealthCheck,
         domains: [],
         domains: [],
+        private: false,
       },
       },
     }))
     }))
     .with("worker", () => ({
     .with("worker", () => ({
@@ -180,6 +185,7 @@ export function serializeService(service: ClientService): SerializedService {
           domains: config.domains.map((domain) => ({
           domains: config.domains.map((domain) => ({
             name: domain.name.value,
             name: domain.name.value,
           })),
           })),
+          private: config.private.value,
         },
         },
       })
       })
     )
     )
@@ -280,6 +286,10 @@ export function deserializeService({
               )?.name
               )?.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 { FormProvider, useForm } from "react-hook-form";
 import {
 import {
   PorterAppFormData,
   PorterAppFormData,
@@ -14,6 +14,17 @@ import TabSelector from "components/TabSelector";
 import { useHistory } from "react-router";
 import { useHistory } from "react-router";
 import { match } from "ts-pattern";
 import { match } from "ts-pattern";
 import Overview from "./tabs/Overview";
 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
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
 // 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 AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const history = useHistory();
   const history = useHistory();
+  const queryClient = useQueryClient();
+  const [redeployOnSave, setRedeployOnSave] = useState(false);
+
   const {
   const {
     porterApp,
     porterApp,
     latestProto,
     latestProto,
@@ -48,6 +62,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     deploymentTargetId,
     deploymentTargetId,
     servicesFromYaml,
     servicesFromYaml,
   } = useLatestRevision();
   } = useLatestRevision();
+  const { validateApp } = useAppValidation({
+    deploymentTargetID: deploymentTargetId,
+  });
 
 
   const currentTab = useMemo(() => {
   const currentTab = useMemo(() => {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
@@ -86,7 +103,94 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       source: latestSource,
       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(() => {
   useEffect(() => {
     if (servicesFromYaml) {
     if (servicesFromYaml) {
@@ -95,32 +199,78 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         source: latestSource,
         source: latestSource,
       });
       });
     }
     }
-  }, [servicesFromYaml]);
+  }, [servicesFromYaml, currentTab]);
 
 
   return (
   return (
     <FormProvider {...porterAppFormMethods}>
     <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>
     </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";
 } from "lib/porter-apps/services";
 import Error from "components/porter/Error";
 import Error from "components/porter/Error";
 import Button from "components/porter/Button";
 import Button from "components/porter/Button";
+import { useLatestRevision } from "../LatestRevisionContext";
 
 
 const Overview: React.FC = () => {
 const Overview: React.FC = () => {
   const { formState } = useFormContext<PorterAppFormData>();
   const { formState } = useFormContext<PorterAppFormData>();
+  const { porterApp } = useLatestRevision();
 
 
   const buttonStatus = useMemo(() => {
   const buttonStatus = useMemo(() => {
     if (formState.isSubmitting) {
     if (formState.isSubmitting) {
@@ -29,20 +31,24 @@ const Overview: React.FC = () => {
 
 
   return (
   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>
       <Text size={16}>Application services</Text>
       <Spacer y={0.5} />
       <Spacer y={0.5} />
       <ServiceList addNewText={"Add a new service"} />
       <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: {
       app: {
         name: "",
         name: "",
         build: {
         build: {
+          method: "pack",
           context: "./",
           context: "./",
           builder: "",
           builder: "",
           buildpacks: [],
           buildpacks: [],
-          dockerfile: "",
         },
         },
       },
       },
       source: {
       source: {
@@ -166,7 +166,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     }) => {
     }) => {
       setIsDeploying(true);
       setIsDeploying(true);
       // log analytics event that we started form submission
       // log analytics event that we started form submission
-      updateAppStep("stack-launch-complete");
+      updateAppStep({ step: "stack-launch-complete" });
 
 
       try {
       try {
         if (!currentProject?.id || !currentCluster?.id) {
         if (!currentProject?.id || !currentCluster?.id) {
@@ -202,7 +202,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         );
         );
 
 
         // log analytics event that we successfully deployed
         // log analytics event that we successfully deployed
-        updateAppStep("stack-launch-success");
+        updateAppStep({ step: "stack-launch-success" });
 
 
         if (source.type === "docker-registry") {
         if (source.type === "docker-registry") {
           history.push(`/apps/${app.name}`);
           history.push(`/apps/${app.name}`);
@@ -211,14 +211,17 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         return true;
         return true;
       } catch (err) {
       } catch (err) {
         if (axios.isAxiosError(err) && err.response?.data?.error) {
         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);
           setDeployError(err.response?.data?.error);
           return false;
           return false;
         }
         }
 
 
         const msg =
         const msg =
           "An error occurred while deploying your application. Please try again.";
           "An error occurred while deploying your application. Please try again.";
-        updateAppStep("stack-launch-failure", msg);
+        updateAppStep({ step: "stack-launch-failure", errorMessage: msg });
         setDeployError(msg);
         setDeployError(msg);
         return false;
         return false;
       } finally {
       } 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 RepositorySelector from "../build-settings/RepositorySelector";
 import BranchSelector from "../build-settings/BranchSelector";
 import BranchSelector from "../build-settings/BranchSelector";
 import BuildpackSettings from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
 import BuildpackSettings from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
+import { match } from "ts-pattern";
 
 
 type Props = {
 type Props = {
   projectId: number;
   projectId: number;
   source: SourceOptions & { type: "github" };
   source: SourceOptions & { type: "github" };
   build: BuildOptions;
   build: BuildOptions;
+  appExists?: boolean;
 };
 };
 
 
 const branchContentsSchema = z
 const branchContentsSchema = z
@@ -35,15 +37,14 @@ const branchContentsSchema = z
 type BranchContents = z.infer<typeof branchContentsSchema>;
 type BranchContents = z.infer<typeof branchContentsSchema>;
 type BuildView = "docker" | "pack";
 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 [showSettings, setShowSettings] = useState<boolean>(false);
-  const method = watch("app.build.method");
 
 
   const repoIsSet = useMemo(() => source.git_repo_name !== "", [
   const repoIsSet = useMemo(() => source.git_repo_name !== "", [
     source.git_repo_name,
     source.git_repo_name,
@@ -129,23 +130,25 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
             setValue={() => {}}
             setValue={() => {}}
             placeholder=""
             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} />
           <Spacer y={0.5} />
           <Spacer y={0.5} />
           <Text color="helper">Specify your GitHub branch.</Text>
           <Text color="helper">Specify your GitHub branch.</Text>
@@ -208,7 +211,7 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
                   setShowSettings(!showSettings);
                   setShowSettings(!showSettings);
                 }}
                 }}
               >
               >
-                {method == "docker" ? (
+                {build.method == "docker" ? (
                   <AdvancedBuildTitle>
                   <AdvancedBuildTitle>
                     <i className="material-icons dropdown">arrow_drop_down</i>
                     <i className="material-icons dropdown">arrow_drop_down</i>
                     Configure Dockerfile settings
                     Configure Dockerfile settings
@@ -224,7 +227,7 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
               <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
               <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
                 <StyledSourceBox>
                 <StyledSourceBox>
                   <Select
                   <Select
-                    value={method}
+                    value={build.method}
                     width="300px"
                     width="300px"
                     options={[
                     options={[
                       { value: "docker", label: "Docker" },
                       { value: "docker", label: "Docker" },
@@ -235,28 +238,31 @@ const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
                     }
                     }
                     label="Build method"
                     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>
                 </StyledSourceBox>
               </AnimateHeight>
               </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>
                 <Text size={16}>Pre-deploy job (optional)</Text>
                 <Spacer y={0.5} />
                 <Spacer y={0.5} />
                 <Text color="helper">
                 <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>
                 </Text>
                 <Spacer y={0.5} />
                 <Spacer y={0.5} />
                 <Services
                 <Services
@@ -584,6 +583,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   limitOne={true}
                   limitOne={true}
                   addNewText={"Add a new pre-deploy job"}
                   addNewText={"Add a new pre-deploy job"}
                   prePopulateService={Service.default("pre-deploy", "release", porterJsonWithPath?.porterJson)}
                   prePopulateService={Service.default("pre-deploy", "release", porterJsonWithPath?.porterJson)}
+                  appName={porterApp.name}
                 />
                 />
               </>,
               </>,
               <Button
               <Button

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

@@ -23,7 +23,7 @@ interface ServiceProps {
   chart?: any;
   chart?: any;
   editService: (service: Service) => void;
   editService: (service: Service) => void;
   deleteService: () => void;
   deleteService: () => void;
-  setExpandedJob: (x: string) => void;
+  setExpandedJob?: (x: string) => void;
 }
 }
 
 
 const ServiceContainer: React.FC<ServiceProps> = ({
 const ServiceContainer: React.FC<ServiceProps> = ({
@@ -35,7 +35,7 @@ const ServiceContainer: React.FC<ServiceProps> = ({
 }) => {
 }) => {
   const [height, setHeight] = React.useState<Height>("auto");
   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 [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
   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,
               RAM: 4,
             };
             };
 
 
-            data.forEach(node => {
+            // TODO: type this response
+            data.forEach((node: any) => {
               if (node.labels['porter.run/workload-kind'] == "application") {
               if (node.labels['porter.run/workload-kind'] == "application") {
                 var instanceType: string = node.labels['beta.kubernetes.io/instance-type'];
                 var instanceType: string = node.labels['beta.kubernetes.io/instance-type'];
                 const [instanceClass, instanceSize] = instanceType.split('.');
                 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()}
       {maybeRenderAddServicesButton()}
       {showAddServiceModal && (
       {showAddServiceModal && (
-        <Modal closeModal={() => setShowAddServiceModal(false)} width="500px">
+        <Modal
+          closeModal={() => {
+            setShowAddServiceModal(false)
+            setServiceName("")
+            setServiceType("web")
+          }}
+          width="500px"
+        >
           <Text size={16}>{addNewText}</Text>
           <Text size={16}>{addNewText}</Text>
           <Spacer y={1} />
           <Spacer y={1} />
           <Text color="helper">Select a service type:</Text>
           <Text color="helper">Select a service type:</Text>
@@ -168,11 +175,7 @@ const Services: React.FC<ServicesProps> = ({
               setServiceName("");
               setServiceName("");
               setServiceType("web");
               setServiceType("web");
             }}
             }}
-            disabled={
-              !isServiceNameValid(serviceName) ||
-              isServiceNameDuplicate(serviceName) ||
-              serviceName?.length > 61
-            }
+            disabled={maybeGetError() != null}
           >
           >
             <I className="material-icons">add</I> Add service
             <I className="material-icons">add</I> Add service
           </Button>
           </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 Networking: React.FC<NetworkingProps> = ({ index, service }) => {
   const { register, control, watch } = useFormContext<PorterAppFormData>();
   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 = () => {
   const getApplicationURLText = () => {
     if (service.config.domains.length !== 0) {
     if (service.config.domains.length !== 0) {
@@ -69,12 +69,12 @@ const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
       />
       />
       <Spacer y={1} />
       <Spacer y={1} />
       <Controller
       <Controller
-        name={`app.services.${index}.config.ingressEnabled`}
+        name={`app.services.${index}.config.private.value`}
         control={control}
         control={control}
         render={({ field: { value, onChange } }) => (
         render={({ field: { value, onChange } }) => (
           <Checkbox
           <Checkbox
-            checked={Boolean(value)}
-            disabled={service.config.domains.some((d) => d.name.readOnly)}
+            checked={value}
+            disabled={service.config.private.readOnly}
             toggleChecked={() => {
             toggleChecked={() => {
               onChange(!value);
               onChange(!value);
             }}
             }}
@@ -82,11 +82,11 @@ const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
               "You may only edit this field in your porter.yaml."
               "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>
           </Checkbox>
         )}
         )}
       />
       />
-      {ingressEnabled && (
+      {!privateService && (
         <>
         <>
           <Spacer y={0.5} />
           <Spacer y={0.5} />
           {getApplicationURLText()}
           {getApplicationURLText()}

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

@@ -24,7 +24,7 @@ export type Collaborator = {
   kind: string;
   kind: string;
 };
 };
 
 
-const InvitePage: React.FunctionComponent<Props> = ({}) => {
+const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const {
   const {
     currentProject,
     currentProject,
     setCurrentModal,
     setCurrentModal,
@@ -185,8 +185,11 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
   };
   };
 
 
   const validateEmail = () => {
   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,}))$/;
     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);
       setIsInvalidEmail(true);
       return;
       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.email > b.email ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
     const buildInviteLink = (token: string) => `
     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) {
     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 styled from "styled-components";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -18,9 +18,17 @@ import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
 import Metadata from "./Metadata";
 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 PropsType = RouteComponentProps & WithAuthProps & {};
-
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
 type StateType = {
 type StateType = {
   projectName: string;
   projectName: string;
   currentTab: string;
   currentTab: string;
@@ -28,62 +36,49 @@ type StateType = {
   showCostConfirmModal: boolean;
   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 =
     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
     // ? Disabled for now https://discord.com/channels/542888846271184896/1059277393031856208/1059277395913351258
     // tabOptions.push({
     // tabOptions.push({
     //   value: "billing",
     //   value: "billing",
     //   label: "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) {
       // if (this.context?.hasBillingEnabled) {
       //   tabOptions.push({
       //   tabOptions.push({
       //     value: "billing",
       //     value: "billing",
@@ -92,54 +87,97 @@ class ProjectSettings extends Component<PropsType, StateType> {
       // }
       // }
 
 
       if (currentProject?.api_tokens_enabled) {
       if (currentProject?.api_tokens_enabled) {
-        tabOptions.push({
+        tabOpts.push({
           value: "api-tokens",
           value: "api-tokens",
           label: "API Tokens",
           label: "API Tokens",
         });
         });
       }
       }
 
 
-      tabOptions.push({
+      tabOpts.push({
         value: "additional-settings",
         value: "additional-settings",
         label: "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 />;
       return <InvitePage />;
     }
     }
 
 
-    // if (
-    //   this.state.currentTab === "billing" &&
-    //   this.context?.hasBillingEnabled
-    // ) {
-    //   return <BillingPage />;
-    // }
-
-    if (this.state.currentTab === "manage-access") {
+    if (currentTab === "manage-access") {
       return <InvitePage />;
       return <InvitePage />;
     }
     }
-    else if (this.state.currentTab == "metadata") {
+    else if (currentTab == "metadata") {
       return <Metadata />
       return <Metadata />
-    } else if (this.state.currentTab === "api-tokens") {
+    } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
       return <APITokensSection />;
-    } else if (this.state.currentTab === "billing") {
+    } else if (currentTab === "billing") {
       return (
       return (
         <Placeholder>
         <Placeholder>
           <Helper>
           <Helper>
             Visit the{" "}
             Visit the{" "}
             <a
             <a
-              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+              href={`/api/projects/${context.currentProject?.id}/billing/redirect`}
             >
             >
               billing portal
               billing portal
             </a>{" "}
             </a>{" "}
@@ -150,9 +188,30 @@ class ProjectSettings extends Component<PropsType, StateType> {
     } else {
     } else {
       return (
       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>
           </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>
           <Helper>
             Permanently delete this project. This will destroy all clusters tied
             Permanently delete this project. This will destroy all clusters tied
             to this project that have been provisioned by Porter. Note that this
             to this project that have been provisioned by Porter. Note that this
@@ -162,41 +221,40 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
 
           <DeleteButton
           <DeleteButton
             onClick={() => {
             onClick={() => {
-              this.setState({ showCostConfirmModal: true });
+              setShowCostConfirmModal(true);
             }}
             }}
           >
           >
             Delete project
             Delete project
           </DeleteButton>
           </DeleteButton>
           <ProjectDeleteConsent
           <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;
 ProjectSettings.contextType = Context;
 
 
 export default withRouter(withAuth(ProjectSettings));
 export default withRouter(withAuth(ProjectSettings));
@@ -217,7 +275,7 @@ const Placeholder = styled.div`
 const Warning = styled.div`
 const Warning = styled.div`
   font-size: 13px;
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
-    props.highlight ? "#f5cb42" : ""};
+    props.highlight ? "#f5cb42" : "#aaaabb"}
   margin-bottom: 20px;
   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 context = useContext(Context);
   const [showGHAModal, setShowGHAModal] = useState<boolean>(false);
   const [showGHAModal, setShowGHAModal] = useState<boolean>(false);
 
 
-  const { setCurrentProject, setCurrentCluster, user } = context;
+  const { user } = context;
 
 
   useEffect(() => {
   useEffect(() => {
     document.addEventListener("mousedown", handleClickOutside);
     document.addEventListener("mousedown", handleClickOutside);
@@ -263,4 +263,4 @@ const RefreshButton = styled.div`
     justify-content: center;
     justify-content: center;
     height: 15px;
     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) {
             if (clusters_list?.length > 0) {
               setCurrentCluster(clusters_list[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 {
             } else {
               pushFiltered(props, "/onboarding", ["project_id"], {});
               pushFiltered(props, "/onboarding", ["project_id"], {});
             }
             }

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

@@ -11,7 +11,7 @@ import {
   CreateStackBody,
   CreateStackBody,
   SourceConfig,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
 } 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
  * Generic api call format
@@ -75,6 +75,13 @@ const getGitlabIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
   ({ 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<
 const preflightCheckAWSUsage = baseApi<
   {
   {
     target_arn: string;
     target_arn: string;
@@ -128,7 +135,16 @@ const updateCluster = baseApi<
 >("POST", (pathParams) => {
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
   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<
 const renameCluster = baseApi<
   {
   {
     name: string;
     name: string;
@@ -272,9 +288,8 @@ const getFeedEvents = baseApi<
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = 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<
 const createEnvironment = baseApi<
@@ -699,11 +714,9 @@ const detectBuildpack = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("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<
 const detectGitlabBuildpack = baseApi<
@@ -734,11 +747,9 @@ const getBranchContents = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("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<
 const getProcfileContents = baseApi<
@@ -754,11 +765,9 @@ const getProcfileContents = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("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<
 const getPorterYamlContents = baseApi<
@@ -774,11 +783,9 @@ const getPorterYamlContents = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("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<
 const parsePorterYaml = baseApi<
@@ -814,11 +821,9 @@ const getBranchHead = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("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<
 const validatePorterApp = baseApi<
@@ -837,21 +842,21 @@ const validatePorterApp = baseApi<
 
 
 const createApp = 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;
     project_id: number;
     cluster_id: number;
     cluster_id: number;
@@ -1762,11 +1767,9 @@ const getEnvGroup = baseApi<
     version?: number;
     version?: number;
   }
   }
 >("GET", (pathParams) => {
 >("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<
 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}`
     `/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<
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
   {
@@ -2860,6 +2863,7 @@ export default {
   overwriteAWSIntegration,
   overwriteAWSIntegration,
   updateCluster,
   updateCluster,
   renameCluster,
   renameCluster,
+  renameProject,
   createAzureIntegration,
   createAzureIntegration,
   createGitlabIntegration,
   createGitlabIntegration,
   createEmailVerification,
   createEmailVerification,
@@ -3047,6 +3051,7 @@ export default {
   addApplicationToEnvGroup,
   addApplicationToEnvGroup,
   removeApplicationFromEnvGroup,
   removeApplicationFromEnvGroup,
   provisionDatabase,
   provisionDatabase,
+  preflightCheck,
   preflightCheckAWSUsage,
   preflightCheckAWSUsage,
   getDatabases,
   getDatabases,
   getPreviousLogsForContainer,
   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 {
 export function valueExists<T>(value: T | null | undefined): value is T {
   return !!value;
   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/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.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/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
 	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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 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/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 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 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"`
 	HealthCheck     *HealthCheck `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
 	AllowConcurrent bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
 	AllowConcurrent bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
 	Cron            string       `yaml:"cron" 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
 // AutoScaling represents the autoscaling settings for web services
@@ -218,6 +219,7 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 			})
 			})
 		}
 		}
 		webConfig.Domains = domains
 		webConfig.Domains = domains
+		webConfig.Private = service.Private
 
 
 		serviceProto.Config = &porterv1.Service_WebConfig{
 		serviceProto.Config = &porterv1.Service_WebConfig{
 			WebConfig: webConfig,
 			WebConfig: webConfig,