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

Merge branch 'master' of github.com:porter-dev/porter into stacks-auto-refresh-built-image-workflow-file

Feroze Mohideen 2 лет назад
Родитель
Сommit
2e50ec19e4
100 измененных файлов с 6135 добавлено и 1216 удалено
  1. 9 30
      .github/PULL_REQUEST_TEMPLATE.md
  2. 1 0
      .gitignore
  3. 27 0
      Taskfile.yaml
  4. 9 2
      Tiltfile
  5. 0 29
      api/client/api.go
  6. 109 0
      api/client/porter_app.go
  7. 61 0
      api/server/handlers/porter_app/create_app.go
  8. 153 0
      api/server/handlers/porter_app/create_subdomain.go
  9. 8 22
      api/server/handlers/porter_app/current_app_revision.go
  10. 34 10
      api/server/handlers/porter_app/delete.go
  11. 20 1
      api/server/handlers/porter_app/get_logs_within_time_range.go
  12. 134 0
      api/server/handlers/porter_app/list_app_revisions.go
  13. 172 0
      api/server/handlers/porter_app/logs_apply_v2.go
  14. 20 1
      api/server/handlers/porter_app/parse.go
  15. 123 0
      api/server/handlers/porter_app/predeploy_status.go
  16. 144 0
      api/server/handlers/porter_app/stream_logs.go
  17. 14 3
      api/server/handlers/porter_app/validate.go
  18. 51 0
      api/server/handlers/project/rename.go
  19. 71 15
      api/server/handlers/project/update_onboarding_step.go
  20. 69 0
      api/server/handlers/project_integration/preflight_check.go
  21. 146 0
      api/server/router/porter_app.go
  22. 28 1
      api/server/router/project.go
  23. 28 0
      api/server/router/project_integration.go
  24. 16 5
      api/types/incident.go
  25. 5 0
      api/types/project.go
  26. 1 0
      api/types/request.go
  27. 66 0
      cli/cmd/commands/all.go
  28. 6 6
      cli/cmd/commands/app.go
  29. 4 2
      cli/cmd/commands/apply.go
  30. 17 13
      cli/cmd/commands/auth.go
  31. 6 6
      cli/cmd/commands/cluster.go
  32. 6 6
      cli/cmd/commands/config.go
  33. 16 16
      cli/cmd/commands/connect.go
  34. 5 10
      cli/cmd/commands/create.go
  35. 16 31
      cli/cmd/commands/delete.go
  36. 2 2
      cli/cmd/commands/deploy_bluegreen.go
  37. 2 2
      cli/cmd/commands/docker.go
  38. 19 2
      cli/cmd/commands/errors.go
  39. 8 18
      cli/cmd/commands/get.go
  40. 2 2
      cli/cmd/commands/helm.go
  41. 14 30
      cli/cmd/commands/job.go
  42. 2 2
      cli/cmd/commands/kubectl.go
  43. 17 32
      cli/cmd/commands/list.go
  44. 2 2
      cli/cmd/commands/logs.go
  45. 6 6
      cli/cmd/commands/project.go
  46. 8 8
      cli/cmd/commands/registry.go
  47. 4 4
      cli/cmd/commands/run.go
  48. 8 18
      cli/cmd/commands/stack.go
  49. 20 35
      cli/cmd/commands/update.go
  50. 12 31
      cli/cmd/config/config.go
  51. 99 0
      cli/cmd/v2/app_events.go
  52. 221 7
      cli/cmd/v2/apply.go
  53. 7 7
      dashboard/package-lock.json
  54. 1 1
      dashboard/package.json
  55. BIN
      dashboard/src/assets/cloud-formation-stack-complete.png
  56. 11 0
      dashboard/src/components/AzureCredentialForm.tsx
  57. 33 12
      dashboard/src/components/AzureProvisionerSettings.tsx
  58. 225 168
      dashboard/src/components/CloudFormationForm.tsx
  59. 151 77
      dashboard/src/components/GCPCredentialsForm.tsx
  60. 274 83
      dashboard/src/components/GCPProvisionerSettings.tsx
  61. 139 0
      dashboard/src/components/PreflightChecks.tsx
  62. 81 62
      dashboard/src/components/ProvisionerSettings.tsx
  63. 26 0
      dashboard/src/components/RadioFilter.tsx
  64. 27 1
      dashboard/src/components/porter/Button.tsx
  65. 1 0
      dashboard/src/components/porter/Text.tsx
  66. 12 2
      dashboard/src/lib/hooks/useAppAnalytics.ts
  67. 136 0
      dashboard/src/lib/hooks/useAppValidation.ts
  68. 44 14
      dashboard/src/lib/hooks/usePorterYaml.ts
  69. 166 26
      dashboard/src/lib/porter-apps/index.ts
  70. 32 6
      dashboard/src/lib/porter-apps/services.ts
  71. 1 1
      dashboard/src/lib/porter-apps/values.ts
  72. 20 0
      dashboard/src/lib/revisions/types.ts
  73. 5 1
      dashboard/src/main/home/Home.tsx
  74. 285 0
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  75. 187 0
      dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx
  76. 20 158
      dashboard/src/main/home/app-dashboard/app-view/AppView.tsx
  77. 221 0
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  78. 502 0
      dashboard/src/main/home/app-dashboard/app-view/RevisionsList.tsx
  79. 69 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx
  80. 41 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx
  81. 35 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/LogsTab.tsx
  82. 67 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx
  83. 140 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx
  84. 28 50
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  85. 53 47
      dashboard/src/main/home/app-dashboard/create-app/RepoSettings.tsx
  86. 2 3
      dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx
  87. 0 14
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx
  88. 4 4
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx
  89. 7 13
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx
  90. 22 5
      dashboard/src/main/home/app-dashboard/expanded-app/logs/StyledLogs.tsx
  91. 19 6
      dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts
  92. 22 26
      dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts
  93. 3 3
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  94. 4 3
      dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx
  95. 9 6
      dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx
  96. 3 1
      dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackConfigurationModal.tsx
  97. 4 2
      dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackList.tsx
  98. 11 4
      dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx
  99. 537 0
      dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx
  100. 407 0
      dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts

+ 9 - 30
.github/PULL_REQUEST_TEMPLATE.md

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

+ 1 - 0
.gitignore

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

+ 27 - 0
Taskfile.yaml

@@ -4,6 +4,10 @@ tasks:
   move-to-production:
     desc: Move the current branch to production
     cmds:
+    - cmd: git checkout master
+      silent: true
+    - cmd: git pull origin master
+      silent: true
     - cmd: git tag -d production
       ignore_error: false
       silent: true
@@ -16,6 +20,29 @@ tasks:
     - cmd: git push origin production
       ignore_error: false
       silent: true
+
+  cli-prerelease:
+    desc: Create prerelease of CLI at the provided semantic version. Call `task cli-prerelease -- v1.2.3` where v1.2.3 is the desired tag for releasing
+    cmds:
+    - task: semantic-check
+    - cmd: git fetch origin --tags 
+      silent: true
+    - cmd: git checkout master
+      silent: true
+    - cmd: git pull origin master
+      silent: true
+    - cmd: git tag {{.CLI_ARGS}}
+      silent: true
+      ignore_error: false
+    - cmd: git push origin {{.CLI_ARGS}}
+      silent: true
+      ignore_error: false
+    - cmd: echo "View your pre-release at https://github.com/porter-dev/porter/releases/tag{{ .CLI_ARGS }}"
+
+  semantic-check:
+    preconditions:
+    - sh: version={{ .CLI_ARGS }}; semantic_version_regex='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; if [[ $version =~  $semantic_version_regex ]]; then; else exit 1; fi
+      msg: must use a semantic version such as v0.1.2
  
   lint:
     desc: Run all available linters. This mimics any checks performed in Pull Request pre-merge checks

+ 9 - 2
Tiltfile

@@ -36,6 +36,11 @@ else:
     local("echo 'Be careful that you aren't connected to a staging or prod cluster' && exit 1")
     exit()
 
+ngrok_url = os.getenv("NGROK_URL")
+if ngrok_url == "":
+    local("echo 'NGROK_URL env variable is required but not set' && exit 1")
+    exit()
+
 k8s_resource(
     workload='porter-server-web',
     port_forwards=["8080:8080"],
@@ -142,5 +147,7 @@ local_resource(
     resource_deps=["postgresql"],
     labels=["porter"]
 )
-# local_resource('public-url', serve_cmd='lt --subdomain "$(whoami)" --port 8080', resource_deps=["porter-dashboard"], labels=["porter"])
-# local_resource('public-url', serve_cmd='ngrok http 8081 --log=stdout', resource_deps=["porter-dashboard"], labels=["porter"])
+local_resource('public-url', 
+serve_cmd='''
+echo " \n\n****** NGROK URL ****** \n\n" && echo https://%s && echo "\n\n********\n\n" && ngrok http 8081 --log=stdout --domain=%s''' 
+% (ngrok_url, ngrok_url), resource_deps=["porter-dashboard"], labels=["porter"])

+ 0 - 29
api/client/api.go

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

+ 109 - 0
api/client/porter_app.go

@@ -180,12 +180,14 @@ func (c *Client) ValidatePorterApp(
 	projectID, clusterID uint,
 	base64AppProto string,
 	deploymentTarget string,
+	commitSHA string,
 ) (*porter_app.ValidatePorterAppResponse, error) {
 	resp := &porter_app.ValidatePorterAppResponse{}
 
 	req := &porter_app.ValidatePorterAppRequest{
 		Base64AppProto:     base64AppProto,
 		DeploymentTargetId: deploymentTarget,
+		CommitSHA:          commitSHA,
 	}
 
 	err := c.postRequest(
@@ -272,3 +274,110 @@ func (c *Client) CurrentAppRevision(
 
 	return resp, err
 }
+
+// CreatePorterAppDBEntryInput is the input struct to CreatePorterAppDBEntry
+type CreatePorterAppDBEntryInput struct {
+	AppName         string
+	GitRepoName     string
+	GitRepoID       uint
+	GitBranch       string
+	ImageRepository string
+	PorterYamlPath  string
+	ImageTag        string
+	Local           bool
+}
+
+// CreatePorterAppDBEntry creates an entry in the porter app
+func (c *Client) CreatePorterAppDBEntry(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	inp CreatePorterAppDBEntryInput,
+) error {
+	var sourceType porter_app.SourceType
+	var image *porter_app.Image
+	if inp.Local {
+		sourceType = porter_app.SourceType_Local
+	}
+	if inp.GitRepoName != "" {
+		sourceType = porter_app.SourceType_Github
+	}
+	if inp.ImageRepository != "" {
+		sourceType = porter_app.SourceType_DockerRegistry
+		image = &porter_app.Image{
+			Repository: inp.ImageRepository,
+			Tag:        inp.ImageTag,
+		}
+	}
+	if sourceType == "" {
+		return fmt.Errorf("cannot determine source type")
+	}
+
+	req := &porter_app.CreateAppRequest{
+		Name:           inp.AppName,
+		SourceType:     sourceType,
+		GitBranch:      inp.GitBranch,
+		GitRepoName:    inp.GitRepoName,
+		GitRepoID:      inp.GitRepoID,
+		PorterYamlPath: inp.PorterYamlPath,
+		Image:          image,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/create",
+			projectID, clusterID,
+		),
+		req,
+		&types.PorterApp{},
+	)
+
+	return err
+}
+
+// CreateSubdomain returns a subdomain for a given service that point to the ingress-nginx service in the cluster
+func (c *Client) CreateSubdomain(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	appName string, serviceName string,
+) (*porter_app.CreateSubdomainResponse, error) {
+	resp := &porter_app.CreateSubdomainResponse{}
+
+	req := &porter_app.CreateSubdomainRequest{
+		ServiceName: serviceName,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/subdomain",
+			projectID, clusterID, appName,
+		),
+		req,
+		resp,
+	)
+
+	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
+}

+ 61 - 0
api/server/handlers/porter_app/create_app.go

@@ -39,6 +39,8 @@ const (
 	SourceType_Github SourceType = "github"
 	// SourceType_DockerRegistry is the source kind for an app using an image from a docker registry
 	SourceType_DockerRegistry SourceType = "docker-registry"
+	// SourceType_Local is the source kind for an app being built locally
+	SourceType_Local SourceType = "other"
 )
 
 // Image is the image used by an app with a docker registry source
@@ -80,6 +82,14 @@ type CreateDockerRegistryAppInput struct {
 	PorterAppRepository repository.PorterAppRepository
 }
 
+// CreateLocalAppInput is the input for creating an app that is built locally via the cli
+type CreateLocalAppInput struct {
+	ProjectID           uint
+	ClusterID           uint
+	Name                string
+	PorterAppRepository repository.PorterAppRepository
+}
+
 func (c *CreateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-app")
 	defer span.End()
@@ -114,6 +124,24 @@ func (c *CreateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "source-type", Value: request.SourceType})
 
+	porterAppDBEntries, err := c.Repo().PorterApp().ReadPorterAppsByProjectIDAndName(project.ID, request.Name)
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error reading porter apps by project id and name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if len(porterAppDBEntries) > 1 {
+		err := telemetry.Error(ctx, span, nil, "multiple apps with same name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if len(porterAppDBEntries) == 1 {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-app-id", Value: porterAppDBEntries[0].ID})
+		c.WriteResult(w, r, porterAppDBEntries[0].ToPorterAppType())
+		return
+	}
+
 	var porterApp *types.PorterApp
 	switch request.SourceType {
 	case SourceType_Github:
@@ -185,6 +213,21 @@ func (c *CreateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 		porterApp = app.ToPorterAppType()
+	case SourceType_Local:
+		input := CreateLocalAppInput{
+			ProjectID:           project.ID,
+			ClusterID:           cluster.ID,
+			Name:                request.Name,
+			PorterAppRepository: c.Repo().PorterApp(),
+		}
+
+		app, err := createLocalApp(ctx, input)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error creating other app")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		porterApp = app.ToPorterAppType()
 	default:
 		err := telemetry.Error(ctx, span, nil, "source type not supported")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
@@ -236,3 +279,21 @@ func createDockerRegistryApp(ctx context.Context, input CreateDockerRegistryAppI
 
 	return porterApp, nil
 }
+
+func createLocalApp(ctx context.Context, input CreateLocalAppInput) (*models.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-local-app")
+	defer span.End()
+
+	porterApp := &models.PorterApp{
+		Name:      input.Name,
+		ProjectID: input.ProjectID,
+		ClusterID: input.ClusterID,
+	}
+
+	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
+	if err != nil {
+		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
+	}
+
+	return porterApp, nil
+}

+ 153 - 0
api/server/handlers/porter_app/create_subdomain.go

@@ -0,0 +1,153 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"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/kubernetes/domain"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateSubdomainHandler handles requests to the /apps/{porter_app_name}/subdomain endpoint
+type CreateSubdomainHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewCreateSubdomainHandler returns a new CreateSubdomainHandler
+func NewCreateSubdomainHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateSubdomainHandler {
+	return &CreateSubdomainHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// CreateSubdomainRequest is the request object for the /apps/{porter_app_name}/subdomain endpoint
+type CreateSubdomainRequest struct {
+	ServiceName string `schema:"service_name"`
+}
+
+// CreateSubdomainResponse is the response object for the /apps/{porter_app_name}/subdomain endpoint
+type CreateSubdomainResponse struct {
+	// Subdomain is the url for the created subdomain
+	Subdomain string `json:"subdomain"`
+}
+
+// ServeHTTP creates a subdomain for the provided service and returns it
+func (c *CreateSubdomainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-subdomain")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+
+	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},
+	)
+
+	request := &CreateSubdomainRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.ServiceName == "" {
+		err := telemetry.Error(ctx, span, nil, "service name cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})
+
+	k8sAgent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if k8sAgent == nil {
+		err := telemetry.Error(ctx, span, nil, "agent is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	endpoint, found, err := domain.GetNGINXIngressServiceIP(k8sAgent.Clientset)
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error getting nginx ingress service ip")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if !found {
+		err := telemetry.Error(ctx, span, nil, "nginx ingress service ip not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if endpoint == "" {
+		err := telemetry.Error(ctx, span, nil, "nginx ingress service ip is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "nginx-ingress-ip", Value: endpoint})
+
+	createDomain := domain.CreateDNSRecordConfig{
+		ReleaseName: request.ServiceName,
+		RootDomain:  c.Config().ServerConf.AppRootDomain,
+		Endpoint:    endpoint,
+	}
+
+	record := createDomain.NewDNSRecordForEndpoint()
+	if record == nil {
+		err := telemetry.Error(ctx, span, nil, "dns record is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "host-name", Value: record.Hostname})
+
+	record, err = c.Repo().DNSRecord().CreateDNSRecord(record)
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error creating dns record")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if record == nil {
+		err := telemetry.Error(ctx, span, nil, "dns record is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	_record := domain.DNSRecord(*record)
+
+	if c.Config().PowerDNSClient == nil {
+		err := telemetry.Error(ctx, span, nil, "powerdns client is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	err = _record.CreateDomain(c.Config().PowerDNSClient)
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error creating domain")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	resp := &CreateSubdomainResponse{
+		Subdomain: _record.Hostname,
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 8 - 22
api/server/handlers/porter_app/current_app_revision.go

@@ -1,18 +1,17 @@
 package porter_app
 
 import (
-	"encoding/base64"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 
 	"connectrpc.com/connect"
 
-	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 
 	"github.com/google/uuid"
 
+	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,12 +45,8 @@ type LatestAppRevisionRequest struct {
 
 // LatestAppRevisionResponse is the response object for the /apps/{porter_app_name}/latest endpoint
 type LatestAppRevisionResponse struct {
-	// B64AppProto is the base64 encoded app proto definition
-	B64AppProto string `json:"b64_app_proto"`
-	// Status is the status of the revision
-	Status string `json:"status"`
-	// RevisionNumber is the revision number with respect to the app and deployment target
-	RevisionNumber uint64 `json:"revision_number"`
+	// AppRevision is the latest revision for the app
+	AppRevision porter_app.Revision `json:"app_revision"`
 }
 
 // ServeHTTP translates the request into a CurrentAppRevision grpc request, forwards to the cluster control plane, and returns the response.
@@ -134,25 +129,16 @@ func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if currentAppRevisionResp.Msg.App == nil {
-		err := telemetry.Error(ctx, span, err, "current app revision definition is nil")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	encoded, err := helpers.MarshalContractObject(ctx, currentAppRevisionResp.Msg.App)
+	appRevision := currentAppRevisionResp.Msg.AppRevision
+	encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, appRevision)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error marshalling app proto back to json")
+		err := telemetry.Error(ctx, span, err, "error encoding revision from proto")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	b64 := base64.StdEncoding.EncodeToString(encoded)
-
-	response := &LatestAppRevisionResponse{
-		B64AppProto:    b64,
-		Status:         currentAppRevisionResp.Msg.Status,
-		RevisionNumber: currentAppRevisionResp.Msg.RevisionNumber,
+	response := LatestAppRevisionResponse{
+		AppRevision: encodedRevision,
 	}
 
 	c.WriteResult(w, r, response)

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

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

+ 20 - 1
api/server/handlers/porter_app/get_logs_within_time_range.go

@@ -80,7 +80,7 @@ func (c *GetLogsWithinTimeRangeHandler) ServeHTTP(w http.ResponseWriter, r *http
 
 	var podSelector string
 	if request.ChartName == "" {
-		podSelector = request.PodSelector
+		podSelector = trimPodSelector(request.PodSelector)
 	} else {
 		// get the pod values which will be used to get the correct pod selector
 		podVals, err := porter_agent.GetPodValues(agent.Clientset, agentSvc, podValuesRequest)
@@ -153,3 +153,22 @@ func (c *GetLogsWithinTimeRangeHandler) ServeHTTP(w http.ResponseWriter, r *http
 
 	c.WriteResult(w, r, logs)
 }
+
+/**
+ * Application pods are of the format <app-name>-<service-name>-<random-4-char-string>
+ * The max length of a pod name is 63 characters
+ * Therefore if the podSelector we try to use is longer than 58 characters (63 characters minus 4 characters for the random string minus 1 character for the last hyphen), then it won't match any pods
+ * e.g. podSelector "postgres-snowflake-connector-postgres-snowflake-service-wkr-" (60 chars) won't work because the pod is actually named "postgres-snowflake-connector-postgres-snowflake-service-wkqcpz2"
+ * so we trim the podSelector to "postgres-snowflake-connector-postgres-snowflake-service-wk" (58 characters) to ensure we match the pod
+ * This is only to fix current pods; new pods will be named correctly because we imposed service name limits in https://github.com/porter-dev/porter/pull/3439
+ * */
+func trimPodSelector(podSelector string) string {
+	if !strings.HasSuffix(podSelector, ".*") {
+		return podSelector
+	}
+	podSelectorWithoutWildcard := strings.TrimSuffix(podSelector, ".*")
+	if len(podSelectorWithoutWildcard) <= 58 {
+		return podSelector
+	}
+	return fmt.Sprintf("%s.*", podSelectorWithoutWildcard[:58])
+}

+ 134 - 0
api/server/handlers/porter_app/list_app_revisions.go

@@ -0,0 +1,134 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListAppRevisionsHandler handles requests to the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListAppRevisionsHandler returns a new ListAppRevisionsHandler
+func NewListAppRevisionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListAppRevisionsHandler {
+	return &ListAppRevisionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ListAppRevisionsRequest represents the response from the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsRequest struct {
+	// The deployment target ID for the revisions
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// ListAppRevisionsResponse represents the response from the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsResponse struct {
+	AppRevisions []porter_app.Revision `json:"app_revisions"`
+}
+
+func (c *ListAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-app-revisions")
+	defer span.End()
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if app.ID == 0 {
+		err = telemetry.Error(ctx, span, nil, "app with name does not exist in project")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	request := &ListAppRevisionsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "invalid deployment target ID")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == uuid.Nil {
+		err = telemetry.Error(ctx, span, nil, "deployment target ID cannot be nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID.String()})
+
+	listAppRevisionsReq := connect.NewRequest(&porterv1.ListAppRevisionsRequest{
+		ProjectId:          int64(project.ID),
+		AppId:              int64(app.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	listAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.ListAppRevisions(r.Context(), listAppRevisionsReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing app revisions")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if listAppRevisionsResp == nil || listAppRevisionsResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list app revisions response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	appRevisions := listAppRevisionsResp.Msg.AppRevisions
+	if appRevisions == nil {
+		appRevisions = []*porterv1.AppRevision{}
+	}
+
+	res := &ListAppRevisionsResponse{
+		AppRevisions: make([]porter_app.Revision, 0),
+	}
+
+	for _, revision := range appRevisions {
+		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, revision)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting encoded revision from proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		res.AppRevisions = append(res.AppRevisions, encodedRevision)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 172 - 0
api/server/handlers/porter_app/logs_apply_v2.go

@@ -0,0 +1,172 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"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"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AppLogsHandler handles the /apps/logs endpoint
+type AppLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAppLogsHandler returns a new AppLogsHandler
+func NewAppLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppLogsHandler {
+	return &AppLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AppLogsRequest represents the accepted fields on a request to the /apps/logs endpoint
+type AppLogsRequest struct {
+	DeploymentTargetID string    `schema:"deployment_target_id"`
+	ServiceName        string    `schema:"service_name"`
+	AppName            string    `schema:"app_name"`
+	Limit              uint      `schema:"limit"`
+	StartRange         time.Time `schema:"start_range,omitempty"`
+	EndRange           time.Time `schema:"end_range,omitempty"`
+	SearchParam        string    `schema:"search_param"`
+	Direction          string    `schema:"direction"`
+}
+
+const (
+	lokiLabel_PorterAppName     = "porter_run_app_name"
+	lokiLabel_PorterServiceName = "porter_run_service_name"
+	lokiLabel_Namespace         = "namespace"
+)
+
+// ServeHTTP gets logs for a given app, service, and deployment target
+func (c *AppLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-logs")
+	defer span.End()
+	r = r.Clone(ctx)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &AppLogsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.AppName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: request.AppName})
+
+	if request.ServiceName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide service name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	if request.StartRange.IsZero() || request.EndRange.IsZero() {
+		err := telemetry.Error(ctx, span, nil, "must provide start and end range")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "start-range", Value: request.StartRange.String()},
+		telemetry.AttributeKV{Key: "end-range", Value: request.EndRange.String()},
+	)
+
+	k8sAgent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get agent"), http.StatusInternalServerError))
+		return
+	}
+
+	agentSvc, err := porter_agent.GetAgentService(k8sAgent.Clientset)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get agent service")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get agent service"), http.StatusInternalServerError))
+		return
+	}
+
+	matchLabels := map[string]string{
+		lokiLabel_Namespace:     namespace,
+		lokiLabel_PorterAppName: request.AppName,
+	}
+
+	if request.ServiceName != "all" {
+		matchLabels[lokiLabel_PorterServiceName] = request.ServiceName
+	}
+
+	logRequest := &types.LogRequest{
+		Limit:       request.Limit,
+		StartRange:  &request.StartRange,
+		EndRange:    &request.EndRange,
+		MatchLabels: matchLabels,
+		Direction:   request.Direction,
+		SearchParam: request.SearchParam,
+	}
+
+	logs, err := porter_agent.Logs(k8sAgent.Clientset, agentSvc, logRequest)
+	if err != nil {
+		_ = telemetry.Error(ctx, span, err, "unable to get logs")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("unable to get logs"), http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 20 - 1
api/server/handlers/porter_app/parse.go

@@ -546,6 +546,14 @@ func buildUmbrellaChart(application *Application, config *config.Config, project
 			// have to repair the dependency name because of https://github.com/helm/helm/issues/9214
 			if strings.HasSuffix(dep.Name, "-web") || strings.HasSuffix(dep.Name, "-wkr") || strings.HasSuffix(dep.Name, "-job") {
 				dep.Name = getChartTypeFromHelmName(dep.Name)
+				if dep.Name == "" {
+					return nil, fmt.Errorf("unable to determine type of existing dependency")
+				}
+				version, err := getLatestTemplateVersion(dep.Name, config, projectID)
+				if err != nil {
+					return nil, err
+				}
+				dep.Version = version
 			}
 			deps = append(deps, dep)
 		}
@@ -895,8 +903,19 @@ func convertHelmValuesToPorterYaml(helmValues string) (*PorterStackYAML, error)
 			return nil, fmt.Errorf("invalid service key: %s. make sure that service key ends in either -web, -wkr, or -job", k)
 		}
 
+		config := convertMap(v).(map[string]interface{})
+		var runCommand string
+
+		if config["container"] != nil {
+			containerMap := config["container"].(map[string]interface{})
+			if containerMap["command"] != nil {
+				runCommand = containerMap["command"].(string)
+			}
+		}
+
 		services[serviceName] = &Service{
-			Config: convertMap(v).(map[string]interface{}),
+			Run:    &runCommand,
+			Config: config,
 			Type:   &serviceType,
 		}
 	}

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

+ 144 - 0
api/server/handlers/porter_app/stream_logs.go

@@ -0,0 +1,144 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"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/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// StreamLogsLokiHandler handles the /apps/logs/loki endpoint
+type StreamLogsLokiHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewStreamLogsLokiHandler returns a new StreamLogsLokiHandler
+func NewStreamLogsLokiHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamLogsLokiHandler {
+	return &StreamLogsLokiHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP streams live logs for a given app, service, and deployment target
+func (c *StreamLogsLokiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-stream-app-logs")
+	defer span.End()
+	r = r.Clone(ctx)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &AppLogsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.AppName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: request.AppName})
+
+	if request.ServiceName == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide service name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	if request.StartRange.IsZero() {
+		dayAgo := time.Now().Add(-24 * time.Hour)
+		request.StartRange = dayAgo
+	}
+
+	startTime, err := request.StartRange.MarshalText()
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error marshaling start time")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "start-time", Value: string(startTime)})
+
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	labels := []string{
+		fmt.Sprintf("%s=%s", lokiLabel_Namespace, namespace),
+		fmt.Sprintf("%s=%s", lokiLabel_PorterAppName, request.AppName),
+	}
+
+	if request.ServiceName != "all" {
+		labels = append(labels, fmt.Sprintf("%s=%s", lokiLabel_PorterServiceName, request.ServiceName))
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "labels", Value: strings.Join(labels, ",")})
+
+	err = agent.StreamPorterAgentLokiLog(labels, string(startTime), request.SearchParam, 0, safeRW)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error streaming logs")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 14 - 3
api/server/handlers/porter_app/validate.go

@@ -36,11 +36,18 @@ func NewValidatePorterAppHandler(
 	}
 }
 
+// Deletions are the names of services and env variables to delete
+type Deletions struct {
+	ServiceNames     []string `json:"service_names"`
+	EnvVariableNames []string `json:"env_variable_names"`
+}
+
 // ValidatePorterAppRequest is the request object for the /apps/validate endpoint
 type ValidatePorterAppRequest struct {
-	Base64AppProto     string `json:"b64_app_proto"`
-	DeploymentTargetId string `json:"deployment_target_id"`
-	CommitSHA          string `json:"commit_sha"`
+	Base64AppProto     string    `json:"b64_app_proto"`
+	DeploymentTargetId string    `json:"deployment_target_id"`
+	CommitSHA          string    `json:"commit_sha"`
+	Deletions          Deletions `json:"deletions"`
 }
 
 // ValidatePorterAppResponse is the response object for the /apps/validate endpoint
@@ -112,6 +119,10 @@ func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		DeploymentTargetId: request.DeploymentTargetId,
 		CommitSha:          request.CommitSHA,
 		App:                appProto,
+		Deletions: &porterv1.Deletions{
+			ServiceNames:     request.Deletions.ServiceNames,
+			EnvVariableNames: request.Deletions.EnvVariableNames,
+		},
 	})
 	ccpResp, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateReq)
 	if err != nil {

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

+ 71 - 15
api/server/handlers/project/update_onboarding_step.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type UpdateOnboardingStepHandler struct {
@@ -26,26 +27,47 @@ func NewUpdateOnboardingStepHandler(
 }
 
 func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-onboarding-step")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	request := &types.UpdateOnboardingStepRequest{}
+
+	// intentionally do not return error so as not to block post-reporting steps
 	if ok := v.DecodeAndValidate(w, r, request); !ok {
-		return
+		_ = telemetry.Error(ctx, span, nil, "error decoding request")
 	}
 
 	if request.Step == "project-delete" {
-		v.Config().AnalyticsClient.Track(analytics.ProjectDeleteTrack(&analytics.ProjectCreateDeleteTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.ProjectDeleteTrack(&analytics.ProjectCreateDeleteTrackOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
+			Email:                  user.Email,
+			FirstName:              user.FirstName,
+			LastName:               user.LastName,
+			CompanyName:            user.CompanyName,
+		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking project delete")
+		}
+	}
+
+	if request.Step == "cluster-delete" {
+		err := v.Config().AnalyticsClient.Track(analytics.ClusterDeleteTrack(&analytics.ClusterDeleteTrackOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
 			LastName:               user.LastName,
 			CompanyName:            user.CompanyName,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking cluster delete")
+		}
 	}
 
 	if request.Step == "cost-consent-opened" {
-		v.Config().AnalyticsClient.Track(analytics.CostConsentOpenedTrack(&analytics.CostConsentOpenedTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.CostConsentOpenedTrack(&analytics.CostConsentOpenedTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 			Provider:            request.Provider,
 			Email:               user.Email,
@@ -53,10 +75,13 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			LastName:            user.LastName,
 			CompanyName:         user.CompanyName,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking cost consent opened")
+		}
 	}
 
 	if request.Step == "cost-consent-complete" {
-		v.Config().AnalyticsClient.Track(analytics.CostConsentCompletedTrack(&analytics.CostConsentCompletedTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.CostConsentCompletedTrack(&analytics.CostConsentCompletedTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 			Provider:            request.Provider,
 			Email:               user.Email,
@@ -64,10 +89,13 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			LastName:            user.LastName,
 			CompanyName:         user.CompanyName,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking cost consent completed")
+		}
 	}
 
 	if request.Step == "aws-account-id-complete" {
-		v.Config().AnalyticsClient.Track(analytics.AWSInputTrack(&analytics.AWSInputTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.AWSInputTrack(&analytics.AWSInputTrackOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
@@ -75,10 +103,13 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			CompanyName:            user.CompanyName,
 			AccountId:              request.AccountId,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking aws input")
+		}
 	}
 
 	if request.Step == "aws-login-redirect-success" {
-		v.Config().AnalyticsClient.Track(analytics.AWSLoginRedirectSuccess(&analytics.AWSRedirectOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.AWSLoginRedirectSuccess(&analytics.AWSRedirectOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
@@ -87,10 +118,13 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			AccountId:              request.AccountId,
 			LoginURL:               request.LoginURL,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking aws login redirect")
+		}
 	}
 
 	if request.Step == "aws-cloudformation-redirect-success" {
-		v.Config().AnalyticsClient.Track(analytics.AWSCloudformationRedirectSuccess(&analytics.AWSRedirectOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.AWSCloudformationRedirectSuccess(&analytics.AWSRedirectOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
@@ -100,10 +134,13 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			CloudformationURL:      request.CloudformationURL,
 			ExternalId:             request.ExternalId,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking aws cloudformation redirect")
+		}
 	}
 
 	if request.Step == "aws-create-integration-success" {
-		v.Config().AnalyticsClient.Track(analytics.AWSCreateIntegrationSucceeded(&analytics.AWSCreateIntegrationOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.AWSCreateIntegrationSucceeded(&analytics.AWSCreateIntegrationOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
@@ -111,10 +148,13 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			CompanyName:            user.CompanyName,
 			AccountId:              request.AccountId,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking aws create integration")
+		}
 	}
 
 	if request.Step == "aws-create-integration-failure" {
-		v.Config().AnalyticsClient.Track(analytics.AWSCreateIntegrationFailed(&analytics.AWSCreateIntegrationOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.AWSCreateIntegrationFailed(&analytics.AWSCreateIntegrationOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
@@ -124,37 +164,50 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			ErrorMessage:           request.ErrorMessage,
 			ExternalId:             request.ExternalId,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking aws create integration failure")
+		}
 	}
 
 	if request.Step == "credential-step-complete" {
-		v.Config().AnalyticsClient.Track(analytics.CredentialStepTrack(&analytics.CredentialStepTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.CredentialStepTrack(&analytics.CredentialStepTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking credential step complete")
+		}
 	}
 
 	if request.Step == "pre-provisioning-check-started" {
-		v.Config().AnalyticsClient.Track(analytics.PreProvisionCheckTrack(&analytics.PreProvisionCheckTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.PreProvisionCheckTrack(&analytics.PreProvisionCheckTrackOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
 			LastName:               user.LastName,
 			CompanyName:            user.CompanyName,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking pre-provisioning check started")
+		}
 	}
 
 	if request.Step == "provisioning-started" {
-		v.Config().AnalyticsClient.Track(analytics.ProvisioningAttemptTrack(&analytics.ProvisioningAttemptTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.ProvisioningAttemptTrack(&analytics.ProvisioningAttemptTrackOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
 			LastName:               user.LastName,
 			CompanyName:            user.CompanyName,
 			Region:                 request.Region,
+			Provider:               request.Provider,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking provisioning started")
+		}
 	}
 
 	if request.Step == "provisioning-failed" {
-		v.Config().AnalyticsClient.Track(analytics.ProvisionFailureTrack(&analytics.ProvisioningAttemptTrackOpts{
+		err := v.Config().AnalyticsClient.Track(analytics.ProvisionFailureTrack(&analytics.ProvisioningAttemptTrackOpts{
 			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
 			Email:                  user.Email,
 			FirstName:              user.FirstName,
@@ -163,6 +216,9 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			ErrorMessage:           request.ErrorMessage,
 			Region:                 request.Region,
 		}))
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error tracking provisioning failure")
+		}
 	}
 
 	v.WriteResult(w, r, user.ToUserType())

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

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

@@ -745,5 +745,151 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions -> porter_app.NewCurrentAppRevisionHandler
+	listAppRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/revisions", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listAppRevisionsHandler := porter_app.NewListAppRevisionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listAppRevisionsEndpoint,
+		Handler:  listAppRevisionsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/subdomain -> porter_app.NewCreateSubdomainHandler
+	createSubdomainEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/subdomain", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createSubdomainHandler := porter_app.NewCreateSubdomainHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createSubdomainEndpoint,
+		Handler:  createSubdomainHandler,
+		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,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/logs -> cluster.NewAppLogsHandler
+	appLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/logs",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appLogsHandler := porter_app.NewAppLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appLogsEndpoint,
+		Handler:  appLogsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/logs/loki -> namespace.NewStreamLogsLokiHandler
+	streamLogsLokiEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/logs/loki",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamLogsLokiHandler := porter_app.NewStreamLogsLokiHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: streamLogsLokiEndpoint,
+		Handler:  streamLogsLokiHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

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

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

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

+ 16 - 5
api/types/incident.go

@@ -112,6 +112,16 @@ type GetLogRequest struct {
 	Direction   string     `schema:"direction"`
 }
 
+// LogRequest is a request for logs from the porter agent. It generalizes pod selectors and namespace to just MatchLabels.
+type LogRequest struct {
+	Limit       uint              `schema:"limit"`
+	StartRange  *time.Time        `schema:"start_range"`
+	EndRange    *time.Time        `schema:"end_range"`
+	SearchParam string            `schema:"search_param"`
+	MatchLabels map[string]string `schema:"match_labels"`
+	Direction   string            `schema:"direction"`
+}
+
 // You may either provide the pod selector directly, or the chart name,
 // in which case we will attempt to find the correct pod within the timeframe.
 type GetChartLogsWithinTimeRangeRequest struct {
@@ -146,11 +156,12 @@ type LogLine struct {
 }
 
 type LogMetadata struct {
-	PodName      string `json:"pod_name"`
-	PodNamespace string `json:"pod_namespace"`
-	Revision     string `json:"revision"`
-	OutputStream string `json:"output_stream"`
-	AppName      string `json:"app_name"`
+	PodName      string            `json:"pod_name"`
+	PodNamespace string            `json:"pod_namespace"`
+	Revision     string            `json:"revision"`
+	OutputStream string            `json:"output_stream"`
+	AppName      string            `json:"app_name"`
+	RawLabels    map[string]string `json:"raw_labels"`
 }
 
 type GetLogResponse struct {

+ 5 - 0
api/types/project.go

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

+ 1 - 0
api/types/request.go

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 99 - 0
cli/cmd/v2/app_events.go

@@ -0,0 +1,99 @@
+package v2
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+func createBuildEvent(ctx context.Context, client api.Client, applicationName string, projectId, clusterId uint) (string, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-build-event")
+	defer span.End()
+
+	req := &types.CreateOrUpdatePorterAppEventRequest{
+		Status:             types.PorterAppEventStatus_Progressing,
+		Type:               types.PorterAppEventType_Build,
+		TypeExternalSource: "GITHUB",
+		Metadata:           make(map[string]interface{}),
+	}
+
+	actionRunID := os.Getenv("GITHUB_RUN_ID")
+	if actionRunID != "" {
+		arid, err := strconv.Atoi(actionRunID)
+		if err != nil {
+			fmt.Println("could not parse action run id")
+			return "", telemetry.Error(ctx, span, err, "could not parse action run id")
+		}
+		req.Metadata["action_run_id"] = arid
+
+		repoName := os.Getenv("GITHUB_REPOSITORY")
+		parsedRepoName := strings.Split(repoName, "/")
+		if len(parsedRepoName) != 2 {
+			fmt.Println("repo name is not in the format owner/name")
+			return "", telemetry.Error(ctx, span, nil, "repo name is not in the format owner/name")
+		}
+		req.Metadata["repo"] = parsedRepoName[1]
+
+		repoOwnerAccountID := os.Getenv("GITHUB_REPOSITORY_OWNER_ID")
+		if repoOwnerAccountID != "" {
+			arid, err := strconv.Atoi(repoOwnerAccountID)
+			if err != nil {
+				fmt.Println("could not parse repo owner account id")
+				return "", telemetry.Error(ctx, span, err, "could not parse repo owner account id")
+			}
+			req.Metadata["github_account_id"] = arid
+		}
+	}
+
+	event, err := client.CreateOrUpdatePorterAppEvent(ctx, projectId, clusterId, applicationName, req)
+	if err != nil {
+		fmt.Println("could not create build event")
+		return "", telemetry.Error(ctx, span, err, "could not create build event")
+	}
+
+	return event.ID, nil
+}
+
+func createPredeployEvent(ctx context.Context, client api.Client, applicationName string, projectId, clusterId uint, createdAt time.Time) (string, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-predeploy-event")
+	defer span.End()
+
+	req := &types.CreateOrUpdatePorterAppEventRequest{
+		Status:   types.PorterAppEventStatus_Progressing,
+		Type:     types.PorterAppEventType_PreDeploy,
+		Metadata: make(map[string]interface{}),
+	}
+	req.Metadata["start_time"] = createdAt
+
+	event, err := client.CreateOrUpdatePorterAppEvent(ctx, projectId, clusterId, applicationName, req)
+	if err != nil {
+		return "", telemetry.Error(ctx, span, err, "could not create predeploy event")
+	}
+
+	return event.ID, nil
+}
+
+func updateExistingEvent(ctx context.Context, client api.Client, applicationName string, projectId, clusterId uint, eventID string, status types.PorterAppEventStatus, metadata map[string]interface{}) error {
+	ctx, span := telemetry.NewSpan(ctx, "update-existing-event")
+	defer span.End()
+
+	req := &types.CreateOrUpdatePorterAppEventRequest{
+		ID:       eventID,
+		Status:   status,
+		Metadata: metadata,
+	}
+
+	_, err := client.CreateOrUpdatePorterAppEvent(ctx, projectId, clusterId, applicationName, req)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "could not update existing app event")
+	}
+
+	return nil
+}

+ 221 - 7
cli/cmd/v2/apply.go

@@ -7,6 +7,13 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strconv"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
+	"github.com/porter-dev/porter/api/types"
+
+	"github.com/cli/cli/git"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/api-contracts/generated/go/helpers"
@@ -37,6 +44,13 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return errors.New("b64 app proto is empty")
 	}
 
+	appName, err := appNameFromB64AppProto(parseResp.B64AppProto)
+	if err != nil {
+		return fmt.Errorf("error getting app name from b64 app proto: %w", err)
+	}
+
+	color.New(color.FgGreen).Printf("Successfully parsed Porter YAML: applying app \"%s\"\n", appName) // nolint:errcheck,gosec
+
 	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
 	if err != nil {
 		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
@@ -46,7 +60,16 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return errors.New("deployment target id is empty")
 	}
 
-	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, parseResp.B64AppProto, targetResp.DeploymentTargetID)
+	var commitSHA string
+	if os.Getenv("PORTER_COMMIT_SHA") != "" {
+		commitSHA = os.Getenv("PORTER_COMMIT_SHA")
+	} else if os.Getenv("GITHUB_SHA") != "" {
+		commitSHA = os.Getenv("GITHUB_SHA")
+	} else if commit, err := git.LastCommit(); err == nil && commit != nil {
+		commitSHA = commit.Sha
+	}
+
+	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, parseResp.B64AppProto, targetResp.DeploymentTargetID, commitSHA)
 	if err != nil {
 		return fmt.Errorf("error calling validate endpoint: %w", err)
 	}
@@ -56,7 +79,22 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	}
 	base64AppProto := validateResp.ValidatedBase64AppProto
 
-	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, validateResp.ValidatedBase64AppProto, targetResp.DeploymentTargetID, "")
+	createPorterAppDBEntryInp, err := createPorterAppDbEntryInputFromProtoAndEnv(validateResp.ValidatedBase64AppProto)
+	if err != nil {
+		return fmt.Errorf("error creating porter app db entry input from proto: %w", err)
+	}
+
+	err = client.CreatePorterAppDBEntry(ctx, cliConf.Project, cliConf.Cluster, createPorterAppDBEntryInp)
+	if err != nil {
+		return fmt.Errorf("error creating porter app db entry: %w", err)
+	}
+
+	base64AppProtoWithSubdomains, err := addPorterSubdomainsIfNecessary(ctx, client, cliConf.Project, cliConf.Cluster, base64AppProto)
+	if err != nil {
+		return fmt.Errorf("error creating subdomains: %w", err)
+	}
+
+	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, base64AppProtoWithSubdomains, targetResp.DeploymentTargetID, "")
 	if err != nil {
 		return fmt.Errorf("error calling apply endpoint: %w", err)
 	}
@@ -66,21 +104,34 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	}
 
 	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
+		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
+
+		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster)
+
+		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.")
+		}
+
 		buildSettings, err := buildSettingsFromBase64AppProto(base64AppProto)
 		if err != nil {
 			return fmt.Errorf("error building settings from base64 app proto: %w", err)
 		}
 
-		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, buildSettings.AppName, targetResp.DeploymentTargetID)
+		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, targetResp.DeploymentTargetID)
 		if err != nil {
 			return fmt.Errorf("error getting current app revision: %w", err)
 		}
 
-		if currentAppRevisionResp.B64AppProto == "" {
+		if currentAppRevisionResp == nil {
+			return errors.New("current app revision is nil")
+		}
+
+		appRevision := currentAppRevisionResp.AppRevision
+		if appRevision.B64AppProto == "" {
 			return errors.New("current app revision b64 app proto is empty")
 		}
 
-		currentImageTag, err := imageTagFromBase64AppProto(currentAppRevisionResp.B64AppProto)
+		currentImageTag, err := imageTagFromBase64AppProto(appRevision.B64AppProto)
 		if err != nil {
 			return fmt.Errorf("error getting image tag from current app revision: %w", err)
 		}
@@ -90,12 +141,55 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 
 		err = build(ctx, client, buildSettings)
 		if err != nil {
+			_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, eventID, types.PorterAppEventStatus_Failed, nil)
 			return fmt.Errorf("error building app: %w", err)
 		}
 
+		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.ImageTag) // nolint:errcheck,gosec
+
+		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, eventID, types.PorterAppEventStatus_Success, nil)
+
+		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()
+		eventID, _ := createPredeployEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, now)
+
+		eventStatus := types.PorterAppEventStatus_Success
+		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 {
+				eventStatus = types.PorterAppEventStatus_Failed
+				break
+			}
+			if predeployStatusResp.Status == porter_app.PredeployStatus_Successful {
+				break
+			}
+
+			time.Sleep(checkPredeployFrequency)
+		}
+
+		metadata := make(map[string]interface{})
+		metadata["end_time"] = time.Now().UTC()
+		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, eventID, eventStatus, metadata)
+
 		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
 		if err != nil {
-			return fmt.Errorf("error calling apply endpoint after build: %w", err)
+			return fmt.Errorf("apply error post-predeploy: %w", err)
 		}
 	}
 
@@ -103,10 +197,130 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
 	}
 
-	color.New(color.FgGreen).Printf("Successfully applied Porter YAML as revision %v, next action: %v\n", applyResp.AppRevisionId, applyResp.CLIAction) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", applyResp.AppRevisionId, appName) // nolint:errcheck,gosec
 	return nil
 }
 
+// checkPredeployTimeout is the maximum amount of time the CLI will wait for a predeploy to complete before calling apply again
+const checkPredeployTimeout = 60 * time.Minute
+
+// checkPredeployFrequency is the frequency at which the CLI will check the status of a predeploy
+const checkPredeployFrequency = 10 * time.Second
+
+func appNameFromB64AppProto(base64AppProto string) (string, error) {
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return "", fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return "", fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	if app.Name == "" {
+		return "", fmt.Errorf("app does not contain name")
+	}
+	return app.Name, nil
+}
+
+func createPorterAppDbEntryInputFromProtoAndEnv(base64AppProto string) (api.CreatePorterAppDBEntryInput, error) {
+	var input api.CreatePorterAppDBEntryInput
+
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return input, fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return input, fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	if app.Name == "" {
+		return input, fmt.Errorf("app does not contain name")
+	}
+	input.AppName = app.Name
+
+	if app.Build != nil {
+		if os.Getenv("GITHUB_REPOSITORY_ID") == "" {
+			input.Local = true
+			return input, nil
+		}
+		gitRepoId, err := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ID"))
+		if err != nil {
+			return input, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
+		}
+		input.GitRepoID = uint(gitRepoId)
+		input.GitRepoName = os.Getenv("GITHUB_REPOSITORY")
+		input.GitBranch = os.Getenv("GITHUB_REF_NAME")
+		input.PorterYamlPath = "porter.yaml"
+		return input, nil
+	}
+
+	if app.Image != nil {
+		input.ImageRepository = app.Image.Repository
+		input.ImageTag = app.Image.Tag
+		return input, nil
+	}
+
+	return input, fmt.Errorf("app does not contain build or image settings")
+}
+
+func addPorterSubdomainsIfNecessary(ctx context.Context, client api.Client, project uint, cluster uint, base64AppProto string) (string, error) {
+	var editedB64AppProto string
+
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return editedB64AppProto, fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return editedB64AppProto, fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	for serviceName, service := range app.Services {
+		if service.Type == porterv1.ServiceType_SERVICE_TYPE_WEB {
+			if service.GetWebConfig() == nil {
+				return editedB64AppProto, fmt.Errorf("web service %s does not contain web config", serviceName)
+			}
+
+			webConfig := service.GetWebConfig()
+
+			if !webConfig.Private && len(webConfig.Domains) == 0 {
+				color.New(color.FgYellow).Printf("Service %s is public but does not contain any domains, creating Porter domain\n", serviceName) // nolint:errcheck,gosec
+				domain, err := client.CreateSubdomain(ctx, project, cluster, app.Name, serviceName)
+				if err != nil {
+					return editedB64AppProto, fmt.Errorf("error creating subdomain: %w", err)
+				}
+
+				if domain.Subdomain == "" {
+					return editedB64AppProto, errors.New("response subdomain is empty")
+				}
+
+				webConfig.Domains = []*porterv1.Domain{
+					{Name: domain.Subdomain},
+				}
+
+				service.Config = &porterv1.Service_WebConfig{WebConfig: webConfig}
+			}
+		}
+	}
+
+	marshalled, err := helpers.MarshalContractObject(ctx, app)
+	if err != nil {
+		return editedB64AppProto, fmt.Errorf("unable to marshal app back to json: %w", err)
+	}
+
+	editedB64AppProto = base64.StdEncoding.EncodeToString(marshalled)
+
+	return editedB64AppProto, nil
+}
+
 func buildSettingsFromBase64AppProto(base64AppProto string) (buildInput, error) {
 	var buildSettings buildInput
 

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.86",
+        "@porter-dev/api-contracts": "^0.0.95",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.86",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.86.tgz",
-      "integrity": "sha512-nihcZuR+FsbbBBr+7gIsvpxSJvRS+eGurSAElytN5LIimL8TbYN4T+7EDAA0sDvRp95qF7B+vdezPbHTsWRkcA==",
+      "version": "0.0.95",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
+      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.86",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.86.tgz",
-      "integrity": "sha512-nihcZuR+FsbbBBr+7gIsvpxSJvRS+eGurSAElytN5LIimL8TbYN4T+7EDAA0sDvRp95qF7B+vdezPbHTsWRkcA==",
+      "version": "0.0.95",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
+      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.86",
+    "@porter-dev/api-contracts": "^0.0.95",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

BIN
dashboard/src/assets/cloud-formation-stack-complete.png


+ 11 - 0
dashboard/src/components/AzureCredentialForm.tsx

@@ -60,6 +60,17 @@ const AzureCredentialForm: React.FC<Props> = ({ goBack, proceed }) => {
             id: currentProject.id,
           });
         const azureIntegrationId = azureIntegrationResponse.data.cloud_provider_credentials_id;
+        try {
+          if (currentProject?.id != null) {
+            api.inviteAdmin(
+              "<token>",
+              {},
+              { project_id: currentProject?.id }
+            );
+          }
+        } catch (err) {
+          console.log(err);
+        }
         proceed(azureIntegrationId)
       } catch (err) {
         if (err.response?.data?.error) {

+ 33 - 12
dashboard/src/components/AzureProvisionerSettings.tsx

@@ -37,7 +37,7 @@ const machineTypeOptions = [
   { value: "Standard_A4_v2", label: "Standard_A4_v2" },
 ];
 
-const clusterVersionOptions = [{ value: "v1.26.6", label: "v1.26.6" },{ value: "v1.24.9", label: "v1.24.9" }];
+const clusterVersionOptions = [{ value: "v1.26.6", label: "v1.26.6" }, { value: "v1.24.9", label: "v1.24.9" }];
 
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
@@ -71,9 +71,9 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
 
-  const markStepStarted = async (step: string) => {
+  const markStepStarted = async (step: string, region: string) => {
     try {
-      await api.updateOnboardingStep("<token>", { step }, {
+      await api.updateOnboardingStep("<token>", { step, region, provider: "azure" }, {
         project_id: currentProject.id,
       });
     } catch (err) {
@@ -102,15 +102,15 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
 
   const isDisabled = () => {
     return (
-      !user.email.endsWith("porter.run") &&
-      ((!clusterName && true) ||
-        (isReadOnly && props.provisionerError === "") ||
-        props.provisionerError === "" ||
-        currentCluster?.status === "UPDATING" ||
-        isClicked)
-    );
+      (!clusterName && true)
+      || (isReadOnly && props.provisionerError === "")
+      || currentCluster?.status === "UPDATING"
+      || isClicked
+      || (!currentProject?.enable_reprovision && props.clusterId)
+    )
   };
 
+
   const validateInputs = (): string => {
     if (!clusterName) {
       return "Cluster name is required";
@@ -128,7 +128,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
       return "VPC CIDR range must be in the format of [0-255].[0-255].0.0/16";
     }
     if (clusterVersion == "v1.24.9") {
-        return "Cluster version v1.24.9 is no longer supported";
+      return "Cluster version v1.24.9 is no longer supported";
     }
 
     return "";
@@ -193,7 +193,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
       setErrorDetails("")
 
       if (!props.clusterId) {
-        markStepStarted("provisioning-started");
+        markStepStarted("provisioning-started", azureLocation);
       }
 
       const res = await api.createContract("<token>", data, {
@@ -381,6 +381,27 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
       >
         Provision
       </Button>
+      {
+        (!currentProject?.enable_reprovision && currentCluster) &&
+        <>
+          <Spacer y={1} />
+          <Text>Updates to the cluster are disabled on this project. Enable re-provisioning by contacting <a href="mailto:support@porter.run">Porter Support</a>.</Text>
+        </>
+      }
+      {user.isPorterUser &&
+        <>
+
+          <Spacer y={1} />
+          <Text color="yellow">Visible to Admin Only</Text>
+          <Button
+            color="red"
+            onClick={createCluster}
+            status={getStatus()}
+          >
+            Override Provision
+          </Button>
+        </>
+      }
     </>
   );
 };

+ 225 - 168
dashboard/src/components/CloudFormationForm.tsx

@@ -1,9 +1,10 @@
-import React, { useState, useContext } from "react";
+import React, { useState, useContext, useMemo } from "react";
 import styled from "styled-components";
 import { v4 as uuidv4 } from 'uuid';
 
 import api from "shared/api";
 import aws from "assets/aws.png";
+import cloudformationStatus from "assets/cloud-formation-stack-complete.png";
 
 import { Context } from "shared/Context";
 
@@ -11,11 +12,13 @@ import Text from "./porter/Text";
 import Spacer from "./porter/Spacer";
 import Input from "./porter/Input";
 import Button from "./porter/Button";
-import Error from "./porter/Error";
-import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Container from "./porter/Container";
-import VerticalSteps from "./porter/VerticalSteps";
+import Step from "./porter/Step";
+import { Box, Step as MuiStep, StepContent, StepLabel, Stepper, ThemeProvider, Typography, createTheme } from "@material-ui/core";
+import { useQuery } from "@tanstack/react-query";
+import Modal from "./porter/Modal";
+import theme from "shared/themes/midnight";
 
 type Props = {
   goBack: () => void;
@@ -23,17 +26,45 @@ type Props = {
   switchToCredentialFlow: () => void;
 };
 
+const stepperTheme = createTheme({
+  palette: {
+    background: {
+      paper: 'none',
+    },
+    text: {
+      primary: '#DFDFE1',
+      secondary: '#aaaabb',
+    },
+    action: {
+      active: '#001E3C',
+    },
+  },
+  typography: {
+    fontFamily: "Work Sans, sans-serif",
+  },
+  overrides: {
+    MuiStepIcon: {
+      root: {
+        '&$completed': {
+          color: theme.button,
+        },
+        '&$active': {
+          color: theme.button,
+        },
+      },
+    },
+  },
+});
+
 const CloudFormationForm: React.FC<Props> = ({
   goBack,
   proceed,
   switchToCredentialFlow
 }) => {
-  const [hasSentAWSNotif, setHasSentAWSNotif] = useState(false);
-  const [roleStatus, setRoleStatus] = useState("");
-  const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
   const [AWSAccountID, setAWSAccountID] = useState("");
-  const [AWSAccountIDInputError, setAWSAccountIDInputError] = useState<string | undefined>(undefined);
-  const [currentStep, setCurrentStep] = useState<number>(1);
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [hasClickedCloudformationButton, setHasClickedCloudformationButton] = useState(false);
+  const [showNeedHelpModal, setShowNeedHelpModal] = useState(false);
 
   const { currentProject } = useContext(Context);
   const markStepStarted = async (
@@ -55,6 +86,9 @@ const CloudFormationForm: React.FC<Props> = ({
       }
   ) => {
     try {
+      if (currentProject == null) {
+        return;
+      }
       await api.updateOnboardingStep("<token>", { step, account_id, cloudformation_url, error_message, login_url, external_id }, {
         project_id: currentProject.id,
       });
@@ -63,51 +97,83 @@ const CloudFormationForm: React.FC<Props> = ({
     }
   };
 
-  const getAccountIdInputError = (accountId: string) => {
+  const { data: canProceed } = useQuery(
+    ["createAWSIntegration", currentStep, hasClickedCloudformationButton, AWSAccountID],
+    async () => {
+      if (currentProject == null) {
+        return false;
+      };
+      let externalId = getExternalId();
+      let targetARN = `arn:aws:iam::${AWSAccountID}:role/porter-manager`
+      await api
+        .createAWSIntegration(
+          "<token>",
+          {
+            aws_target_arn: targetARN,
+            aws_external_id: externalId,
+          },
+          {
+            id: currentProject.id,
+          }
+        );
+      return true;
+    },
+    {
+      enabled: currentStep === 2,
+      retry: (failureCount, err) => {
+        // if they've waited over 35 seconds notify us on slack. Cloudformation stack should only take around 20-25 seconds to create
+        if (failureCount === 7 && hasClickedCloudformationButton) {
+          reportFailedCreateAWSIntegration();
+        }
+        return true;
+      },
+      retryDelay: 5000,
+    }
+  )
+
+  const awsAccountIdInputError = useMemo(() => {
     const regex = /^\d{12}$/;
-    if (accountId === "") {
+    if (AWSAccountID.trim().length === 0) {
       return undefined;
-    } else if (!regex.test(accountId)) {
+    } else if (!regex.test(AWSAccountID)) {
       return 'A valid AWS Account ID must be a 12-digit number.';
     }
     return undefined;
-  };
+  }, [AWSAccountID]);
 
   const handleAWSAccountIDChange = (accountId: string) => {
     setAWSAccountID(accountId);
+    setHasClickedCloudformationButton(false);
     if (accountId === "open-sesame") {
       switchToCredentialFlow();
     }
-    // handle case where user resets the input to empty
-    if (accountId.trim().length === 0) {
-      setCurrentStep(1);
-      setAWSAccountIDInputError(undefined);
-      return;
-    }
-    const accountIdInputError = getAccountIdInputError(accountId);
-    if (accountIdInputError == null) {
-      setCurrentStep(2);
-      if (!hasSentAWSNotif) {
-        setHasSentAWSNotif(true);
-        markStepStarted({ step: "aws-account-id-complete", account_id: accountId });
-        if (currentProject != null) {
-          try {
-            api.inviteAdmin(
-              "<token>",
-              {},
-              { project_id: currentProject.id }
-            );
-          } catch (err) {
-            console.log(err);
-          }
-        }
-      }
-    } else {
-      setCurrentStep(1);
-    }
-    setAWSAccountIDInputError(accountIdInputError);
   };
 
+  const handleContinueWithAWSAccountId = () => {
+    setCurrentStep(2);
+    markStepStarted({ step: "aws-account-id-complete", account_id: AWSAccountID });
+  }
+
+  const handleProceedToProvisionStep = () => {
+    try {
+      if (currentProject != null) {
+        api.inviteAdmin(
+          "<token>",
+          {},
+          { project_id: currentProject.id }
+        );
+      };
+    } catch (err) {
+      console.log(err);
+    }
+    markStepStarted({ step: "aws-create-integration-success", account_id: AWSAccountID })
+    proceed(`arn:aws:iam::${AWSAccountID}:role/porter-manager`);
+  }
+
+  const reportFailedCreateAWSIntegration = () => {
+    markStepStarted({ step: "aws-create-integration-failed", account_id: AWSAccountID, external_id: getExternalId() })
+  }
+
   const getExternalId = () => {
     let externalId = localStorage.getItem(AWSAccountID)
     if (!externalId) {
@@ -118,89 +184,54 @@ const CloudFormationForm: React.FC<Props> = ({
     return externalId
   }
 
-  const checkIfRoleExists = async () => {
-    let externalId = getExternalId();
-    let targetARN = `arn:aws:iam::${AWSAccountID}:role/porter-manager`
-
-    setRoleStatus("loading");
-    setErrorMessage(undefined)
-    try {
-      if (currentProject == null) {
-        setErrorMessage("Could not find current project.")
-        return;
-      };
-      await api
-        .createAWSIntegration(
-          "<token>",
-          {
-            aws_target_arn: targetARN,
-            aws_external_id: externalId,
-          },
-          {
-            id: currentProject.id,
-          }
-        );
-      setRoleStatus("successful")
-      markStepStarted({ step: "aws-create-integration-success", account_id: AWSAccountID })
-      proceed(targetARN);
-    } catch (err) {
-      setRoleStatus("");
-      setErrorMessage("Porter could not access your AWS account. Please make sure you have granted permissions and try again.")
-      markStepStarted({
-        step: "aws-create-integration-failure",
-        account_id: AWSAccountID,
-        error_message: err?.response?.data?.error ??
-          err?.toString() ?? "unable to determine error - check honeycomb",
-        external_id: externalId,
-      })
-    }
-  };
-
-  const directToAWSLoginAndProceedStep = () => {
+  const directToAWSLogin = () => {
     const login_url = `https://signin.aws.amazon.com/console`;
-    markStepStarted({ step: "aws-login-redirect-success", login_url })
-    window.open(login_url, "_blank")
+    markStepStarted({ step: "aws-login-redirect-success", login_url });
+    window.open(login_url, "_blank");
   }
 
-  const directToCloudFormationAndProceedStep = () => {
+  const directToCloudFormation = () => {
     const externalId = getExternalId();
     let trustArn = process.env.TRUST_ARN ? process.env.TRUST_ARN : "arn:aws:iam::108458755588:role/CAPIManagement";
     const cloudformation_url = `https://console.aws.amazon.com/cloudformation/home?#/stacks/create/review?templateURL=https://porter-role.s3.us-east-2.amazonaws.com/cloudformation-policy.json&stackName=PorterRole&param_ExternalIdParameter=${externalId}&param_TrustArnParameter=${trustArn}`
     markStepStarted({ step: "aws-cloudformation-redirect-success", account_id: AWSAccountID, cloudformation_url, external_id: externalId })
-    setCurrentStep(3);
     window.open(cloudformation_url, "_blank")
+    setHasClickedCloudformationButton(true);
   }
 
   const renderContent = () => {
     return (
       <>
-        <Text>Grant Porter permissions to create infrastructure in your AWS account by following 4 simple steps.</Text>
+        <Text>Grant Porter permissions to create infrastructure in your AWS account by following 3 simple steps.</Text>
         <Spacer y={1} />
-        <VerticalSteps
-          currentStep={currentStep}
-          steps={
-            [
-              <>
-                <Text size={16}>1. Log in to your AWS Account.</Text>
-                <Spacer y={0.25} />
-                <Text color="helper">Return to Porter after successful log-in.</Text>
+        <ThemeProvider theme={stepperTheme} >
+          <Stepper activeStep={currentStep} orientation="vertical" style={{ padding: 0 }}>
+            <MuiStep>
+              <StepLabel>Log in to your AWS Account.</StepLabel>
+              <StepContent>
+                <Text color="helper">Return to Porter after successful login.</Text>
                 <Spacer y={0.5} />
                 <AWSButtonContainer>
                   <ButtonImg src={aws} />
                   <Button
                     width={"170px"}
-                    onClick={directToAWSLoginAndProceedStep}
-                    color="#1E2631"
+                    onClick={directToAWSLogin}
+                    color="linear-gradient(180deg, #26292e, #24272c)"
                     withBorder
                   >
                     Log in
                   </Button>
                 </AWSButtonContainer>
-              </>,
-              <>
-                <Text size={16}>2. Provide your AWS Account ID.</Text>
-                <Spacer y={0.25} />
-                <Text color="helper">Make sure this is the ID of the account you are currently logged into, and would like to provision resources in.</Text>
+                <Spacer y={0.5} />
+                <StepChangeButtonsContainer>
+                  <Button onClick={() => setCurrentStep(1)}>Continue</Button>
+                </StepChangeButtonsContainer>
+              </StepContent>
+            </MuiStep>
+            <MuiStep>
+              <StepLabel>Enter your AWS Account ID.</StepLabel>
+              <StepContent>
+                <Text color="helper">Make sure this is the ID of the account you are currently logged into and would like to provision resources in.</Text>
                 <Spacer y={0.5} />
                 <Input
                   label={
@@ -219,86 +250,104 @@ const CloudFormationForm: React.FC<Props> = ({
                   value={AWSAccountID}
                   setValue={handleAWSAccountIDChange}
                   placeholder="ex: 915037676314"
-                  error={AWSAccountIDInputError}
+                  error={awsAccountIdInputError}
                 />
-              </>,
-              <>
-                <Text size={16}>3. Create an AWS Cloudformation Stack.</Text>
-                <Spacer y={0.25} />
-                <Text color="helper">This grants Porter permissions to create infrastructure.</Text>
-                <Spacer y={0.25} />
-                <Text color="helper">
-                  Return to Porter once the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE".
-                </Text>
+                <Spacer y={0.5} />
+                <StepChangeButtonsContainer>
+                  <Button onClick={handleContinueWithAWSAccountId} disabled={awsAccountIdInputError != null || AWSAccountID.length === 0}>Continue</Button>
+                  <Spacer inline x={0.5} />
+                  <Button onClick={() => setCurrentStep(0)} color="#121212">Back</Button>
+                </StepChangeButtonsContainer>
+              </StepContent>
+            </MuiStep>
+            <MuiStep>
+              <StepLabel optional={<Typography variant="caption" color="textSecondary">This grants Porter permissions to create infrastructure in your account.</Typography>}>Create an AWS Cloudformation Stack.</StepLabel>
+              <StepContent>
+                <Text color="helper">Clicking the button below will take you to the AWS CloudFormation console. Return to Porter after clicking 'Create stack' in the bottom right corner.</Text>
                 <Spacer y={0.5} />
                 <AWSButtonContainer>
                   <ButtonImg src={aws} />
                   <Button
                     width={"170px"}
-                    onClick={directToCloudFormationAndProceedStep}
-                    color="#1E2631"
+                    onClick={directToCloudFormation}
+                    color="linear-gradient(180deg, #26292e, #24272c)"
                     withBorder
+                    disabled={canProceed}
+                    disabledTooltipMessage={"Porter can already access your account!"}
                   >
                     Grant permissions
                   </Button>
                 </AWSButtonContainer>
-              </>,
-              <>
-                <Text size={16}>4. Continue to the provision step.</Text>
                 <Spacer y={0.5} />
-                <Button
-                  width={"200px"}
-                  onClick={checkIfRoleExists}
-                  status={
-                    errorMessage ? (
-                      <Error
-                        message={errorMessage}
-                        ctaText="Troubleshooting steps"
-                        errorModalContents={
-                          <>
-                            <Text size={16}>Granting Porter access to AWS</Text>
-                            <Spacer y={1} />
-                            <Text color="helper">
-                              Porter needs access to your AWS account in order to create infrastructure. You can grant Porter access to AWS by following these steps:
-                            </Text>
-                            <Spacer y={1} />
-                            <Step number={1}>
-                              <Link to="https://aws.amazon.com/resources/create-account/" target="_blank">
-                                Create an AWS account
-                              </Link>
-                              <Spacer inline width="5px" />
-                              if you don't already have one.
-                            </Step>
-                            <Spacer y={1} />
-                            <Step number={2}>
-                              Once you are logged in to your AWS account,
-                              <Spacer inline width="5px" />
-                              <Link to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account" target="_blank">
-                                copy your account ID
-                              </Link>.
-                            </Step>
-                            <Spacer y={1} />
-                            <Step number={3}>Fill in your account ID on Porter and select "Grant permissions".</Step>
-                            <Spacer y={1} />
-                            <Step number={4}>After being redirected to AWS, select "Create stack" on the AWS console.</Step>
-                            <Spacer y={1} />
-                            <Step number={5}>Wait until the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE".</Step>
-                            <Spacer y={1} />
-                            <Step number={6}>Return to Porter and select "Continue".</Step>
-                          </>
-                        }
-                      />
-                    ) : (
-                      roleStatus
-                    )
-                  }
-                >
-                  Continue
-                </Button>
-              </>
-            ].filter(step => step != null)
-          }
-        />
+                <Text color="helper">
+                  Once the CloudFormation stack has status{" "}
+                  <Box component="span" color="#1d8102">
+                    CREATE_COMPLETE
+                  </Box>, you may proceed.
+                </Text>
+                <Spacer y={0.25} />
+                <Text color="helper">This may take 1 - 2 minutes.</Text>
+                <Spacer y={0.5} />
+                <StepChangeButtonsContainer>
+                  <Button onClick={handleProceedToProvisionStep} disabled={!canProceed}>Continue</Button>
+                  <Spacer inline x={0.5} />
+                  <Button
+                    onClick={() => setCurrentStep(1)}
+                    color="#121212"
+                    status={canProceed ? "success" : hasClickedCloudformationButton ? "loading" : undefined}
+                    loadingText={`Checking if Porter can access AWS account ID ${AWSAccountID}...`}
+                    successText={`Porter can access AWS account ID ${AWSAccountID}`}
+                  >
+                    Back
+                  </Button>
+                </StepChangeButtonsContainer>
+                <Spacer y={0.5} />
+                <Link hasunderline onClick={() => setShowNeedHelpModal(true)}>
+                  Need help?
+                </Link>
+              </StepContent>
+            </MuiStep>
+            {showNeedHelpModal &&
+              <Modal closeModal={() => setShowNeedHelpModal(false)} width={"800px"}>
+                <Text size={16}>Granting Porter access to AWS</Text>
+                <Spacer y={1} />
+                <Text color="helper">
+                  Porter needs access to your AWS account in order to create infrastructure. You can grant Porter access to AWS by following these steps:
+                </Text>
+                <Spacer y={1} />
+                <Step number={1}>
+                  <Link to="https://aws.amazon.com/resources/create-account/" target="_blank">
+                    Create an AWS account
+                  </Link>
+                  <Spacer inline width="5px" />
+                  if you don't already have one.
+                </Step>
+                <Spacer y={1} />
+                <Step number={2}>
+                  Once you are logged in to your AWS account,
+                  <Spacer inline width="5px" />
+                  <Link to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account" target="_blank">
+                    copy your account ID
+                  </Link>.
+                </Step>
+                <Spacer y={1} />
+                <Step number={3}>Fill in your account ID on Porter and select "Grant permissions".</Step>
+                <Spacer y={1} />
+                <Step number={4}>After being redirected to AWS CloudFormation, select "Create stack" on the bottom right.</Step>
+                <Spacer y={1} />
+                <Step number={5}>The stack will start to create. Refresh until the stack status has changed from "CREATE_IN_PROGRESS" to "CREATE_COMPLETE":</Step>
+                <Spacer y={1} />
+                <ImageDiv>
+                  <img src={cloudformationStatus} height="250px" />
+                </ImageDiv>
+                <Spacer y={1} />
+                <Step number={6}>Return to Porter and select "Continue".</Step>
+                <Spacer y={1} />
+                <Step number={7}>If you continue to see issues, <a href="mailto:support@porter.run">email support.</a></Step>
+              </Modal>
+            }
+          </Stepper>
+        </ThemeProvider>
       </>
     );
   }
@@ -324,6 +373,14 @@ const CloudFormationForm: React.FC<Props> = ({
 
 export default CloudFormationForm;
 
+const ImageDiv = styled.div`
+  text-align: center;
+`;
+
+const StepChangeButtonsContainer = styled.div`
+  display: flex;
+`;
+
 const Flex = styled.div`
   display: flex;
   ailgn-items: center;

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

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

+ 274 - 83
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -19,7 +19,9 @@ import {
   GKE,
   GKENetwork,
   GKENodePool,
-  GKENodePoolType
+  GKENodePoolType,
+  GKEPreflightValues,
+  PreflightCheckRequest
 } from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import Button from "./porter/Button";
@@ -28,9 +30,19 @@ import Spacer from "./porter/Spacer";
 import Step from "./porter/Step";
 import Link from "./porter/Link";
 import Text from "./porter/Text";
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import Loading from "components/Loading";
+import Placeholder from "./Placeholder";
+import Fieldset from "./porter/Fieldset";
+import ExpandableSection from "./porter/ExpandableSection";
+import PreflightChecks from "./PreflightChecks";
+
 
 const locationOptions = [
-  { value: "us-east1", label: "us-east1" },
+  { value: "us-east1", label: "us-east1 (South Carolina, USA)" },
+  { value: "us-east4", label: "us-east4 (Virginia, USA)" },
+  { value: "asia-south1", label: "asia-south1 (Mumbia, India)" },
 ];
 
 const defaultClusterNetworking = new GKENetwork({
@@ -40,8 +52,7 @@ const defaultClusterNetworking = new GKENetwork({
   serviceCidr: "10.75.0.0/16",
 });
 
-const defaultClusterVersion = "1.25";
-
+const clusterVersionOptions = [{ value: "1.25", label: "v1.25" }, { value: "1.26", label: "v1.26" }, { value: "1.27", label: "v1.27" }];
 
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
@@ -50,7 +61,8 @@ type Props = RouteComponentProps & {
   clusterId?: number;
 };
 
-const VALID_CIDR_RANGE_PATTERN = /^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.0\.0\/16$/;
+const VALID_CIDR_RANGE_PATTERN = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(8|9|1\d|2[0-8])$/;
+
 
 const GCPProvisionerSettings: React.FC<Props> = (props) => {
   const {
@@ -66,15 +78,19 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   const [minInstances, setMinInstances] = useState(1);
   const [maxInstances, setMaxInstances] = useState(10);
   const [clusterNetworking, setClusterNetworking] = useState(defaultClusterNetworking);
-  const [clusterVersion, setClusterVersion] = useState(defaultClusterVersion);
+  const [clusterVersion, setClusterVersion] = useState(clusterVersionOptions[0].value);
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
+  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, region?: string) => {
     try {
-      await api.updateOnboardingStep("<token>", { step }, {
+      await api.updateOnboardingStep("<token>", { step, provider: "gcp", region }, {
         project_id: currentProject.id,
       });
     } catch (err) {
@@ -83,6 +99,9 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   };
 
   const getStatus = () => {
+    if (isLoading) {
+      return <Loading />
+    }
     if (isReadOnly && props.provisionerError == "") {
       return "Provisioning is still in progress...";
     } else if (errorMessage !== "") {
@@ -103,13 +122,12 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
   const isDisabled = () => {
     return (
-      !user.email.endsWith("porter.run") &&
-      ((!clusterName && true) ||
-        (isReadOnly && props.provisionerError === "") ||
-        props.provisionerError === "" ||
-        currentCluster?.status === "UPDATING" ||
-        isClicked)
-    );
+      (!clusterName && true)
+      || (isReadOnly && props.provisionerError === "")
+      || currentCluster?.status === "UPDATING"
+      || isClicked
+      || (!currentProject?.enable_reprovision && props.clusterId)
+    )
   };
 
   const validateInputs = (): string => {
@@ -128,13 +146,73 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
     return "";
   }
+  const renderAdvancedSettings = () => {
+    return (
+      <>
+        {
+          < Heading >
+            <ExpandHeader
+              onClick={() => setIsExpanded(!isExpanded)}
+              isExpanded={isExpanded}
+            >
+              <i className="material-icons">arrow_drop_down</i>
+              Advanced settings
+            </ExpandHeader>
+          </Heading >
+        }
+        {
+          isExpanded && (
+            <>
+              <SelectRow
+                options={clusterVersionOptions}
+                width="350px"
+                disabled={isReadOnly}
+                value={clusterVersion}
+                scrollBuffer={true}
+                dropdownMaxHeight="240px"
+                setActiveValue={setClusterVersion}
+                label="Cluster version"
+              />
+              <InputRow
+                width="350px"
+                type="string"
+                disabled={isReadOnly}
+                value={clusterNetworking.cidrRange}
+                setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
+                label="VPC CIDR range"
+                placeholder="ex: 10.78.0.0/16"
+              />
+              <Spacer y={0.25} />
+              <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+            </>
+          )
+        }
+      </>
+    );
+  };
+
+  const statusPreflight = (): string => {
+
+
+    if (!clusterNetworking.cidrRange) {
+      return "VPC CIDR range is required";
+    }
+    if (!VALID_CIDR_RANGE_PATTERN.test(clusterNetworking.cidrRange)) {
+      return "VPC CIDR range must be in the format of [0-255].[0-255].0.0/16";
+    }
+
+    return "";
+  }
+
   const createCluster = async () => {
+
     const err = validateInputs();
     if (err !== "") {
       setErrorMessage(err)
       setErrorDetails("")
       return;
     }
+    setIsLoading(true);
 
     setIsClicked(true);
     var data = new Contract({
@@ -147,7 +225,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           case: "gkeKind",
           value: new GKE({
             clusterName: clusterName,
-            clusterVersion: clusterVersion || defaultClusterVersion,
+            clusterVersion: clusterVersion || clusterVersionOptions[0].value,
             region: region,
             network: new GKENetwork({
               cidrRange: clusterNetworking.cidrRange || defaultClusterNetworking.cidrRange,
@@ -182,56 +260,69 @@ 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", region);
+        }
 
-      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);
+        setIsLoading(true);
+
+        // TODO: handle different error conditions here from preflights
+        setErrorMessage(DEFAULT_ERROR_MESSAGE);
+        setErrorDetails(errMessage)
+      } finally {
+        setIsReadOnly(false);
+        setIsClicked(false);
+        setIsLoading(true);
+
+      }
+    } else {
       setIsClicked(false);
+      setIsLoading(true);
+
       // TODO: handle different error conditions here from preflights
       setErrorMessage(DEFAULT_ERROR_MESSAGE);
-      setErrorDetails(errMessage)
-    } finally {
-      setIsReadOnly(false);
-      setIsClicked(false);
+      setErrorDetails("Could not perform Preflight Checks ")
     }
   };
 
@@ -251,8 +342,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   useEffect(() => {
     const contract = props.selectedClusterVersion as any;
     if (contract?.cluster) {
-      if (contract.cluster.gkeKind.nodePools) {
-        contract.cluster.gkeKind.nodePools.map((nodePool: any) => {
+      if (contract.cluster?.gkeKind?.nodePools) {
+        contract.cluster?.gkeKind?.nodePools.map((nodePool: any) => {
           if (nodePool.nodePoolType === "NODE_POOL_TYPE_APPLICATION") {
             setMinInstances(nodePool.minInstances);
             setMaxInstances(nodePool.maxInstances);
@@ -260,11 +351,11 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
         });
       }
       setCreateStatus("");
-      setClusterName(contract.cluster.gkeKind.clusterName);
-      setRegion(contract.cluster.gkeKind.region);
-      setClusterVersion(contract.cluster.gkeKind.clusterVersion);
+      setClusterName(contract.cluster.gkeKind?.clusterName);
+      setRegion(contract.cluster.gkeKind?.region);
+      setClusterVersion(contract.cluster.gkeKind?.clusterVersion);
       let cn = new GKENetwork({
-        cidrRange: contract.cluster.gkeKind.clusterNetworking?.cidrRange || defaultClusterNetworking.cidrRange,
+        cidrRange: contract.cluster.gkeKind?.clusterNetworking?.cidrRange || defaultClusterNetworking.cidrRange,
         controlPlaneCidr: defaultClusterNetworking.controlPlaneCidr,
         podCidr: defaultClusterNetworking.podCidr,
         serviceCidr: defaultClusterNetworking.serviceCidr,
@@ -273,6 +364,44 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
     }
   }, [props.selectedClusterVersion]);
 
+  useEffect(() => {
+    if (statusPreflight() == "" && !props.clusterId) {
+      preflightChecks()
+    }
+
+  }, [props.selectedClusterVersion, clusterNetworking, region]);
+
+  const preflightChecks = async () => {
+    setIsLoading(true);
+
+
+    var data = new PreflightCheckRequest({
+      projectId: BigInt(currentProject.id),
+      cloudProvider: EnumCloudProvider.GCP,
+      cloudProviderCredentialsId: props.credentialId,
+      preflightValues: {
+        case: "gkePreflightValues",
+        value: new GKEPreflightValues({
+          network: new GKENetwork({
+            cidrRange: clusterNetworking.cidrRange || defaultClusterNetworking.cidrRange,
+            controlPlaneCidr: defaultClusterNetworking.controlPlaneCidr,
+            podCidr: defaultClusterNetworking.podCidr,
+            serviceCidr: defaultClusterNetworking.serviceCidr,
+          })
+        })
+      }
+    });
+    const preflightDataResp = await api.preflightCheck(
+      "<token>", data,
+      {
+        id: currentProject.id,
+      }
+    )
+    setPreflightData(preflightDataResp?.data?.Msg);
+    setIsLoading(false)
+
+  }
+
   const renderForm = () => {
     // Render simplified form if initial create
     if (!props.clusterId) {
@@ -295,17 +424,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
             setActiveValue={setRegion}
             label="📍 GCP location"
           />
-          <InputRow
-            width="350px"
-            type="string"
-            disabled={isReadOnly}
-            value={clusterNetworking.cidrRange}
-            setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
-            label="VPC CIDR range"
-            placeholder="ex: 10.78.0.0/16"
-          />
-          <Spacer y={0.25} />
-          <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+          {renderAdvancedSettings()}
+
         </>
       );
     }
@@ -324,6 +444,16 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
           setActiveValue={setRegion}
           label="📍 Google Cloud Region"
         />
+        <SelectRow
+          options={clusterVersionOptions}
+          width="350px"
+          disabled={isReadOnly}
+          value={clusterVersion}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setClusterVersion}
+          label="Cluster version"
+        />
       </>
     );
   };
@@ -331,13 +461,61 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   return (
     <>
       <StyledForm>{renderForm()}</StyledForm>
+
+      {props.credentialId && (<>
+
+        {isLoading ?
+          <>
+            <Placeholder>
+              <Loading />
+            </Placeholder>
+            <Spacer y={1} />
+          </>
+          :
+          <>
+            {(!props.clusterId) &&
+              <>
+                <PreflightChecks preflightData={preflightData} setPreflightFailed={setPreflightFailed} />
+                <Spacer y={1} />
+              </>
+            }
+          </>
+        }
+
+      </>
+      )}
+
       <Button
-        disabled={isDisabled()}
+        disabled={isDisabled() || isLoading || preflightFailed || statusPreflight() != ""}
         onClick={createCluster}
         status={getStatus()}
       >
         Provision
       </Button>
+
+      {
+        (!currentProject?.enable_reprovision && props.clusterId) &&
+        <>
+          <Spacer y={1} />
+          <Text>Updates to the cluster are disabled on this project. Enable re-provisioning by contacting <a href="mailto:support@porter.run">Porter Support</a>.</Text>
+        </>
+      }
+
+      {user.isPorterUser &&
+        <>
+
+          <Spacer y={1} />
+          <Text color="yellow">Visible to Admin Only</Text>
+          <Button
+            color="red"
+            onClick={createCluster}
+            status={getStatus()}
+          >
+            Override Provision
+          </Button>
+        </>
+      }
+
     </>
   );
 };
@@ -346,14 +524,14 @@ export default withRouter(GCPProvisionerSettings);
 
 
 const StyledForm = styled.div`
-  position: relative;
-  padding: 30px 30px 25px;
-  border-radius: 5px;
-  background: ${({ theme }) => theme.fg};
-  border: 1px solid #494b4f;
-  font-size: 13px;
-  margin-bottom: 30px;
-`;
+      position: relative;
+      padding: 30px 30px 25px;
+      border-radius: 5px;
+      background: ${({ theme }) => theme.fg};
+      border: 1px solid #494b4f;
+      font-size: 13px;
+      margin-bottom: 30px;
+      `;
 
 const DEFAULT_ERROR_MESSAGE =
   "An error occurred while provisioning your infrastructure. Please try again.";
@@ -364,3 +542,16 @@ const errorMessageToModal = (errorMessage: string) => {
       return null;
   }
 };
+
+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
+`;

+ 81 - 62
dashboard/src/components/ProvisionerSettings.tsx

@@ -39,6 +39,7 @@ import Tooltip from "./porter/Tooltip";
 import Icon from "./porter/Icon";
 import { set } from "traverse";
 import { load } from "js-yaml";
+import Loading from "./Loading";
 const regionOptions = [
   { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
   { value: "us-east-2", label: "US East (Ohio) us-east-2" },
@@ -130,6 +131,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [errorMessage, setErrorMessage] = useState<string>(undefined);
   const [isClicked, setIsClicked] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+
   const markStepStarted = async (step: string, errMessage?: string) => {
     try {
       await api.updateOnboardingStep(
@@ -138,6 +141,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
           step,
           error_message: errMessage,
           region: awsRegion,
+          provider: "aws",
         },
         {
           project_id: currentProject.id,
@@ -149,6 +153,9 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   };
 
   const getStatus = () => {
+    if (isLoading) {
+      return <Loading />
+    }
     if (isReadOnly && props.provisionerError == "") {
       return "Provisioning is still in progress...";
     } else if (errorMessage) {
@@ -218,9 +225,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
 
   const isDisabled = () => {
     return (
-      !user?.isPorterUser &&
-      (clusterNameDoesNotExist() || userProvisioning() || isClicked)
-    );
+      (clusterNameDoesNotExist() || userProvisioning() || isClicked || (currentCluster && !currentProject?.enable_reprovision)
+      ))
   };
   function convertStringToTags(tagString) {
     if (typeof tagString !== "string" || tagString.trim() === "") {
@@ -239,6 +245,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     return tags;
   }
   const createCluster = async () => {
+    setIsLoading(true);
     setIsClicked(true);
 
     let loadBalancerObj = new LoadBalancer({});
@@ -368,9 +375,10 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       // }
       setErrorMessage(undefined);
     } catch (err) {
-      const errMessage = err.response.data.error.replace("unknown: ", "");
+      const errMessage = err.response.data?.error.replace("unknown: ", "");
       // hacky, need to standardize error contract with backend
       setIsClicked(false);
+      setIsLoading(false)
       if (errMessage.includes("elastic IP")) {
         setErrorMessage(AWS_EIP_QUOTA_ERROR_MESSAGE);
       } else if (errMessage.includes("VPC")) {
@@ -387,6 +395,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       markStepStarted("provisioning-failed", errMessage);
     } finally {
       setIsReadOnly(false);
+      setIsLoading(false);
+
       setIsClicked(false);
     }
   };
@@ -775,8 +785,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                         </ErrorInLine>
                       )}
 
-                    <Spacer y={1} />
-                    {/* <Checkbox
+                      <Spacer y={1} />
+                      {/* <Checkbox
               checked={accessS3Logs}
               disabled={isReadOnly}
               toggleChecked={() => {
@@ -844,61 +854,61 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
                     </>
                   )}
                   <FlexCenter>
-                      <Checkbox
-                          checked={guardDutyEnabled}
-                          disabled={isReadOnly}
-                          toggleChecked={() => {
-                            setGuardDutyEnabled(!guardDutyEnabled);
-                          }}
-                          disabledTooltip={
-                            "Wait for provisioning to complete before editing this field."
-                          }
-                      >
-                        <Text color="helper">
-                          Install AWS GuardDuty agent on this cluster (see details to fully enable)
-                        </Text>
-                        <Spacer x={.5} inline/>
-                        <Tooltip
-                            children={<Icon src={info} />}
-                            content={
-                              "In addition to installing the agent, you must enable GuardDuty through your AWS Console and enable EKS Protection in the EKS Protection tab of the GuardDuty console."
-                            }
-                            position="right"
-                        />
-                      </Checkbox>
+                    <Checkbox
+                      checked={guardDutyEnabled}
+                      disabled={isReadOnly}
+                      toggleChecked={() => {
+                        setGuardDutyEnabled(!guardDutyEnabled);
+                      }}
+                      disabledTooltip={
+                        "Wait for provisioning to complete before editing this field."
+                      }
+                    >
+                      <Text color="helper">
+                        Install AWS GuardDuty agent on this cluster (see details to fully enable)
+                      </Text>
+                      <Spacer x={.5} inline />
+                      <Tooltip
+                        children={<Icon src={info} />}
+                        content={
+                          "In addition to installing the agent, you must enable GuardDuty through your AWS Console and enable EKS Protection in the EKS Protection tab of the GuardDuty console."
+                        }
+                        position="right"
+                      />
+                    </Checkbox>
                   </FlexCenter>
                   <Spacer y={1} />
                   <FlexCenter>
-                      <Checkbox
-                          checked={kmsEncryptionEnabled}
-                          disabled={isReadOnly || currentCluster != null}
-                          toggleChecked={() => {
-                            setKmsEncryptionEnabled(!kmsEncryptionEnabled);
-                          }}
-                          disabledTooltip={ kmsEncryptionEnabled ? "KMS encryption can never be disabled." :
-                            "Encryption is only supported at cluster creation."
-                          }
-                      >
-                        <Text color="helper">
-                          Enable KMS encryption for this cluster
-                        </Text>
-                        <Spacer x={.5} inline/>
-                        <Tooltip
-                            children={<Icon src={info} />}
-                            content={
-                              "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
-                            }
-                            position="right"
-                        />
-                      </Checkbox>
-                  </FlexCenter>
-                  {kmsEncryptionEnabled && (
-                      <ErrorInLine>
-                        <i className="material-icons">error</i>
-                        {
+                    <Checkbox
+                      checked={kmsEncryptionEnabled}
+                      disabled={isReadOnly || currentCluster != null}
+                      toggleChecked={() => {
+                        setKmsEncryptionEnabled(!kmsEncryptionEnabled);
+                      }}
+                      disabledTooltip={kmsEncryptionEnabled ? "KMS encryption can never be disabled." :
+                        "Encryption is only supported at cluster creation."
+                      }
+                    >
+                      <Text color="helper">
+                        Enable KMS encryption for this cluster
+                      </Text>
+                      <Spacer x={.5} inline />
+                      <Tooltip
+                        children={<Icon src={info} />}
+                        content={
                           "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
                         }
-                      </ErrorInLine>
+                        position="right"
+                      />
+                    </Checkbox>
+                  </FlexCenter>
+                  {kmsEncryptionEnabled && (
+                    <ErrorInLine>
+                      <i className="material-icons">error</i>
+                      {
+                        "KMS encryption can never be disabled. Deletion of the KMS key will permanently place this cluster in a degraded state."
+                      }
+                    </ErrorInLine>
                   )}
                   <Spacer y={1} />
                 </>
@@ -963,18 +973,27 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       <Button
         // disabled={isDisabled()}
         disabled={
-          user?.email === "admin@porter.run" ||
-          currentProject?.enable_reprovision
-            ? false
-            : currentCluster
-            ? true
-            : isDisabled()
+          isDisabled()
         }
         onClick={createCluster}
         status={getStatus()}
       >
         Provision
       </Button>
+      {user.isPorterUser &&
+        <>
+
+          <Spacer y={1} />
+          <Text color="yellow">Visible to Admin Only</Text>
+          <Button
+            color="red"
+            onClick={createCluster}
+            status={getStatus()}
+          >
+            Override Provision
+          </Button>
+        </>
+      }
     </>
   );
 };

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

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

+ 27 - 1
dashboard/src/components/porter/Button.tsx

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
 import styled, { keyframes } from "styled-components";
 
 import loading from "assets/loading.gif";
+import Tooltip from "./Tooltip";
 
 type Props = {
   children: React.ReactNode;
@@ -19,6 +20,7 @@ type Props = {
   rounded?: boolean;
   alt?: boolean;
   type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
+  disabledTooltipMessage?: string;
 };
 
 const Button: React.FC<Props> = ({
@@ -37,6 +39,7 @@ const Button: React.FC<Props> = ({
   rounded,
   alt,
   type,
+  disabledTooltipMessage,
 }) => {
   const renderStatus = () => {
     switch (status) {
@@ -65,7 +68,30 @@ const Button: React.FC<Props> = ({
     }
   };
 
-  return (
+  return disabled && disabledTooltipMessage ? (
+    <Tooltip content={disabledTooltipMessage} position="right">
+      <Wrapper>
+        <StyledButton
+          disabled={disabled}
+          onClick={() => {
+            if (!disabled && onClick) {
+              onClick();
+            }
+          }}
+          width={width}
+          height={height}
+          color={color}
+          withBorder={withBorder || alt}
+          rounded={rounded || alt}
+          alt={alt}
+          type={type}
+        >
+          <Text>{children}</Text>
+        </StyledButton>
+        {(helperText || status) && renderStatus()}
+      </Wrapper>
+    </Tooltip>
+  ) : (
     <Wrapper>
       <StyledButton
         disabled={disabled}

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

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

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

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

+ 136 - 0
dashboard/src/lib/hooks/useAppValidation.ts

@@ -0,0 +1,136 @@
+import { PorterApp } from "@porter-dev/api-contracts";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppToProto,
+} from "lib/porter-apps";
+import { useCallback, useContext } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
+export const useAppValidation = ({
+  deploymentTargetID,
+  creating = false,
+}: {
+  deploymentTargetID?: string;
+  creating?: boolean;
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const removedEnvKeys = (
+    current: Record<string, string>,
+    previous: Record<string, string>
+  ) => {
+    return Object.keys(previous).filter((key) => !current[key]);
+  };
+
+  const getBranchHead = async ({
+    projectID,
+    source,
+  }: {
+    projectID: number;
+    source: SourceOptions & {
+      type: "github";
+    };
+  }) => {
+    const [owner, repo_name] = await z
+      .tuple([z.string(), z.string()])
+      .parseAsync(source.git_repo_name?.split("/"));
+
+    const res = await api.getBranchHead(
+      "<token>",
+      {},
+      {
+        ...source,
+        project_id: projectID,
+        kind: "github",
+        owner,
+        name: repo_name,
+        branch: source.git_branch,
+      }
+    );
+
+    const commitData = await z
+      .object({
+        commit_sha: z.string(),
+      })
+      .parseAsync(res.data);
+
+    return commitData;
+  };
+
+  const validateApp = useCallback(
+    async (data: PorterAppFormData, prevRevision?: PorterApp) => {
+      if (!currentProject || !currentCluster) {
+        throw new Error("No project or cluster selected");
+      }
+
+      if (!deploymentTargetID) {
+        throw new Error("No deployment target selected");
+      }
+
+      const envVariableDeletions = removedEnvKeys(
+        data.app.env,
+        prevRevision?.env || {}
+      );
+
+      const proto = clientAppToProto(data);
+      const commit_sha = await match(data.source)
+        .with({ type: "github" }, async (src) => {
+          if (!creating) {
+            return "";
+          }
+
+          const { commit_sha } = await getBranchHead({
+            projectID: currentProject.id,
+            source: src,
+          });
+          return commit_sha;
+        })
+        .with({ type: "docker-registry" }, () => {
+          return "";
+        })
+        .exhaustive();
+
+      const res = await api.validatePorterApp(
+        "<token>",
+        {
+          b64_app_proto: btoa(
+            proto.toJsonString({
+              emitDefaultValues: true,
+            })
+          ),
+          deployment_target_id: deploymentTargetID,
+          commit_sha,
+          deletions: {
+            service_names: data.deletions.serviceNames.map((s) => s.name),
+            env_variable_names: envVariableDeletions,
+          },
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const validAppData = await z
+        .object({
+          validate_b64_app_proto: z.string(),
+        })
+        .parseAsync(res.data);
+
+      const validatedAppProto = PorterApp.fromJsonString(
+        atob(validAppData.validate_b64_app_proto)
+      );
+
+      return validatedAppProto;
+    },
+    [deploymentTargetID, currentProject, currentCluster]
+  );
+
+  return {
+    validateApp,
+  };
+};

+ 44 - 14
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -1,16 +1,21 @@
 import { PorterApp } from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
-import { SourceOptions, defaultServicesWithOverrides } from "lib/porter-apps";
-import { ClientService } from "lib/porter-apps/services";
+import { SourceOptions, serviceOverrides } from "lib/porter-apps";
+import { ClientService, DetectedServices } from "lib/porter-apps/services";
 import { useCallback, useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { z } from "zod";
 
-type DetectedServices = {
-  services: ClientService[];
-  predeploy?: ClientService;
-};
+type PorterYamlStatus =
+  | {
+      loading: true;
+      detectedServices: null;
+    }
+  | {
+      detectedServices: DetectedServices | null;
+      loading: false;
+    };
 
 /*
  *
@@ -19,25 +24,31 @@ type DetectedServices = {
  * added to an app by default with read-only values.
  *
  */
-export const usePorterYaml = (source: SourceOptions) => {
+export const usePorterYaml = ({
+  source,
+  useDefaults = true,
+}: {
+  source: SourceOptions | null;
+  useDefaults?: boolean;
+}): PorterYamlStatus => {
   const { currentProject, currentCluster } = useContext(Context);
   const [
     detectedServices,
     setDetectedServices,
   ] = useState<DetectedServices | null>(null);
 
-  const { data } = useQuery(
+  const { data, status } = useQuery(
     [
       "getPorterYamlContents",
       currentProject?.id,
-      source.git_branch,
-      source.git_repo_name,
+      source?.git_branch,
+      source?.git_repo_name,
     ],
     async () => {
       if (!currentProject) {
         return;
       }
-      if (source.type !== "github") {
+      if (source?.type !== "github") {
         return;
       }
       const res = await api.getPorterYamlContents(
@@ -59,9 +70,10 @@ export const usePorterYaml = (source: SourceOptions) => {
     },
     {
       enabled:
-        source.type === "github" &&
+        source?.type === "github" &&
         Boolean(source.git_repo_name) &&
         Boolean(source.git_branch),
+      retry: false,
     }
   );
 
@@ -92,8 +104,9 @@ export const usePorterYaml = (source: SourceOptions) => {
           .parseAsync(res.data);
         const proto = PorterApp.fromJsonString(atob(data.b64_app_proto));
 
-        const { services, predeploy } = defaultServicesWithOverrides({
+        const { services, predeploy } = serviceOverrides({
           overrides: proto,
+          useDefaults,
         });
 
         if (services.length || predeploy) {
@@ -127,5 +140,22 @@ export const usePorterYaml = (source: SourceOptions) => {
     }
   }, [data]);
 
-  return detectedServices;
+  if (source?.type !== "github") {
+    return {
+      loading: false,
+      detectedServices: null,
+    };
+  }
+
+  if (status === "loading") {
+    return {
+      loading: true,
+      detectedServices: null,
+    };
+  }
+
+  return {
+    detectedServices,
+    loading: false,
+  };
 };

+ 166 - 26
dashboard/src/lib/porter-apps/index.ts

@@ -1,7 +1,10 @@
-import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
+import {
+  BUILDPACK_TO_NAME,
+  buildpackSchema,
+} from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 import {
-  ClientService,
+  DetectedServices,
   defaultSerialized,
   deserializeService,
   isPredeployService,
@@ -10,8 +13,9 @@ import {
   serviceProto,
   serviceValidator,
 } from "./services";
-import { PorterApp, Service } from "@porter-dev/api-contracts";
+import { Build, PorterApp, Service } from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
+import { valueExists } from "shared/util";
 
 // buildValidator is used to validate inputs for build setting fields
 export const buildValidator = z.discriminatedUnion("method", [
@@ -52,6 +56,14 @@ export const sourceValidator = z.discriminatedUnion("type", [
 ]);
 export type SourceOptions = z.infer<typeof sourceValidator>;
 
+export const deletionValidator = z.object({
+  serviceNames: z
+    .object({
+      name: z.string(),
+    })
+    .array(),
+});
+
 // clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
 export const clientAppValidator = z.object({
   name: z.string().min(1),
@@ -65,48 +77,66 @@ export type ClientPorterApp = z.infer<typeof clientAppValidator>;
 export const porterAppFormValidator = z.object({
   app: clientAppValidator,
   source: sourceValidator,
+  deletions: deletionValidator,
 });
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 
-// defaultServicesWithOverrides is used to generate the default services for an app from porter.yaml
+// serviceOverrides is used to generate the services overrides for an app from porter.yaml
 // this method is only called when a porter.yaml is present and has services defined
-export function defaultServicesWithOverrides({
+export function serviceOverrides({
   overrides,
+  useDefaults = true,
 }: {
   overrides: PorterApp;
-}): {
-  services: ClientService[];
-  predeploy?: ClientService;
-} {
+  useDefaults?: boolean;
+}): DetectedServices {
   const services = Object.entries(overrides.services)
     .map(([name, service]) => serializedServiceFromProto({ name, service }))
-    .map((svc) =>
-      deserializeService(
-        defaultSerialized({
-          name: svc.name,
-          type: svc.config.type,
-        }),
-        svc
-      )
-    );
+    .map((svc) => {
+      if (useDefaults) {
+        return deserializeService({
+          service: defaultSerialized({ name: svc.name, type: svc.config.type }),
+          override: svc,
+          expanded: true,
+        });
+      }
+
+      return deserializeService({ service: svc });
+    });
+
+  if (!overrides.predeploy) {
+    return {
+      services,
+    };
+  }
 
-  const predeploy = overrides.predeploy
-    ? deserializeService(
-        defaultSerialized({
+  if (useDefaults) {
+    return {
+      services,
+      predeploy: deserializeService({
+        service: defaultSerialized({
           name: "pre-deploy",
           type: "predeploy",
         }),
-        serializedServiceFromProto({
+        override: serializedServiceFromProto({
           name: "pre-deploy",
           service: overrides.predeploy,
           isPredeploy: true,
-        })
-      )
-    : undefined;
+        }),
+        expanded: true,
+      }),
+    };
+  }
 
   return {
     services,
-    predeploy,
+    predeploy: deserializeService({
+      service: serializedServiceFromProto({
+        name: "pre-deploy",
+        service: overrides.predeploy,
+        isPredeploy: true,
+      }),
+    }),
   };
 }
 
@@ -114,6 +144,7 @@ const clientBuildToProto = (build: BuildOptions) => {
   return match(build)
     .with({ method: "pack" }, (b) =>
       Object.freeze({
+        method: "pack",
         context: b.context,
         buildpacks: b.buildpacks.map((b) => b.buildpack),
         builder: b.builder,
@@ -121,6 +152,7 @@ const clientBuildToProto = (build: BuildOptions) => {
     )
     .with({ method: "docker" }, (b) =>
       Object.freeze({
+        method: "docker",
         context: b.context,
         dockerfile: b.dockerfile,
       })
@@ -171,3 +203,111 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
 
   return proto;
 }
+
+const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
+  if (!proto) {
+    return;
+  }
+
+  const buildValidation = z
+    .discriminatedUnion("method", [
+      z.object({
+        method: z.literal("pack"),
+        context: z.string(),
+        buildpacks: z.array(z.string()).default([]),
+        builder: z.string(),
+      }),
+      z.object({
+        method: z.literal("docker"),
+        context: z.string(),
+        dockerfile: z.string(),
+      }),
+    ])
+    .safeParse(proto);
+
+  if (!buildValidation.success) {
+    return;
+  }
+
+  const build = buildValidation.data;
+
+  return match(build)
+    .with({ method: "pack" }, (b) =>
+      Object.freeze({
+        method: b.method,
+        context: b.context,
+        buildpacks: b.buildpacks.map((b) => ({
+          name: BUILDPACK_TO_NAME[b],
+          buildpack: b,
+        })),
+        builder: b.builder,
+      })
+    )
+    .with({ method: "docker" }, (b) =>
+      Object.freeze({
+        method: b.method,
+        context: b.context,
+        dockerfile: b.dockerfile,
+      })
+    )
+    .exhaustive();
+};
+
+export function clientAppFromProto(
+  proto: PorterApp,
+  overrides: DetectedServices | null
+): ClientPorterApp {
+  const services = Object.entries(proto.services)
+    .map(([name, service]) => serializedServiceFromProto({ name, service }))
+    .map((svc) => {
+      const override = overrides?.services.find(
+        (s) => s.name.value === svc.name
+      );
+
+      if (override) {
+        return deserializeService({
+          service: svc,
+          override: serializeService(override),
+        });
+      }
+      return deserializeService({ service: svc });
+    });
+
+  if (!overrides?.predeploy) {
+    return {
+      name: proto.name,
+      services,
+      env: proto.env,
+      build: clientBuildFromProto(proto.build) ?? {
+        method: "pack",
+        context: "./",
+        buildpacks: [],
+        builder: "",
+      },
+    };
+  }
+
+  const predeployOverrides = serializeService(overrides.predeploy);
+  const predeploy = proto.predeploy
+    ? deserializeService({
+        service: serializedServiceFromProto({
+          name: "pre-deploy",
+          service: proto.predeploy,
+          isPredeploy: true,
+        }),
+        override: predeployOverrides,
+      })
+    : undefined;
+
+  return {
+    name: proto.name,
+    services: [...services, predeploy].filter(valueExists),
+    env: proto.env,
+    build: clientBuildFromProto(proto.build) ?? {
+      method: "pack",
+      context: "./",
+      buildpacks: [],
+      builder: "",
+    },
+  };
+}

+ 32 - 6
dashboard/src/lib/porter-apps/services.ts

@@ -17,6 +17,10 @@ import {
 } from "./values";
 import { Service, ServiceType } from "@porter-dev/api-contracts";
 
+export type DetectedServices = {
+  services: ClientService[];
+  predeploy?: ClientService;
+};
 type ClientServiceType = "web" | "worker" | "job" | "predeploy";
 
 // serviceValidator is the validator for a ClientService
@@ -34,9 +38,12 @@ export const serviceValidator = z.object({
     z.object({
       type: z.literal("web"),
       autoscaling: autoscalingValidator.optional(),
-      ingressEnabled: z.boolean().default(false).optional(),
       domains: domainsValidator,
       healthCheck: healthcheckValidator.optional(),
+      private: serviceBooleanValidator.default({
+        value: false,
+        readOnly: false,
+      }),
     }),
     z.object({
       type: z.literal("worker"),
@@ -72,6 +79,7 @@ export type SerializedService = {
         }[];
         autoscaling?: SerializedAutoscaling;
         healthCheck?: SerializedHealthcheck;
+        private: boolean;
       }
     | {
         type: "worker";
@@ -91,6 +99,13 @@ export function isPredeployService(service: SerializedService | ClientService) {
   return service.config.type == "predeploy";
 }
 
+export function prefixSubdomain(subdomain: string) {
+  if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
+    return subdomain;
+  }
+  return "https://" + subdomain;
+}
+
 export function defaultSerialized({
   name,
   type,
@@ -128,6 +143,7 @@ export function defaultSerialized({
         autoscaling: defaultAutoscaling,
         healthCheck: defaultHealthCheck,
         domains: [],
+        private: false,
       },
     }))
     .with("worker", () => ({
@@ -176,6 +192,7 @@ export function serializeService(service: ClientService): SerializedService {
           domains: config.domains.map((domain) => ({
             name: domain.name.value,
           })),
+          private: config.private.value,
         },
       })
     )
@@ -228,12 +245,17 @@ export function serializeService(service: ClientService): SerializedService {
 
 // deserializeService converts a SerializedService to a ClientService
 // A deserialized ClientService represents the state of a service in the UI and which fields are editable
-export function deserializeService(
-  service: SerializedService,
-  override?: SerializedService
-): ClientService {
+export function deserializeService({
+  service,
+  override,
+  expanded,
+}: {
+  service: SerializedService;
+  override?: SerializedService;
+  expanded?: boolean;
+}): ClientService {
   const baseService = {
-    expanded: true,
+    expanded,
     canDelete: !override,
     name: ServiceField.string(service.name, override?.name),
     run: ServiceField.string(service.run, override?.run),
@@ -271,6 +293,10 @@ export function deserializeService(
               )?.name
             ),
           })),
+          private: ServiceField.boolean(
+            config.private,
+            overrideWebConfig?.private
+          ),
         },
       };
     })

+ 1 - 1
dashboard/src/lib/porter-apps/values.ts

@@ -10,7 +10,7 @@ export type ServiceString = z.infer<typeof serviceStringValidator>;
 // ServiceNumber is a number value in a service that can be read-only or editable
 export const serviceNumberValidator = z.object({
   readOnly: z.boolean(),
-  value: z.number(),
+  value: z.coerce.number(),
 });
 export type ServiceNumber = z.infer<typeof serviceNumberValidator>;
 

+ 20 - 0
dashboard/src/lib/revisions/types.ts

@@ -0,0 +1,20 @@
+import { z } from "zod";
+
+export const appRevisionValidator = z.object({
+  status: z.enum([
+    "CREATED",
+    "AWAITING_BUILD_ARTIFACT",
+    "AWAITING_PREDEPLOY",
+    "READY_TO_APPLY",
+    "DEPLOYED",
+    "BUILD_FAILED",
+    "BUILD_CANCELED",
+    "DEPLOY_FAILED",
+  ]),
+  b64_app_proto: z.string(),
+  revision_number: z.number(),
+  created_at: z.string(),
+  updated_at: z.string(),
+});
+
+export type AppRevision = z.infer<typeof appRevisionValidator>;

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

@@ -423,7 +423,11 @@ const Home: React.FC<Props> = (props) => {
               )}
             </Route>
             <Route path="/apps/:appName/:tab">
-              <ExpandedApp />
+              {currentProject?.validate_apply_v2 ? (
+                <AppView />
+              ) : (
+                <ExpandedApp />
+              )}
             </Route>
             <Route path="/apps/:appName">
               {currentProject?.validate_apply_v2 ? (

+ 285 - 0
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -0,0 +1,285 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppFromProto,
+  porterAppFormValidator,
+} from "lib/porter-apps";
+import { zodResolver } from "@hookform/resolvers/zod";
+import RevisionsList from "./RevisionsList";
+import { useLatestRevision } from "./LatestRevisionContext";
+import Spacer from "components/porter/Spacer";
+import TabSelector from "components/TabSelector";
+import { useHistory } from "react-router";
+import { match } from "ts-pattern";
+import Overview from "./tabs/Overview";
+import { useAppValidation } from "lib/hooks/useAppValidation";
+import api from "shared/api";
+import { useQueryClient } from "@tanstack/react-query";
+import Settings from "./tabs/Settings";
+import BuildSettings from "./tabs/BuildSettings";
+import Environment from "./tabs/Environment";
+import AnimateHeight from "react-animate-height";
+import Banner from "components/porter/Banner";
+import Button from "components/porter/Button";
+import Icon from "components/porter/Icon";
+import save from "assets/save-01.svg";
+import LogsTab from "./tabs/LogsTab";
+
+// commented out tabs are not yet implemented
+// will be included as support is available based on data from app revisions rather than helm releases
+const validTabs = [
+  // "activity",
+  // "events",
+  "overview",
+  "logs",
+  // "metrics",
+  // "debug",
+  "environment",
+  "build-settings",
+  "settings",
+  // "helm-values",
+  // "job-history",
+] as const;
+const DEFAULT_TAB = "overview";
+type ValidTab = typeof validTabs[number];
+
+type AppDataContainerProps = {
+  tabParam?: string;
+};
+
+const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
+  const history = useHistory();
+  const queryClient = useQueryClient();
+  const [redeployOnSave, setRedeployOnSave] = useState(false);
+
+  const {
+    porterApp,
+    latestProto,
+    latestRevision,
+    projectId,
+    clusterId,
+    deploymentTargetId,
+    servicesFromYaml,
+    setPreviewRevision,
+  } = useLatestRevision();
+  const { validateApp } = useAppValidation({
+    deploymentTargetID: deploymentTargetId,
+  });
+
+  const currentTab = useMemo(() => {
+    if (tabParam && validTabs.includes(tabParam as ValidTab)) {
+      return tabParam as ValidTab;
+    }
+
+    return DEFAULT_TAB;
+  }, [tabParam]);
+
+  const latestSource: SourceOptions = useMemo(() => {
+    if (porterApp.image_repo_uri) {
+      const [repository, tag] = porterApp.image_repo_uri.split(":");
+      return {
+        type: "docker-registry",
+        image: {
+          repository,
+          tag,
+        },
+      };
+    }
+
+    return {
+      type: "github",
+      git_repo_id: porterApp.git_repo_id ?? 0,
+      git_repo_name: porterApp.repo_name ?? "",
+      git_branch: porterApp.git_branch ?? "",
+      porter_yaml_path: porterApp.porter_yaml_path ?? "./porter.yaml",
+    };
+  }, [porterApp]);
+
+  const porterAppFormMethods = useForm<PorterAppFormData>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(porterAppFormValidator),
+    defaultValues: {
+      app: clientAppFromProto(latestProto, servicesFromYaml),
+      source: latestSource,
+      deletions: {
+        serviceNames: [],
+      },
+    },
+  });
+  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, latestProto);
+      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,
+      ]);
+      setPreviewRevision(null);
+    } catch (err) {}
+  });
+
+  useEffect(() => {
+    if (servicesFromYaml) {
+      reset({
+        app: clientAppFromProto(latestProto, servicesFromYaml),
+        source: latestSource,
+        deletions: {
+          serviceNames: [],
+        },
+      });
+    }
+  }, [servicesFromYaml, currentTab, latestProto]);
+
+  return (
+    <FormProvider {...porterAppFormMethods}>
+      <form onSubmit={onSubmit}>
+        <RevisionsList
+          latestRevisionNumber={latestRevision.revision_number}
+          deploymentTargetId={deploymentTargetId}
+          projectId={projectId}
+          clusterId={clusterId}
+          appName={porterApp.name}
+          latestSource={latestSource}
+          onSubmit={onSubmit}
+        />
+        <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: "Logs", value: "logs" },
+            { 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 />)
+          .with("logs", () => <LogsTab />)
+          .otherwise(() => null)}
+        <Spacer y={2} />
+      </form>
+    </FormProvider>
+  );
+};
+
+export default AppDataContainer;

+ 187 - 0
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -0,0 +1,187 @@
+import React, { useMemo } from "react";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+
+import web from "assets/web.png";
+import box from "assets/box.png";
+import github from "assets/github-white.png";
+import pr_icon from "assets/pull_request_icon.svg";
+
+import { PorterApp } from "@porter-dev/api-contracts";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import styled from "styled-components";
+import { useLatestRevision } from "./LatestRevisionContext";
+import { prefixSubdomain } from "lib/porter-apps/services";
+import { readableDate } from "shared/string_utils";
+
+// Buildpack icons
+const icons = [
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/go/go-original-wordmark.svg",
+  web,
+];
+
+const AppHeader: React.FC = () => {
+  const { latestProto, porterApp, latestRevision } = useLatestRevision();
+
+  const gitData = useMemo(() => {
+    if (
+      !porterApp.git_branch ||
+      !porterApp.repo_name ||
+      !porterApp.git_repo_id
+    ) {
+      return null;
+    }
+
+    return {
+      id: porterApp.git_repo_id,
+      branch: porterApp.git_branch,
+      repo: porterApp.repo_name,
+    };
+  }, [porterApp]);
+
+  const getIconSvg = (build: PorterApp["build"]) => {
+    if (!build) {
+      return box;
+    }
+
+    const bp = build.buildpacks[0]?.split("/")[1];
+    switch (bp) {
+      case "ruby":
+        return icons[0];
+      case "nodejs":
+        return icons[1];
+      case "python":
+        return icons[2];
+      case "go":
+        return icons[3];
+      default:
+        return box;
+    }
+  };
+
+  const displayDomain = useMemo(() => {
+    const domains = Object.values(latestProto.services).reduce(
+      (acc: string[], s) => {
+        if (s.config.case === "webConfig") {
+          const names = s.config.value.domains.map((d) => d.name);
+          return [...acc, ...names];
+        }
+
+        return acc;
+      },
+      []
+    );
+
+    return domains.length === 1 ? prefixSubdomain(domains[0]) : "";
+  }, [latestProto]);
+
+  return (
+    <>
+      <Container row>
+        <Icon src={getIconSvg(latestProto.build)} height={"24px"} />
+        <Spacer inline x={1} />
+        <Text size={21}>{latestProto.name}</Text>
+        {gitData && (
+          <>
+            <Spacer inline x={1} />
+            <Container row>
+              <A target="_blank" href={`https://github.com/${gitData.repo}`}>
+                <SmallIcon src={github} />
+                <Text size={13}>{gitData.repo}</Text>
+              </A>
+            </Container>
+            <Spacer inline x={1} />
+            <TagWrapper>
+              Branch
+              <BranchTag>
+                <BranchIcon src={pr_icon} />
+                {gitData.branch}
+              </BranchTag>
+            </TagWrapper>
+          </>
+        )}
+        {!gitData && porterApp.image_repo_uri && (
+          <>
+            <Spacer inline x={1} />
+            <Container row>
+              <SmallIcon
+                height="19px"
+                src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+              />
+              <Text size={13} color="helper">
+                {porterApp.image_repo_uri}
+              </Text>
+            </Container>
+          </>
+        )}
+      </Container>
+      <Spacer y={0.5} />
+      {displayDomain && (
+        <>
+          <Container>
+            <Text>
+              <a href={displayDomain} target="_blank">
+                {displayDomain}
+              </a>
+            </Text>
+          </Container>
+          <Spacer y={0.5} />
+        </>
+      )}
+      <Text color="#aaaabb66">
+        Last deployed {readableDate(latestRevision.created_at)}
+      </Text>
+    </>
+  );
+};
+
+export default AppHeader;
+
+const A = styled.a`
+  display: flex;
+  align-items: center;
+`;
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  height: ${(props) => props.height || "15px"};
+  opacity: ${(props) => props.opacity || 1};
+  margin-right: 10px;
+`;
+const BranchIcon = styled.img`
+  height: 14px;
+  opacity: 0.65;
+  margin-right: 5px;
+`;
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 6px;
+`;
+const BranchTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;

+ 20 - 158
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -1,24 +1,16 @@
-import React, { useContext } from "react";
-import { useMemo } from "react";
+import React, { useMemo } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
-import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
-import Loading from "components/Loading";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
-import { PorterApp } from "@porter-dev/api-contracts";
 import styled from "styled-components";
-import Back from "components/porter/Back";
-import Container from "components/porter/Container";
-import web from "assets/web.png";
-import box from "assets/box.png";
-import github from "assets/github-white.png";
-import pr_icon from "assets/pull_request_icon.svg";
 
-import Icon from "components/porter/Icon";
+import Back from "components/porter/Back";
 import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
+
+import AppDataContainer from "./AppDataContainer";
+
+import web from "assets/web.png";
+import AppHeader from "./AppHeader";
+import { LatestRevisionProvider } from "./LatestRevisionContext";
 
 export const porterAppValidator = z.object({
   name: z.string(),
@@ -32,6 +24,7 @@ export const porterAppValidator = z.object({
   image_repo_uri: z.string().optional(),
   porter_yaml_path: z.string().optional(),
 });
+export type PorterAppRecord = z.infer<typeof porterAppValidator>;
 
 // Buildpack icons
 const icons = [
@@ -63,165 +56,34 @@ type ValidTab = typeof validTabs[number];
 type Props = RouteComponentProps & {};
 
 const AppView: React.FC<Props> = ({ match }) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const deploymentTarget = useDefaultDeploymentTarget();
-
   const params = useMemo(() => {
     const { params } = match;
     const validParams = z
       .object({
         appName: z.string(),
+        tab: z.string().optional(),
       })
       .safeParse(params);
 
     if (!validParams.success) {
       return {
-        appName: null,
+        appName: undefined,
+        tab: undefined,
       };
     }
 
     return validParams.data;
   }, [match]);
 
-  const appParamsExist =
-    !!params.appName &&
-    !!currentCluster &&
-    !!currentProject &&
-    !!deploymentTarget;
-
-  const { data: appData, status: porterAppStatus } = useQuery(
-    ["getPorterApp", currentCluster?.id, currentProject?.id, params.appName],
-    async () => {
-      if (!appParamsExist) {
-        return;
-      }
-
-      const res = await api.getPorterApp(
-        "<token>",
-        {},
-        {
-          cluster_id: currentCluster.id,
-          project_id: currentProject.id,
-          name: params.appName,
-        }
-      );
-
-      const porterApp = await porterAppValidator.parseAsync(res.data);
-      return porterApp;
-    },
-    {
-      enabled: appParamsExist,
-    }
-  );
-
-  const { data: revision, status } = useQuery(
-    ["getAppRevision", params.appName, "latest"],
-    async () => {
-      if (!appParamsExist) {
-        return null;
-      }
-
-      const res = await api.getLatestRevision(
-        "<token>",
-        {
-          deployment_target_id: deploymentTarget.deployment_target_id,
-        },
-        {
-          cluster_id: currentCluster.id,
-          project_id: currentProject.id,
-          porter_app_name: params.appName,
-        }
-      );
-
-      const rawAppData = await z
-        .object({
-          b64_app_proto: z.string(),
-        })
-        .parseAsync(res.data);
-
-      const porterApp = PorterApp.fromJsonString(
-        atob(rawAppData.b64_app_proto)
-      );
-
-      return porterApp;
-    },
-    {
-      enabled: appParamsExist,
-    }
-  );
-
-  const gitData = useMemo(() => {
-    if (!appData?.git_branch || !appData?.repo_name || !appData?.git_repo_id) {
-      return null;
-    }
-
-    return {
-      id: appData.git_repo_id,
-      branch: appData.git_branch,
-      repo: appData.repo_name,
-    };
-  }, [appData]);
-
-  const getIconSvg = (build: PorterApp["build"]) => {
-    if (!build) {
-      return box;
-    }
-
-    const bp = build.buildpacks[0].split("/")[1];
-    switch (bp) {
-      case "ruby":
-        return icons[0];
-      case "nodejs":
-        return icons[1];
-      case "python":
-        return icons[2];
-      case "go":
-        return icons[3];
-      default:
-        return box;
-    }
-  };
-
-  if (
-    status === "loading" ||
-    porterAppStatus === "loading" ||
-    !appParamsExist
-  ) {
-    return <Loading />;
-  }
-
-  if (status === "error" || porterAppStatus === "error" || !revision) {
-    return <div>error</div>;
-  }
-
   return (
-    <StyledExpandedApp>
-      <Back to="/apps" />
-      <Container row>
-        <Icon src={getIconSvg(revision.build)} height={"24px"} />
-        <Spacer inline x={1} />
-        <Text size={21}>{revision.name}</Text>
-        {gitData && (
-          <>
-            <Spacer inline x={1} />
-            <Container row>
-              <A target="_blank" href={`https://github.com/${gitData.repo}`}>
-                <SmallIcon src={github} />
-                <Text size={13}>{gitData.repo}</Text>
-              </A>
-            </Container>
-            <Spacer inline x={1} />
-            <TagWrapper>
-              Branch
-              <BranchTag>
-                <BranchIcon src={pr_icon} />
-                {gitData.branch}
-              </BranchTag>
-            </TagWrapper>
-          </>
-        )}
-      </Container>
-    </StyledExpandedApp>
+    <LatestRevisionProvider appName={params.appName}>
+      <StyledExpandedApp>
+        <Back to="/apps" />
+        <AppHeader />
+        <Spacer y={0.5} />
+        <AppDataContainer tabParam={params.tab} />
+      </StyledExpandedApp>
+    </LatestRevisionProvider>
   );
 };
 

+ 221 - 0
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -0,0 +1,221 @@
+import React, { Dispatch, SetStateAction, useMemo, useState } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import { createContext, useContext } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { PorterAppRecord, porterAppValidator } from "./AppView";
+import { z } from "zod";
+import { AppRevision, appRevisionValidator } from "lib/revisions/types";
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import notFound from "assets/not-found.png";
+import styled from "styled-components";
+import { SourceOptions } from "lib/porter-apps";
+import { usePorterYaml } from "lib/hooks/usePorterYaml";
+import { DetectedServices } from "lib/porter-apps/services";
+
+export const LatestRevisionContext = createContext<{
+  porterApp: PorterAppRecord;
+  latestRevision: AppRevision;
+  latestProto: PorterApp;
+  servicesFromYaml: DetectedServices | null;
+  clusterId: number;
+  projectId: number;
+  deploymentTargetId: string;
+  previewRevision: number | null;
+  setPreviewRevision: Dispatch<SetStateAction<number | null>>;
+} | null>(null);
+
+export const useLatestRevision = () => {
+  const context = useContext(LatestRevisionContext);
+  if (context === null) {
+    throw new Error(
+      "useLatestRevision must be used within a LatestRevisionContext"
+    );
+  }
+  return context;
+};
+
+export const LatestRevisionProvider = ({
+  appName,
+  children,
+}: {
+  appName?: string;
+  children: JSX.Element;
+}) => {
+  const [previewRevision, setPreviewRevision] = useState<number | null>(null);
+  const { currentCluster, currentProject } = useContext(Context);
+  const deploymentTarget = useDefaultDeploymentTarget();
+
+  const appParamsExist =
+    !!appName && !!currentCluster && !!currentProject && !!deploymentTarget;
+
+  const { data: porterApp, status: porterAppStatus } = useQuery(
+    ["getPorterApp", currentCluster?.id, currentProject?.id, appName],
+    async () => {
+      if (!appParamsExist) {
+        return;
+      }
+
+      const res = await api.getPorterApp(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          name: appName,
+        }
+      );
+
+      const porterApp = await porterAppValidator.parseAsync(res.data);
+      return porterApp;
+    },
+    {
+      enabled: appParamsExist,
+    }
+  );
+
+  const { data: latestRevision, status } = useQuery(
+    [
+      "getLatestRevision",
+      currentProject?.id,
+      currentCluster?.id,
+      deploymentTarget?.deployment_target_id,
+      appName,
+    ],
+    async () => {
+      if (!appParamsExist) {
+        return;
+      }
+      const res = await api.getLatestRevision(
+        "<token>",
+        {
+          deployment_target_id: deploymentTarget.deployment_target_id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          porter_app_name: appName,
+        }
+      );
+
+      const revisionData = await z
+        .object({
+          app_revision: appRevisionValidator,
+        })
+        .parseAsync(res.data);
+
+      return revisionData.app_revision;
+    },
+    {
+      enabled: appParamsExist,
+      refetchInterval: 5000,
+    }
+  );
+
+  const latestSource: SourceOptions | null = useMemo(() => {
+    if (!porterApp) {
+      return null;
+    }
+
+    if (porterApp.image_repo_uri) {
+      const [repository, tag] = porterApp.image_repo_uri.split(":");
+      return {
+        type: "docker-registry",
+        image: {
+          repository,
+          tag,
+        },
+      };
+    }
+
+    return {
+      type: "github",
+      git_repo_id: porterApp.git_repo_id ?? 0,
+      git_repo_name: porterApp.repo_name ?? "",
+      git_branch: porterApp.git_branch ?? "",
+      porter_yaml_path: porterApp.porter_yaml_path ?? "./porter.yaml",
+    };
+  }, [porterApp]);
+
+  const { loading: porterYamlLoading, detectedServices } = usePorterYaml({
+    source: latestSource,
+    useDefaults: false,
+  });
+
+  const latestProto = useMemo(() => {
+    if (!latestRevision) {
+      return;
+    }
+
+    return PorterApp.fromJsonString(atob(latestRevision.b64_app_proto));
+  }, [latestRevision]);
+
+  if (
+    status === "loading" ||
+    porterAppStatus === "loading" ||
+    !appParamsExist ||
+    porterYamlLoading
+  ) {
+    return <Loading />;
+  }
+
+  if (
+    status === "error" ||
+    porterAppStatus === "error" ||
+    !latestRevision ||
+    !latestProto ||
+    !porterApp
+  ) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No application matching "{appName}" was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/apps">Return to dashboard</Link>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <LatestRevisionContext.Provider
+      value={{
+        latestRevision,
+        latestProto,
+        porterApp,
+        clusterId: currentCluster.id,
+        projectId: currentProject.id,
+        deploymentTargetId: deploymentTarget.deployment_target_id,
+        servicesFromYaml: detectedServices,
+        previewRevision,
+        setPreviewRevision,
+      }}
+    >
+      {children}
+    </LatestRevisionContext.Provider>
+  );
+};
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;

+ 502 - 0
dashboard/src/main/home/app-dashboard/app-view/RevisionsList.tsx

@@ -0,0 +1,502 @@
+import { useQuery } from "@tanstack/react-query";
+import { AppRevision, appRevisionValidator } from "lib/revisions/types";
+import React, { useCallback, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+import loading from "assets/loading.gif";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppFromProto,
+} from "lib/porter-apps";
+import { z } from "zod";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { readableDate } from "shared/string_utils";
+import Text from "components/porter/Text";
+import { useLatestRevision } from "./LatestRevisionContext";
+import { useFormContext } from "react-hook-form";
+import ConfirmOverlay from "components/porter/ConfirmOverlay";
+
+type Props = {
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  latestSource: SourceOptions;
+  latestRevisionNumber: number;
+  onSubmit: () => Promise<void>;
+};
+
+const RED = "#ff0000";
+const YELLOW = "#FFA500";
+
+const RevisionsList: React.FC<Props> = ({
+  latestRevisionNumber,
+  deploymentTargetId,
+  projectId,
+  clusterId,
+  appName,
+  latestSource,
+  onSubmit,
+}) => {
+  const {
+    previewRevision,
+    setPreviewRevision,
+    servicesFromYaml,
+  } = useLatestRevision();
+  const { reset, setValue } = useFormContext<PorterAppFormData>();
+  const [expandRevisions, setExpandRevisions] = useState(false);
+  const [revertData, setRevertData] = useState<{
+    app: PorterApp;
+    revision: number;
+  } | null>(null);
+
+  const res = useQuery(
+    ["listAppRevisions", projectId, clusterId, latestRevisionNumber, appName],
+    async () => {
+      const res = await api.listAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: deploymentTargetId,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: appName,
+        }
+      );
+
+      const revisions = await z
+        .object({
+          app_revisions: z.array(appRevisionValidator),
+        })
+        .parseAsync(res.data);
+
+      return revisions;
+    }
+  );
+
+  const getReadableStatus = (status: AppRevision["status"]) =>
+    match(status)
+      .with("CREATED", () => "Created")
+      .with("AWAITING_BUILD_ARTIFACT", () => "Awaiting Build")
+      .with("READY_TO_APPLY", () => "Deploying")
+      .with("AWAITING_PREDEPLOY", () => "Awaiting Pre-Deploy")
+      .with("BUILD_CANCELED", () => "Build Canceled")
+      .with("BUILD_FAILED", () => "Build Failed")
+      .with("DEPLOY_FAILED", () => "Deploy Failed")
+      .with("DEPLOYED", () => "Deployed")
+      .exhaustive();
+
+  const getDotColor = (status: AppRevision["status"]) =>
+    match(status)
+      .with(
+        "CREATED",
+        "AWAITING_BUILD_ARTIFACT",
+        "READY_TO_APPLY",
+        "AWAITING_PREDEPLOY",
+        () => YELLOW
+      )
+      .otherwise(() => RED);
+
+  const getTableHeader = (latestRevision?: AppRevision) => {
+    if (!latestRevision) {
+      return "Revisions";
+    }
+
+    if (previewRevision) {
+      return "Previewing revision (not deployed) -";
+    }
+
+    return "Current revision - ";
+  };
+
+  const getSelectedRevisionNumber = (args: {
+    numDeployed: number;
+    latestRevision?: AppRevision;
+  }) => {
+    const { numDeployed, latestRevision } = args;
+
+    if (previewRevision) {
+      return previewRevision;
+    }
+
+    if (latestRevision && latestRevision.revision_number !== 0) {
+      return latestRevision.revision_number;
+    }
+
+    return numDeployed + 1;
+  };
+
+  const onRevert = useCallback(async () => {
+    if (!revertData) {
+      return;
+    }
+
+    setValue("app", clientAppFromProto(revertData.app, servicesFromYaml));
+    setRevertData(null);
+
+    void onSubmit();
+  }, [onSubmit, setValue, revertData]);
+
+  const renderContents = (revisions: AppRevision[]) => {
+    const revisionsWithProto = revisions.map((revision) => {
+      return {
+        ...revision,
+        app_proto: PorterApp.fromJsonString(atob(revision.b64_app_proto)),
+      };
+    });
+
+    const deployedRevisions = revisionsWithProto.filter(
+      (r) => r.revision_number !== 0
+    );
+    const pendingRevisions = revisionsWithProto.filter(
+      (r) => r.revision_number === 0
+    );
+
+    return (
+      <div>
+        <RevisionHeader
+          showRevisions={expandRevisions}
+          isCurrent={!previewRevision}
+          onClick={() => {
+            setExpandRevisions((prev) => !prev);
+          }}
+        >
+          <RevisionPreview>
+            <i className="material-icons">arrow_drop_down</i>
+            {getTableHeader(revisions[0])}
+            {revisions[0] ? (
+              <Revision>
+                No.{" "}
+                {getSelectedRevisionNumber({
+                  numDeployed: deployedRevisions.length,
+                  latestRevision: revisions[0],
+                })}
+              </Revision>
+            ) : null}
+          </RevisionPreview>
+        </RevisionHeader>
+        <RevisionList>
+          <TableWrapper>
+            <RevisionsTable>
+              <tbody>
+                <Tr disableHover>
+                  <Th>Revision no.</Th>
+                  <Th>
+                    {revisionsWithProto[0]?.app_proto.build
+                      ? "Commit SHA"
+                      : "Image Tag"}
+                  </Th>
+                  <Th>Timestamp</Th>
+                  <Th>Status</Th>
+                  <Th>Rollback</Th>
+                </Tr>
+                {pendingRevisions.length > 0 &&
+                  pendingRevisions.map((revision) => (
+                    <Tr key={new Date(revision.updated_at).toUTCString()}>
+                      <Td>{deployedRevisions.length + 1}</Td>
+                      <Td>
+                        {revision.app_proto.build
+                          ? revision.app_proto.build.commitSha.substring(0, 7)
+                          : revision.app_proto.image?.tag}
+                      </Td>
+                      <Td>{readableDate(revision.updated_at)}</Td>
+                      <Td>
+                        <StatusContainer>
+                          <Text>{getReadableStatus(revision.status)}</Text>
+                          <StatusDot color={getDotColor(revision.status)} />
+                        </StatusContainer>
+                      </Td>
+                      <Td>-</Td>
+                    </Tr>
+                  ))}
+
+                {deployedRevisions.map((revision, i) => {
+                  const isLatestDeployedRevision =
+                    latestRevisionNumber !== 0
+                      ? revision.revision_number === latestRevisionNumber
+                      : i === 0;
+
+                  return (
+                    <Tr
+                      key={revision.revision_number}
+                      selected={
+                        previewRevision
+                          ? revision.revision_number === previewRevision
+                          : isLatestDeployedRevision
+                      }
+                      onClick={() => {
+                        reset({
+                          app: clientAppFromProto(
+                            revision.app_proto,
+                            servicesFromYaml
+                          ),
+                          source: latestSource,
+                        });
+                        setPreviewRevision(
+                          isLatestDeployedRevision
+                            ? null
+                            : revision.revision_number
+                        );
+                      }}
+                    >
+                      <Td>{revision.revision_number}</Td>
+
+                      <Td>
+                        {revision.app_proto.build
+                          ? revision.app_proto.build.commitSha.substring(0, 7)
+                          : revision.app_proto.image?.tag}
+                      </Td>
+                      <Td>{readableDate(revision.updated_at)}</Td>
+                      <Td>
+                        {!isLatestDeployedRevision ? (
+                          getReadableStatus(revision.status)
+                        ) : (
+                          <StatusContainer>
+                            <Text>{getReadableStatus(revision.status)}</Text>
+                            <StatusDot />
+                          </StatusContainer>
+                        )}
+                      </Td>
+                      <Td>
+                        <RollbackButton
+                          disabled={isLatestDeployedRevision}
+                          onClick={() => {
+                            if (isLatestDeployedRevision) {
+                              return;
+                            }
+
+                            setRevertData({
+                              app: revision.app_proto,
+                              revision: revision.revision_number,
+                            });
+                          }}
+                        >
+                          {isLatestDeployedRevision ? "Current" : "Revert"}
+                        </RollbackButton>
+                      </Td>
+                    </Tr>
+                  );
+                })}
+              </tbody>
+            </RevisionsTable>
+          </TableWrapper>
+        </RevisionList>
+      </div>
+    );
+  };
+
+  return (
+    <StyledRevisionSection showRevisions={expandRevisions}>
+      {match(res)
+        .with({ status: "loading" }, () => (
+          <LoadingPlaceholder>
+            <StatusWrapper>
+              <LoadingGif src={loading} revision={false} /> Updating . . .
+            </StatusWrapper>
+          </LoadingPlaceholder>
+        ))
+        .with({ status: "success" }, ({ data }) =>
+          renderContents(data.app_revisions)
+        )
+        .otherwise(() => null)}
+      {revertData ? (
+        <ConfirmOverlay
+          message={`Are you sure you want to revert to revision ${revertData?.revision}?`}
+          onYes={onRevert}
+          onNo={() => {
+            setRevertData(null);
+          }}
+        />
+      ) : null}
+    </StyledRevisionSection>
+  );
+};
+
+export default RevisionsList;
+
+const StyledRevisionSection = styled.div`
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "255px" : "40px"};
+  margin: 20px 0px 18px;
+  overflow: hidden;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "expandRevisions 0.3s" : ""};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: ${(props: { revision: boolean }) =>
+    props.revision ? "0px" : "9px"};
+  margin-left: ${(props: { revision: boolean }) =>
+    props.revision ? "10px" : "0px"};
+  margin-bottom: ${(props: { revision: boolean }) =>
+    props.revision ? "-2px" : "0px"};
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  background: ${({ theme }) => theme.fg};
+  :hover {
+    background: ${(props) => props.showRevisions && props.theme.fg2};
+  }
+  > div > i {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
+  }
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionList = styled.div`
+  overflow-y: auto;
+  max-height: 215px;
+`;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const Tr = styled.tr`
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#405eddbb"};
+  }
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StatusDot = styled.div<{ color?: string }>`
+  min-width: 7px;
+  max-width: 7px;
+  height: 7px;
+  margin-left: 10px;
+  border-radius: 50%;
+  background: ${(props) => props.color || "#38a88a"};
+
+  box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
+  transform: scale(1);
+  animation: pulse 2s infinite;
+  @keyframes pulse {
+    0% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.9);
+    }
+
+    70% {
+      transform: scale(1);
+      box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
+    }
+
+    100% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
+    }
+  }
+`;

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

+ 35 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/LogsTab.tsx

@@ -0,0 +1,35 @@
+import { PorterApp } from "@porter-dev/api-contracts";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import React, { useMemo } from "react";
+import { useFormContext, useFormState } from "react-hook-form";
+import Logs from "../../validate-apply/logs/Logs"
+import {
+    defaultSerialized,
+    deserializeService,
+} from "lib/porter-apps/services";
+import Error from "components/porter/Error";
+import Button from "components/porter/Button";
+import { useLatestRevision } from "../LatestRevisionContext";
+
+const LogsTab: React.FC = () => {
+    const { projectId, clusterId, latestProto , deploymentTargetId} = useLatestRevision();
+
+    const appName = latestProto.name
+    const serviceNames = Object.keys(latestProto.services)
+
+    return (
+        <>
+            <Logs
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                serviceNames={serviceNames}
+                deploymentTargetId={deploymentTargetId}
+            />
+        </>
+    );
+};
+
+export default LogsTab;

+ 67 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -0,0 +1,67 @@
+import { PorterApp } from "@porter-dev/api-contracts";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import React, { useMemo } from "react";
+import { useFormContext, useFormState } from "react-hook-form";
+import ServiceList from "../../validate-apply/services-settings/ServiceList";
+import {
+  defaultSerialized,
+  deserializeService,
+} from "lib/porter-apps/services";
+import Error from "components/porter/Error";
+import Button from "components/porter/Button";
+import { useLatestRevision } from "../LatestRevisionContext";
+
+const Overview: React.FC = () => {
+  const { formState } = useFormContext<PorterAppFormData>();
+  const { porterApp } = useLatestRevision();
+
+  const buttonStatus = useMemo(() => {
+    if (formState.isSubmitting) {
+      return "loading";
+    }
+
+    if (Object.keys(formState.errors).length > 0) {
+      return <Error message="Unable to update app" />;
+    }
+
+    return "";
+  }, [formState.isSubmitting, formState.errors]);
+
+  return (
+    <>
+      {porterApp.git_repo_id && (
+        <>
+          <Text size={16}>Pre-deploy job</Text>
+          <Spacer y={0.5} />
+          <ServiceList
+            addNewText={"Add a new pre-deploy job"}
+            prePopulateService={deserializeService({
+              service: defaultSerialized({
+                name: "pre-deploy",
+                type: "predeploy",
+              }),
+            })}
+            isPredeploy
+          />
+          <Spacer y={0.5} />
+        </>
+      )}
+      <Text size={16}>Application services</Text>
+      <Spacer y={0.5} />
+      <ServiceList addNewText={"Add a new service"} />
+      <Spacer y={0.75} />
+      <Button
+        type="submit"
+        status={buttonStatus}
+        loadingText={"Updating..."}
+        disabled={formState.isSubmitting || !formState.isDirty}
+      >
+        Update app
+      </Button>
+    </>
+  );
+};
+
+export default Overview;

+ 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%;
+`;

+ 28 - 50
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -18,7 +18,6 @@ import { Context } from "shared/Context";
 import {
   PorterAppFormData,
   SourceOptions,
-  clientAppToProto,
   porterAppFormValidator,
 } from "lib/porter-apps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
@@ -36,13 +35,14 @@ import EnvVariables from "../validate-apply/app-settings/EnvVariables";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { valueExists } from "shared/util";
 import api from "shared/api";
-import { z } from "zod";
 import { PorterApp } from "@porter-dev/api-contracts";
 import GithubActionModal from "../new-app-flow/GithubActionModal";
 import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import Error from "components/porter/Error";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { useAppValidation } from "lib/hooks/useAppValidation";
 import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -98,10 +98,10 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       app: {
         name: "",
         build: {
+          method: "pack",
           context: "./",
           builder: "",
           buildpacks: [],
-          dockerfile: "",
         },
       },
       source: {
@@ -109,6 +109,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         git_branch: "",
         porter_yaml_path: "./porter.yaml",
       },
+      deletions: {
+        serviceNames: [],
+      }
     },
   });
   const {
@@ -126,45 +129,20 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const build = watch("app.build");
   const image = watch("source.image");
   const services = watch("app.services");
-  const servicesFromYaml = usePorterYaml(source);
+  const { detectedServices: servicesFromYaml } = usePorterYaml({ source });
   const deploymentTarget = useDefaultDeploymentTarget();
   const { updateAppStep } = useAppAnalytics(name);
+  const { validateApp } = useAppValidation({
+    deploymentTargetID: deploymentTarget?.deployment_target_id,
+    creating: true,
+  });
 
   const onSubmit = handleSubmit(async (data) => {
     try {
-      if (!currentProject || !currentCluster) {
-        return;
-      }
-
-      if (!deploymentTarget) {
-        return;
-      }
-
-      const proto = clientAppToProto(data);
-      const res = await api.validatePorterApp(
-        "<token>",
-        {
-          b64_app_proto: btoa(proto.toJsonString()),
-          deployment_target_id: deploymentTarget.deployment_target_id,
-          commit_sha: "",
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-
-      const validAppData = await z
-        .object({
-          validate_b64_app_proto: z.string(),
-        })
-        .parseAsync(res.data);
-
-      const validatedAppProto = PorterApp.fromJsonString(
-        atob(validAppData.validate_b64_app_proto)
-      );
-
+      setDeployError("");
+      const validatedAppProto = await validateApp(data);
       setValidatedAppProto(validatedAppProto);
+
       if (source?.type === "github") {
         setShowGHAModal(true);
         return;
@@ -192,7 +170,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     }) => {
       setIsDeploying(true);
       // log analytics event that we started form submission
-      updateAppStep("stack-launch-complete");
+      updateAppStep({ step: "stack-launch-complete" });
 
       try {
         if (!currentProject?.id || !currentCluster?.id) {
@@ -228,7 +206,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         );
 
         // log analytics event that we successfully deployed
-        updateAppStep("stack-launch-success");
+        updateAppStep({ step: "stack-launch-success" });
 
         if (source.type === "docker-registry") {
           history.push(`/apps/${app.name}`);
@@ -237,14 +215,17 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         return true;
       } catch (err) {
         if (axios.isAxiosError(err) && err.response?.data?.error) {
-          updateAppStep("stack-launch-failure", err.response?.data?.error);
+          updateAppStep({
+            step: "stack-launch-failure",
+            errorMessage: err.response?.data?.error,
+          });
           setDeployError(err.response?.data?.error);
           return false;
         }
 
         const msg =
           "An error occurred while deploying your application. Please try again.";
-        updateAppStep("stack-launch-failure", msg);
+        updateAppStep({ step: "stack-launch-failure", errorMessage: msg });
         setDeployError(msg);
         return false;
       } finally {
@@ -365,8 +346,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       setError("app.name", {
         message: "An app with this name already exists",
       });
+      return;
     }
-  }, [porterApps]);
+  }, [porterApps, name]);
 
   if (!currentProject || !currentCluster) {
     return null;
@@ -474,10 +456,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       )}
                     </Container>
                     <Spacer y={0.5} />
-                    <ServiceList
-                      defaultExpanded={true}
-                      addNewText={"Add a new service"}
-                    />
+                    <ServiceList addNewText={"Add a new service"} />
                   </>,
                   <>
                     <Text size={16}>Environment variables (optional)</Text>
@@ -498,14 +477,13 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       </Text>
                       <Spacer y={0.5} />
                       <ServiceList
-                        limitOne={true}
                         addNewText={"Add a new pre-deploy job"}
-                        prePopulateService={deserializeService(
-                          defaultSerialized({
+                        prePopulateService={deserializeService({
+                          service: defaultSerialized({
                             name: "pre-deploy",
                             type: "predeploy",
-                          })
-                        )}
+                          }),
+                        })}
                         isPredeploy
                       />
                     </>

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

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

+ 2 - 3
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -462,6 +462,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       } else {
         setButtonStatus(<Error message="Unable to update app" />);
       }
+      // redirect to the default tab
+      history.push(`/apps/${appData.app.name}/${DEFAULT_TAB}`);
     } catch (err) {
       // TODO: better error handling
       const errMessage =
@@ -470,9 +472,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         "An error occurred while deploying your app. Please try again.";
       setButtonStatus(<Error message={errMessage} />);
     }
-
-    // redirect to the default tab
-    history.push(`/apps/${appData.app.name}/${DEFAULT_TAB}`);
   };
 
   const fetchPorterYamlContent = async (

+ 0 - 14
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -227,20 +227,6 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData }) => {
           <Pagination page={page} setPage={setPage} totalPages={numPages} />
         </>
       )}
-      <Spacer y={1} />
-      <Container row spaced>
-        <Spacer inline x={1} />
-        <Button
-          onClick={getEvents}
-          height="20px"
-          color="fg"
-          withBorder
-        >
-          <Icon src={refresh} height="10px"></Icon>
-          <Spacer inline x={0.5} />
-          Refresh feed
-        </Button>
-      </Container>
     </StyledActivityFeed>
   );
 };

+ 4 - 4
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -11,7 +11,7 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
 
-import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import Link from "components/porter/Link";
 import document from "assets/document.svg";
@@ -26,11 +26,11 @@ const PreDeployEventCard: React.FC<Props> = ({ event, appData }) => {
   const renderStatusText = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":
-        return <Text color="#68BF8B">Pre-deploy succeeded</Text>;
+        return <Text color={getStatusColor(event.status)}>Pre-deploy succeeded</Text>;
       case "FAILED":
-        return <Text color="#FF6060">Pre-deploy failed</Text>;
+        return <Text color={getStatusColor(event.status)}>Pre-deploy failed</Text>;
       default:
-        return <Text color="helper">Pre-deploy in progress...</Text>;
+        return <Text color={getStatusColor(event.status)}>Pre-deploy in progress...</Text>;
     }
   };
 

+ 7 - 13
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx

@@ -11,7 +11,7 @@ import styled from "styled-components";
 import spinner from "assets/loading.gif";
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { useLogs } from "./utils";
+import { getPodSelectorFromServiceName, useLogs } from "./utils";
 import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "./types";
 import dayjs, { Dayjs } from "dayjs";
 import Loading from "components/Loading";
@@ -61,20 +61,12 @@ const LogSection: React.FC<Props> = ({
   const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
   const [isLoading, setIsLoading] = useState(true);
   const [logsError, setLogsError] = useState<string | undefined>(undefined);
-  const getSelectorFromServiceQueryParam = (serviceName: string | null | undefined) => {
-    if (serviceName == null) {
-      return undefined;
-    }
-    const match = services?.find(s => s.name == serviceName);
-    if (match == null) {
-      return undefined;
-    }
-    return `${match.name}-${match.type == "worker" ? "wkr" : match.type}`;
-  }
+
   const [selectedFilterValues, setSelectedFilterValues] = useState<Record<LogFilterName, string>>({
     revision: filterOpts?.revision ?? GenericLogFilter.getDefaultOption("revision").value,
     output_stream: filterOpts?.output_stream ?? GenericLogFilter.getDefaultOption("output_stream").value,
-    pod_name: getSelectorFromServiceQueryParam(filterOpts?.service) ?? GenericLogFilter.getDefaultOption("pod_name").value,
+    pod_name: getPodSelectorFromServiceName(filterOpts?.service, services) ?? GenericLogFilter.getDefaultOption("pod_name").value,
+    service_name: filterOpts?.service ?? GenericLogFilter.getDefaultOption("service_name").value,
   });
 
   const createVersionOptions = (number: number) => {
@@ -197,7 +189,8 @@ const LogSection: React.FC<Props> = ({
     setSelectedFilterValues({
       revision: filterOpts?.revision ?? GenericLogFilter.getDefaultOption("revision").value,
       output_stream: filterOpts?.output_stream ?? GenericLogFilter.getDefaultOption("output_stream").value,
-      pod_name: getSelectorFromServiceQueryParam(filterOpts?.service) ?? GenericLogFilter.getDefaultOption("pod_name").value,
+      pod_name: getPodSelectorFromServiceName(filterOpts?.service, services) ?? GenericLogFilter.getDefaultOption("pod_name").value,
+      service_name: filterOpts?.service ?? GenericLogFilter.getDefaultOption("service_name").value,
     });
   };
 
@@ -285,6 +278,7 @@ const LogSection: React.FC<Props> = ({
                   logs={logs}
                   appName={appName}
                   filters={filters}
+                  services={services}
                 />
                 <LoadMoreButton
                   active={selectedDate && logs.length !== 0}

+ 22 - 5
dashboard/src/main/home/app-dashboard/expanded-app/logs/StyledLogs.tsx

@@ -3,19 +3,22 @@ import { GenericLogFilter, PorterLog } from "./types";
 import styled from "styled-components";
 import Anser from "anser";
 import dayjs from "dayjs";
-import { getPodSelectorFromPodNameAndAppName, getServiceNameFromPodNameAndAppName, getVersionTagColor } from "./utils";
+import { getPodSelectorFromServiceName, getServiceNameFromPodNameAndAppName, getVersionTagColor } from "./utils";
+import { Service } from "../../new-app-flow/serviceTypes";
 
 
 type Props = {
     logs: PorterLog[];
     appName: string;
     filters: GenericLogFilter[];
+    services?: Service[];
 };
 
 const StyledLogs: React.FC<Props> = ({
     logs,
     appName,
     filters,
+    services,
 }) => {
     const renderFilterTagForLog = (filter: GenericLogFilter, log: PorterLog, index: number) => {
         if (log.metadata == null) {
@@ -27,7 +30,7 @@ const StyledLogs: React.FC<Props> = ({
                     return null;
                 }
                 return (
-                    <StyledLogsTableData width={"100px"}>
+                    <StyledLogsTableData width={"100px"} key={index}>
                         <LogInnerPill
                             color={getVersionTagColor(log.metadata.revision)}
                             key={index}
@@ -42,16 +45,31 @@ const StyledLogs: React.FC<Props> = ({
                     return null;
                 }
                 return (
-                    <StyledLogsTableData width={"100px"}>
+                    <StyledLogsTableData width={"100px"} key={index}>
                         <LogInnerPill
                             color={"white"}
                             key={index}
-                            onClick={() => filter.setValue(getPodSelectorFromPodNameAndAppName(log.metadata.pod_name, appName))}
+                            onClick={() => filter.setValue(getPodSelectorFromServiceName(getServiceNameFromPodNameAndAppName(log.metadata.pod_name, appName), services) ?? GenericLogFilter.getDefaultOption("pod_name").value)}
                         >
                             {getServiceNameFromPodNameAndAppName(log.metadata.pod_name, appName)}
                         </LogInnerPill>
                     </StyledLogsTableData>
                 )
+            case "service_name":
+                if (log.metadata?.raw_labels?.porter_run_service_name == null || log.metadata?.raw_labels?.porter_run_service_name === "") {
+                    return null;
+                }
+                return (
+                    <StyledLogsTableData width={"100px"} key={index}>
+                        <LogInnerPill
+                            color={"white"}
+                            key={index}
+                            onClick={() => filter.setValue(log.metadata?.raw_labels?.porter_run_service_name ?? GenericLogFilter.getDefaultOption("service_name").value)}
+                        >
+                            {log.metadata.raw_labels?.porter_run_service_name}
+                        </LogInnerPill>
+                    </StyledLogsTableData>
+                )
             default:
                 return null;
         }
@@ -95,7 +113,6 @@ const StyledLogs: React.FC<Props> = ({
                     )
                 })}
             </StyledLogsTableBody>
-
         </StyledLogsTable>
     );
 };

+ 19 - 6
dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts

@@ -10,7 +10,7 @@ export interface PorterLog {
     line: AnserJsonEntry[];
     lineNumber: number;
     timestamp?: string;
-    metadata?: z.infer<typeof AgentLogMetadataSchema>;
+    metadata?: z.infer<typeof agentLogMetadataValidator>;
 }
 
 export interface PaginationInfo {
@@ -18,20 +18,31 @@ export interface PaginationInfo {
     nextCursor: string | null;
 }
 
-const AgentLogMetadataSchema = z.object({
+const rawLabelsValidator = z.object({
+    porter_run_absolute_name: z.string().optional(),
+    porter_run_app_id: z.string().optional(),
+    porter_run_app_name: z.string().optional(),
+    porter_run_app_revision_id: z.string().optional(),
+    porter_run_service_name: z.string().optional(),
+    porter_run_service_type: z.string().optional(),
+});
+export type RawLabels = z.infer<typeof rawLabelsValidator>;
+
+const agentLogMetadataValidator = z.object({
     pod_name: z.string(),
     pod_namespace: z.string(),
     revision: z.string(),
     output_stream: z.string(),
     app_name: z.string(),
+    // raw_labels: rawLabelsValidator.optional(),
 });
 
-export const AgentLogSchema = z.object({
+export const agentLogValidator = z.object({
     line: z.string(),
     timestamp: z.string(),
-    metadata: AgentLogMetadataSchema.optional(),
+    metadata: agentLogMetadataValidator.optional(),
 });
-export type AgentLog = z.infer<typeof AgentLogSchema>;
+export type AgentLog = z.infer<typeof agentLogValidator>;
 
 export interface GenericFilterOption {
     label: string;
@@ -42,7 +53,7 @@ export const GenericFilterOption = {
         return { label, value };
     }
 }
-export type LogFilterName = 'revision' | 'output_stream' | 'pod_name';
+export type LogFilterName = 'revision' | 'output_stream' | 'pod_name' | 'service_name';
 export interface GenericLogFilter {
     name: LogFilterName;
     displayName: string;
@@ -57,6 +68,8 @@ export const GenericLogFilter = {
 
     getDefaultOption: (filterName: LogFilterName) => {
         switch (filterName) {
+            case 'service_name':
+                return GenericFilterOption.of('All', 'all');
             case 'revision':
                 return GenericFilterOption.of('All', 'all');
             case 'output_stream':

+ 22 - 26
dashboard/src/main/home/app-dashboard/expanded-app/logs/utils.ts

@@ -6,7 +6,8 @@ import Anser from "anser";
 import { Context } from "shared/Context";
 import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
 import { ChartType } from "shared/types";
-import { AgentLog, AgentLogSchema, Direction, PorterLog, PaginationInfo, GenericLogFilter, LogFilterName } from "./types";
+import { AgentLog, agentLogValidator, Direction, PorterLog, PaginationInfo, GenericLogFilter, LogFilterName } from "./types";
+import { Service } from "../../new-app-flow/serviceTypes";
 
 const MAX_LOGS = 5000;
 const MAX_BUFFER_LOGS = 1000;
@@ -15,7 +16,7 @@ const QUERY_LIMIT = 1000;
 export const parseLogs = (logs: any[] = []): PorterLog[] => {
   return logs.map((log: any, idx) => {
     try {
-      const parsed: AgentLog = AgentLogSchema.parse(log);
+      const parsed: AgentLog = agentLogValidator.parse(log);
 
       // TODO Move log parsing to the render method
       const ansiLog = Anser.ansiToJson(parsed.line);
@@ -163,12 +164,14 @@ export const useLogs = (
 
     const websocketBaseURL = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/logs/loki`;
 
-    const q = new URLSearchParams({
+    const searchParams = {
       pod_selector: currentPodSelector,
       namespace,
       search_param: searchParam,
       revision: currentChart.version.toString(),
-    }).toString();
+    }
+
+    const q = new URLSearchParams(searchParams).toString();
 
     const endpoint = `${websocketBaseURL}?${q}`;
 
@@ -500,31 +503,24 @@ export const getServiceNameFromPodNameAndAppName = (podName: string, porterAppNa
     return podName.substring(0, index);
   }
 
-  return "";
-}
-
-export const getPodSelectorFromPodNameAndAppName = (podName: string, porterAppName: string) => {
-  const prefix: string = porterAppName + "-";
-  if (!podName.startsWith(prefix)) {
-    return "";
+  // if the suffix wasn't found, it's possible that the service name was too long to keep the entire suffix. example: postgres-snowflake-connector-postgres-snowflake-service-wk8gnst
+  // if this is the case, find the service name by removing everything after the last dash
+  // This is only to fix current pods; new pods will be named correctly because we imposed service name limits in https://github.com/porter-dev/porter/pull/3439
+  index = podName.lastIndexOf("-");
+  if (index !== -1) {
+    return podName.substring(0, index)
   }
 
-  podName = podName.replace(prefix, "");
-  const suffixes: string[] = ["-web", "-wkr", "-job"];
-  let index: number = -1;
-  let type = ""
+  return "";
+}
 
-  for (const suffix of suffixes) {
-    const newIndex: number = podName.lastIndexOf(suffix);
-    if (newIndex > index) {
-      index = newIndex;
-      type = suffix;
-    }
+export const getPodSelectorFromServiceName = (serviceName: string | null | undefined, services?: Service[]): string | undefined => {
+  if (serviceName == null) {
+    return undefined;
   }
-
-  if (index !== -1) {
-    return podName.substring(0, index) + type;
+  const match = services?.find(s => s.name === serviceName);
+  if (match == null) {
+    return undefined;
   }
-
-  return "";
+  return `${match.name}-${match.type == "worker" ? "wkr" : match.type}`;
 }

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

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

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

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

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

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

+ 3 - 1
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackConfigurationModal.tsx

@@ -14,7 +14,9 @@ import { Controller, useFieldArray, useFormContext } from "react-hook-form";
 import { BuildOptions, PorterAppFormData } from "lib/porter-apps";
 
 type Props = {
-  build: BuildOptions;
+  build: BuildOptions & {
+    method: "pack";
+  };
   closeModal: () => void;
   sortedStackOptions: { value: string; label: string }[];
   availableBuildpacks: Buildpack[];

+ 4 - 2
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackList.tsx

@@ -10,7 +10,9 @@ import { useFieldArray, useFormContext } from "react-hook-form";
 import { BuildOptions, PorterAppFormData } from "lib/porter-apps";
 
 interface Props {
-  build: BuildOptions;
+  build: BuildOptions & {
+    method: "pack";
+  };
   availableBuildpacks: Buildpack[];
   setAvailableBuildpacks: (buildpacks: Buildpack[]) => void;
   showAvailableBuildpacks: boolean;
@@ -142,4 +144,4 @@ const BuildpackList: React.FC<Props> = ({
   );
 };
 
-export default BuildpackList;
+export default BuildpackList;

+ 11 - 4
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx

@@ -26,7 +26,9 @@ import {
 
 type Props = {
   projectId: number;
-  build: BuildOptions;
+  build: BuildOptions & {
+    method: "pack";
+  };
   source: SourceOptions & { type: "github" };
   autoDetectionDisabled?: boolean;
 };
@@ -102,8 +104,8 @@ const BuildpackSettings: React.FC<Props> = ({
       }
       if (build.buildpacks.length) {
         const bps = build.buildpacks.map((bp) => ({
-          ...bp,
-          name: BUILDPACK_TO_NAME[bp.buildpack] ?? bp,
+          name: BUILDPACK_TO_NAME[bp.buildpack] ?? bp.buildpack,
+          buildpack: bp.buildpack,
         }));
         replace(bps);
       }
@@ -157,7 +159,12 @@ const BuildpackSettings: React.FC<Props> = ({
 
       if (!autoDetectionDisabled) {
         setValue("app.build.builder", detectedBuilder);
-        replace(defaultBuilder.detected);
+        replace(
+          defaultBuilder.detected.map((bp) => ({
+            name: bp.name,
+            buildpack: bp.buildpack,
+          }))
+        );
         setAvailableBuildpacks(defaultBuilder.others);
       } else {
         setValue("app.build.builder", detectedBuilder);

+ 537 - 0
dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx

@@ -0,0 +1,537 @@
+import React, {
+    useCallback,
+    useContext,
+    useEffect,
+    useRef,
+    useState,
+} from "react";
+
+import styled from "styled-components";
+
+import spinner from "assets/loading.gif";
+import api from "shared/api";
+import { useLogs } from "./utils";
+import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "../../expanded-app/logs/types";
+import dayjs, { Dayjs } from "dayjs";
+import Loading from "components/Loading";
+import _ from "lodash";
+import Banner from "components/porter/Banner";
+import LogSearchBar from "components/LogSearchBar";
+import LogQueryModeSelectionToggle from "components/LogQueryModeSelectionToggle";
+import Fieldset from "components/porter/Fieldset";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Container from "components/porter/Container";
+import Button from "components/porter/Button";
+import { Service } from "../../new-app-flow/serviceTypes";
+import LogFilterContainer from "../../expanded-app/logs/LogFilterContainer";
+import StyledLogs from "../../expanded-app/logs/StyledLogs";
+
+type Props = {
+    projectId: number;
+    clusterId: number;
+    appName: string;
+    serviceNames: string[];
+    deploymentTargetId: string;
+};
+
+const Logs: React.FC<Props> = ({
+    projectId,
+    clusterId,
+    appName,
+    serviceNames,
+    deploymentTargetId,
+}) => {
+    const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+    const [scrollToBottomEnabled, setScrollToBottomEnabled] = useState(true);
+    const [enteredSearchText, setEnteredSearchText] = useState("");
+    const [searchText, setSearchText] = useState("");
+    const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
+    const [notification, setNotification] = useState<string>();
+
+    const [hasPorterAgent, setHasPorterAgent] = useState(true);
+    const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+    const [isLoading, setIsLoading] = useState(true);
+    const [logsError, setLogsError] = useState<string | undefined>(undefined);
+
+    const [selectedFilterValues, setSelectedFilterValues] = useState<Record<LogFilterName, string>>({
+        service_name:  GenericLogFilter.getDefaultOption("service_name").value,
+        pod_name: "", // not supported yet
+        revision: "", // not supported yet
+        output_stream: GenericLogFilter.getDefaultOption("output_stream").value,
+    });
+
+    const isAgentVersionUpdated = (agentImage: string | undefined) => {
+        if (agentImage == null) {
+            return false;
+        }
+        const version = agentImage.split(":").pop();
+        if (version === "dev") {
+            return true;
+        }
+        //make sure version is above v3.1.3
+        if (version == null) {
+            return false;
+        }
+        const versionParts = version.split(".");
+        if (versionParts.length < 3) {
+            return false;
+        }
+        const major = parseInt(versionParts[0]);
+        const minor = parseInt(versionParts[1]);
+        const patch = parseInt(versionParts[2]);
+        if (major < 3) {
+            return false;
+        } else if (major > 3) {
+            return true;
+        }
+        if (minor < 1) {
+            return false;
+        } else if (minor > 1) {
+            return true;
+        }
+        return patch >= 7;
+    }
+
+    const [filters, setFilters] = useState<GenericLogFilter[]>([
+        {
+            name: "service_name",
+            displayName: "Service",
+            default: GenericLogFilter.getDefaultOption("service_name"),
+            options: serviceNames.map(s => {
+                return GenericFilterOption.of(s, s)
+            }) ?? [],
+            setValue: (value: string) => {
+                setSelectedFilterValues((s) => ({
+                    ...s,
+                    service_name: value,
+                }));
+            }
+        },
+        {
+            name: "output_stream",
+            displayName: "Output Stream",
+            default: GenericLogFilter.getDefaultOption("output_stream"),
+            options: serviceNames.map(s => {
+                return GenericFilterOption.of(s, s)
+            }) ?? [],
+            setValue: (value: string) => {
+                setSelectedFilterValues((s) => ({
+                    ...s,
+                    output_stream: value,
+                }));
+            }
+        },
+    ]);
+
+    const notify = (message: string) => {
+        setNotification(message);
+
+        setTimeout(() => {
+            setNotification(undefined);
+        }, 5000);
+    };
+
+    const { logs, refresh, moveCursor, paginationInfo } = useLogs(
+        projectId,
+        clusterId,
+        selectedFilterValues,
+        appName,
+        selectedFilterValues.service_name,
+        deploymentTargetId,
+        enteredSearchText,
+        notify,
+        setIsLoading,
+        selectedDate,
+    );
+
+    useEffect(() => {
+        if (!isLoading && scrollToBottomRef.current && scrollToBottomEnabled) {
+            const scrollPosition = scrollToBottomRef.current.offsetTop + scrollToBottomRef.current.offsetHeight - window.innerHeight;
+            scrollToBottomRef.current.scrollIntoView({
+                behavior: "smooth",
+                top: scrollPosition,
+            });
+        }
+    }, [isLoading, logs, scrollToBottomRef, scrollToBottomEnabled]);
+
+
+    const resetFilters = () => {
+        setSelectedFilterValues({
+            output_stream: GenericLogFilter.getDefaultOption("output_stream").value,
+            revision: "", // not supported yet
+            pod_name: "", // not supported yet
+            service_name: GenericLogFilter.getDefaultOption("service_name").value,
+        });
+    };
+
+    const onLoadPrevious = useCallback(() => {
+        if (!selectedDate) {
+            setSelectedDate(dayjs(logs[0].timestamp).toDate());
+            return;
+        }
+
+        moveCursor(Direction.backward);
+    }, [logs, selectedDate]);
+
+    const resetSearch = () => {
+        setSearchText("");
+        setEnteredSearchText("");
+        resetFilters();
+    };
+
+    const setSelectedDateIfUndefined = () => {
+        if (selectedDate == null) {
+            setSelectedDate(dayjs().toDate());
+        }
+    };
+
+    const renderContents = () => {
+        return (
+            <>
+                <FlexRow>
+                    <Flex>
+                        <LogSearchBar
+                            searchText={searchText}
+                            setSearchText={setSearchText}
+                            setEnteredSearchText={setEnteredSearchText}
+                            setSelectedDate={setSelectedDateIfUndefined}
+                        />
+                        <LogQueryModeSelectionToggle
+                            selectedDate={selectedDate}
+                            setSelectedDate={setSelectedDate}
+                            resetSearch={resetSearch}
+                        />
+                    </Flex>
+                    <Flex>
+                        <ScrollButton onClick={() => setScrollToBottomEnabled((s) => !s)}>
+                            <Checkbox checked={scrollToBottomEnabled}>
+                                <i className="material-icons">done</i>
+                            </Checkbox>
+                            Scroll to bottom
+                        </ScrollButton>
+                        <Spacer inline width="10px" />
+                        <ScrollButton
+                            onClick={() => {
+                                refresh();
+                            }}
+                        >
+                            <i className="material-icons">autorenew</i>
+                            Refresh
+                        </ScrollButton>
+                    </Flex>
+                </FlexRow>
+                <Spacer y={0.5} />
+                <>
+                    <LogFilterContainer
+                        filters={filters}
+                        selectedFilterValues={selectedFilterValues}
+                    />
+                    <Spacer y={0.5} />
+                </>
+                <LogsSectionWrapper>
+                    <StyledLogsSection>
+                        {isLoading && <Loading message="Waiting for logs..." />}
+                        {!isLoading && logs.length !== 0 && (
+                            <>
+                                <LoadMoreButton
+                                    active={
+                                        logs.length !== 0 && paginationInfo.previousCursor !== null
+                                    }
+                                    role="button"
+                                    onClick={onLoadPrevious}
+                                >
+                                    Load Previous
+                                </LoadMoreButton>
+                                <StyledLogs
+                                    logs={logs}
+                                    filters={filters}
+                                />
+                                <LoadMoreButton
+                                    active={selectedDate && logs.length !== 0}
+                                    role="button"
+                                    onClick={() => moveCursor(Direction.forward)}
+                                >
+                                    Load more
+                                </LoadMoreButton>
+                            </>
+                        )}
+                        {!isLoading && logs.length === 0 && selectedDate != null && (
+                            <Message>
+                                No logs found for this time range.
+                                <Highlight onClick={() => setSelectedDate(undefined)}>
+                                    <i className="material-icons">autorenew</i>
+                                    Reset
+                                </Highlight>
+                            </Message>
+                        )}
+                        {!isLoading && logs.length === 0 && selectedDate == null && (
+                            <Loading message="Waiting for logs..." />
+                        )}
+                        <div ref={scrollToBottomRef} />
+                    </StyledLogsSection>
+                    <NotificationWrapper
+                        key={JSON.stringify(logs)}
+                        active={!!notification}
+                    >
+                        <Banner>{notification}</Banner>
+                    </NotificationWrapper>
+                </LogsSectionWrapper>
+            </>
+        );
+    };
+
+    useEffect(() => {
+        // determine if the agent is installed properly - if not, start by render upgrade screen
+        checkForAgent();
+    }, []);
+
+    useEffect(() => {
+        if (!isPorterAgentInstalling) {
+            return;
+        }
+
+        const checkForAgentInterval = setInterval(checkForAgent, 3000);
+
+        return () => clearInterval(checkForAgentInterval);
+    }, [isPorterAgentInstalling]);
+
+    const checkForAgent = async () => {
+        const project_id = projectId
+        const cluster_id = clusterId
+
+        try {
+            const res = await api.detectPorterAgent("<token>", {}, { project_id, cluster_id });
+
+            setHasPorterAgent(true);
+
+            const agentImage = res.data?.image;
+            if (!isAgentVersionUpdated(agentImage)) {
+                notify("Porter agent is outdated. Please upgrade to see logs.");
+            }
+        } catch (err) {
+            if (err.response?.status === 404) {
+                setHasPorterAgent(false);
+            }
+        }
+    };
+
+    const installAgent = async () => {
+        const project_id = projectId;
+        const cluster_id = clusterId;
+
+        setIsPorterAgentInstalling(true);
+
+        api
+            .installPorterAgent("<token>", {}, { project_id, cluster_id })
+            .then()
+            .catch((err) => {
+                setIsPorterAgentInstalling(false);
+                console.log(err);
+            });
+    };
+
+    const triggerInstall = () => {
+        installAgent();
+    };
+
+    return isPorterAgentInstalling ? (
+        <Fieldset>
+            <Container row>
+                <Spinner src={spinner} />
+                <Spacer inline x={1} />
+                <Text color="helper">The Porter agent is being installed . . .</Text>
+            </Container>
+        </Fieldset>
+    ) : !hasPorterAgent ? (
+        <Fieldset>
+            <Text size={16}>We couldn't detect the Porter agent on your cluster</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+                In order to use the Logs tab, you need to install the Porter agent.
+            </Text>
+            <Spacer y={1} />
+            <Button onClick={() => triggerInstall()}>
+                <I className="material-icons">add</I> Install Porter agent
+            </Button>
+        </Fieldset>
+    ) : logsError ? (
+        <Fieldset>
+            <Container row>
+                <WarnI className="material-icons">warning</WarnI>
+                <Text color="helper">
+                    Porter encountered an error retrieving logs for this application.
+                </Text>
+            </Container>
+        </Fieldset>
+    ) : (
+        renderContents()
+    );
+};
+
+export default Logs;
+
+const I = styled.i`
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const WarnI = styled.i`
+  font-size: 18px;
+  display: flex;
+  align-items: center;
+  margin-right: 10px;
+  justify-content: center;
+  opacity: 0.6;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+`;
+
+const Checkbox = styled.div<{ checked: boolean }>`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  border-radius: 3px;
+  background: ${(props) => (props.checked ? "#ffffff22" : "#ffffff11")};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props) => (props.checked ? "" : "none")};
+  }
+`;
+
+const ScrollButton = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  height: 30px;
+  font-size: 13px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  padding: 10px;
+  padding-left: 8px;
+  > i {
+    font-size: 16px;
+    margin-right: 5px;
+  }
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: #8590ff;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+`;
+
+const StyledLogsSection = styled.div`
+  width: 100%;
+  height: 600px;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  background: #000000;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const LoadMoreButton = styled.div<{ active: boolean }>`
+  width: 100%;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  padding-block: 10px;
+  background: #1f2023;
+  cursor: pointer;
+  font-family: monospace;
+`;
+
+const NotificationWrapper = styled.div<{ active?: boolean }>`
+  position: absolute;
+  bottom: 10px;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  left: 50%;
+  transform: translateX(-50%);
+  width: fit-content;
+  background: #101420;
+  z-index: 9999;
+
+  @keyframes bounceIn {
+    0% {
+      transform: translateZ(-1400px);
+      opacity: 0;
+    }
+    100% {
+      transform: translateZ(0);
+      opacity: 1;
+    }
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;

+ 407 - 0
dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts

@@ -0,0 +1,407 @@
+import dayjs, { Dayjs } from "dayjs";
+import _ from "lodash";
+import { useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import Anser from "anser";
+import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
+import { AgentLog, agentLogValidator, Direction, PorterLog, PaginationInfo, LogFilterName } from "../../expanded-app/logs/types";
+import { Service } from "../../new-app-flow/serviceTypes";
+
+const MAX_LOGS = 5000;
+const MAX_BUFFER_LOGS = 1000;
+const QUERY_LIMIT = 1000;
+
+export const parseLogs = (logs: any[] = []): PorterLog[] => {
+  return logs.map((log: any, idx) => {
+    try {
+      const parsed: AgentLog = agentLogValidator.parse(log);
+      // TODO Move log parsing to the render method
+      const ansiLog = Anser.ansiToJson(parsed.line);
+      return {
+        line: ansiLog,
+        lineNumber: idx + 1,
+        timestamp: parsed.timestamp,
+        metadata: parsed.metadata,
+      };
+    } catch (err) {
+      console.log(err)
+      return {
+        line: Anser.ansiToJson(log.toString()),
+        lineNumber: idx + 1,
+        timestamp: undefined,
+      }
+    }
+  });
+};
+
+export const useLogs = (
+    projectID: number,
+    clusterID: number,
+  selectedFilterValues: Record<LogFilterName, string>,
+  appName: string,
+  serviceName: string,
+  deploymentTargetId: string,
+  searchParam: string,
+  notify: (message: string) => void,
+  setLoading: (isLoading: boolean) => void,
+  // if setDate is set, results are not live
+  setDate?: Date,
+  timeRange?: {
+    startTime?: Dayjs,
+    endTime?: Dayjs,
+  },
+) => {
+  const isLive = !setDate;
+  const logsBufferRef = useRef<PorterLog[]>([]);
+  const [logs, setLogs] = useState<PorterLog[]>([]);
+  const [paginationInfo, setPaginationInfo] = useState<PaginationInfo>({
+    previousCursor: null,
+    nextCursor: null,
+  });
+
+  // if we are live:
+  // - start date is initially set to 2 weeks ago
+  // - the query has an end date set to current date
+  // - moving the cursor forward does nothing
+
+  // if we are not live:
+  // - end date is set to the setDate
+  // - start date is initially set to 2 weeks ago, but then gets set to the
+  //   result of the initial query
+  // - moving the cursor both forward and backward changes the start and end dates
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+
+  const updateLogs = (
+    newLogs: PorterLog[],
+    direction: Direction = Direction.forward
+  ) => {
+    // Nothing to update here
+    if (!newLogs.length) {
+      return;
+    }
+
+    setLogs((logs) => {
+      let updatedLogs = _.cloneDeep(logs);
+      /**
+       * If direction = Direction.forward, we want to append the new logs
+       * at the end of the current logs, else we want to append before the current logs
+       *
+       */
+      if (direction === Direction.forward) {
+        const lastLineNumber = updatedLogs.at(-1)?.lineNumber ?? 0;
+
+        updatedLogs.push(
+          ...newLogs.map((log, idx) => ({
+            ...log,
+            lineNumber: lastLineNumber + idx + 1,
+          }))
+        );
+
+        // For direction = Direction.forward, remove logs from the front
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+          updatedLogs = updatedLogs.slice(logsToBeRemoved);
+        }
+      } else {
+        updatedLogs = newLogs.concat(
+          updatedLogs.map((log) => ({
+            ...log,
+            lineNumber: log.lineNumber + newLogs.length,
+          }))
+        );
+
+        // For direction = Direction.backward, remove logs from the back
+        if (updatedLogs.length > MAX_LOGS) {
+          const logsToBeRemoved =
+            newLogs.length < MAX_BUFFER_LOGS ? newLogs.length : MAX_BUFFER_LOGS;
+
+          updatedLogs = updatedLogs.slice(0, logsToBeRemoved);
+        }
+      }
+
+      return updatedLogs;
+    });
+  };
+
+  /**
+   * Flushes the logs buffer. If `discard` is true,
+   * it will update `current logs` before executing
+   * the flush operation
+   */
+  const flushLogsBuffer = (discard: boolean = false) => {
+    if (!discard) {
+      updateLogs(logsBufferRef.current ?? []);
+    }
+
+    logsBufferRef.current = [];
+  };
+
+  const pushLogs = (newLogs: PorterLog[]) => {
+    logsBufferRef.current.push(...newLogs);
+
+    if (logsBufferRef.current.length >= MAX_BUFFER_LOGS) {
+      flushLogsBuffer();
+    }
+  };
+
+  const setupWebsocket = (websocketKey: string) => {
+    const websocketBaseURL = `/api/projects/${projectID}/clusters/${clusterID}/apps/logs/loki`;
+
+    const searchParams = {
+      app_name: appName,
+      service_name: serviceName,
+      deployment_target_id: deploymentTargetId,
+      search_param: searchParam,
+    }
+
+    const q = new URLSearchParams(searchParams).toString();
+
+    const endpoint = `${websocketBaseURL}?${q}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        // Nothing to do here
+        if (evt.data == null) {
+          return;
+        }
+        const jsonData = evt.data.trim().split("\n")
+        const newLogs: any[] = [];
+        jsonData.forEach((data: string) => {
+          try {
+            const jsonLog = JSON.parse(data);
+            newLogs.push(jsonLog)
+          } catch (err) {
+            // TODO: better error handling
+            // console.log(err)
+          }
+        });
+        const newLogsParsed = parseLogs(newLogs);
+        pushLogs(newLogsParsed);
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
+  };
+
+  const queryLogs = async (
+    startDate: string,
+    endDate: string,
+    direction: Direction,
+    limit: number = QUERY_LIMIT
+  ): Promise<{
+    logs: PorterLog[];
+    previousCursor: string | null;
+    nextCursor: string | null;
+  }> => {
+    try {
+      const getLogsReq = {
+        app_name: appName,
+        service_name: serviceName,
+        deployment_target_id: deploymentTargetId,
+        search_param: searchParam,
+        start_range: startDate,
+        end_range: endDate,
+        limit,
+        direction,
+      };
+
+      const logsResp = await api.appLogs(
+          "<token>",
+          getLogsReq,
+          {
+            cluster_id: clusterID,
+            project_id: projectID,
+          }
+      )
+
+      if (logsResp.data == null) {
+        return {
+          logs: [],
+          previousCursor: null,
+          nextCursor: null,
+        };
+      }
+
+      const newLogs = parseLogs(logsResp.data.logs);
+      if (direction === Direction.backward) {
+        newLogs.reverse();
+      }
+      return {
+        logs: newLogs,
+        previousCursor:
+          // There are no more historical logs so don't set the previous cursor
+          newLogs.length < QUERY_LIMIT && direction == Direction.backward
+            ? null
+            : logsResp.data.backward_continue_time,
+        nextCursor: logsResp.data.forward_continue_time,
+      };
+    } catch {
+      return {
+        logs: [],
+        previousCursor: null,
+        nextCursor: null,
+      };
+    }
+  };
+
+  const refresh = async () => {
+    setLoading(true);
+    setLogs([]);
+    flushLogsBuffer(true);
+    const endDate = timeRange?.endTime != null ? timeRange.endTime : dayjs(setDate);
+    const oneDayAgo = timeRange?.startTime != null ? timeRange.startTime : endDate.subtract(1, "day");
+
+    const { logs: initialLogs, previousCursor, nextCursor } = await queryLogs(
+      oneDayAgo.toISOString(),
+      endDate.toISOString(),
+      Direction.backward
+    );
+
+    setPaginationInfo({
+      previousCursor,
+      nextCursor,
+    });
+
+    updateLogs(initialLogs);
+
+    if (!isLive && !initialLogs.length) {
+      notify(
+        "You have no logs for this time period. Try with a different time range."
+      );
+    }
+
+    closeAllWebsockets();
+    const suffix = Math.random().toString(36).substring(2, 15);
+    const websocketKey = `${appName}-${serviceName}-websocket-${suffix}`;
+
+    setLoading(false);
+
+    if (isLive) {
+      setupWebsocket(websocketKey);
+
+    }
+  };
+
+  const moveCursor = async (direction: Direction) => {
+    if (direction === Direction.backward) {
+      // we query by setting the endDate equal to the previous startDate, and setting the direction
+      // to "backward"
+      const refDate = paginationInfo.previousCursor ?? dayjs().toISOString();
+      const oneDayAgo = dayjs(refDate).subtract(1, "day");
+
+      const { logs: newLogs, previousCursor } = await queryLogs(
+        oneDayAgo.toISOString(),
+        refDate,
+        Direction.backward
+      );
+
+      const logsToUpdate = paginationInfo.previousCursor
+        ? newLogs.slice(0, -1)
+        : newLogs;
+
+      updateLogs(logsToUpdate, direction);
+
+      if (!logsToUpdate.length) {
+        notify("You have reached the beginning of the logs");
+      }
+
+      setPaginationInfo((paginationInfo) => ({
+        ...paginationInfo,
+        previousCursor,
+      }));
+    } else {
+      if (isLive) {
+        return;
+      }
+
+      // we query by setting the startDate equal to the previous endDate, setting the endDate equal to the
+      // current time, and setting the direction to "forward"
+      const refDate = paginationInfo.nextCursor ?? dayjs(setDate).toISOString();
+      const currDate = dayjs();
+
+      const { logs: newLogs, nextCursor } = await queryLogs(
+        refDate,
+        currDate.toISOString(),
+        Direction.forward
+      );
+
+      const logsToUpdate = paginationInfo.nextCursor
+        ? newLogs.slice(1)
+        : newLogs;
+
+      // If previously we had next cursor set, it is likely that the log might have a duplicate entry so we ignore the first line
+      updateLogs(logsToUpdate);
+
+      if (!logsToUpdate.length) {
+        notify("You are already at the latest logs");
+      }
+
+      setPaginationInfo((paginationInfo) => ({
+        ...paginationInfo,
+        nextCursor,
+      }));
+    }
+  };
+
+  useEffect(() => {
+    setLogs([]);
+    flushLogsBuffer(true);
+  }, []);
+
+  /**
+   * In some situations, we might never hit the limit for the max buffer size.
+   * An example is if the total logs for the pod < MAX_BUFFER_LOGS.
+   *
+   * For handling situations like this, we would want to force a flush operation
+   * on the buffer so that we dont have any stale logs
+   */
+  useEffect(() => {
+    /**
+     * We don't want users to wait for too long for the initial
+     * logs to appear. So we use a setTimeout for 1s to force-flush
+     * logs after 1s of load
+     */
+    setTimeout(flushLogsBuffer, 500);
+
+    const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000);
+
+    return () => clearInterval(flushLogsBufferInterval);
+  }, []);
+
+  useEffect(() => {
+    refresh();
+  }, [appName, serviceName, deploymentTargetId, searchParam, setDate, selectedFilterValues]);
+
+  useEffect(() => {
+    // if the streaming is no longer live, close all websockets
+    if (!isLive) {
+      closeAllWebsockets();
+    }
+  }, [isLive]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  return {
+    logs,
+    refresh,
+    moveCursor,
+    paginationInfo,
+  };
+};

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